Từ Java 8, interface có phương thức default — tức là có mã — nên cái ranh giới cũ "interface chỉ khai, lớp trừu tượng mới có mã" mờ hẳn đi. Nhưng hai khác biệt vẫn còn thật, và chúng gói gọn trong một hình ảnh: lớp trừu tượng là gia đình bạn sinh ra trong đó — bạn có đúng một, và thừa hưởng cả cái nếp nhà của nó (trạng thái, khởi tạo); còn interface là tấm thẻ bạn đeo lên — nói bạn làm được gì, không nói bạn là ai, và bạn đeo bao nhiêu tấm cũng được. Có một dòng máu, nhưng vô số kỹ năng.

Sau vài năm dùng cả hai, tôi rút xuống còn hai câu hỏi. Nhưng trước hết phải thấy rõ hai thứ còn khác nhau ở đâu.

Khác biệt thứ nhất: trạng thái

Lớp trừu tượng giữ được trường và có hàm khởi tạo:

static abstract class NguonDuLieu {
    private final String ten;
    private int soLanDoc = 0;                 // trạng thái dùng chung cho mọi lớp con
    protected NguonDuLieu(String ten) { this.ten = ten; }

    public final String doc() {               // khuôn cố định
        soLanDoc++;
        return "[" + ten + " lần " + soLanDoc + "] " + docThat();
    }
    protected abstract String docThat();       // phần để trống cho lớp con
}
[tệp lần 1] nội dung từ đĩa
[tệp lần 2] nội dung từ đĩa
[mạng lần 1] nội dung từ HTTP

Bộ đếm soLanDoc là riêng của từng đối tượng, và lớp con không cần biết nó tồn tại. Interface không làm được việc này — cái thẻ không giữ được đồ đạc.

Interface có khai được trường, nhưng mọi trường trong interface đều ngầm là public static final:

HangSo.java:3: error: cannot assign a value to static final variable dem
    void thu() { dem = 5; }

Nghĩa là hằng số dùng chung, không phải trạng thái của đối tượng.

Khác biệt thứ hai: một cha, nhiều giao diện

class Con extends Cha1, Cha2 {}
                      ^
error: '{' expected

Java không cho kế thừa nhiều lớp — cú pháp thậm chí không chấp nhận. Nhưng cài bao nhiêu interface cũng được:

static class KetNoi implements CoTheDong, CoTheLamMoi { ... }
  đã đóng kết nối
  làm mới mặc định
  là CoTheDong?   true
  là CoTheLamMoi? true

Một đối tượng mang nhiều vai trò cùng lúc — đeo nhiều thẻ. Đây là điểm mạnh lớn nhất của interface, và cũng là lý do gần như mọi kiểu công khai trong thiết kế hiện đại đều là interface.

Hai câu hỏi để chọn

Câu 1: các lớp cài đặt có cần chia sẻ trạng thái hoặc mã khởi tạo không?

Có → lớp trừu tượng. Bộ đếm, bộ nhớ đệm, tham chiếu tới tài nguyên chung — những thứ đó cần trường thể hiện, và interface không giữ được.

Câu 2: kiểu này có phải là một "vai trò" mà nhiều loại đối tượng khác nhau đều có thể mang không?

Có → interface. Comparable, Closeable, Serializable là vai trò: một String, một LocalDate và một đơn hàng chẳng liên quan gì tới nhau nhưng đều so sánh được.

Cả hai cùng "có" thì dùng cả hai: interface làm kiểu công khai, lớp trừu tượng làm nền chung cho các bản cài đặt. Đây chính là mẫu mà JDK dùng khắp nơi — List là interface, AbstractList là lớp trừu tượng, ArrayList kế thừa lớp trừu tượng và cài interface.

Và khi lưỡng lự, tôi chọn interface. Lý do thực dụng: một lớp chỉ có một cha, nên "suất kế thừa" là tài nguyên khan hiếm — tiêu nó cho lớp trừu tượng của bạn nghĩa là người dùng không kế thừa được gì khác nữa. Interface thì không giới hạn.

Chuyển từ lớp trừu tượng sang interface

Giả sử ban đầu ta viết thế này:

abstract class BoLoc {
    protected String ten;
    abstract boolean chapNhan(String dong);
    void inTen() { System.out.println("Bộ lọc: " + ten); }
}

Vấn đề lộ ra khi có một lớp đã kế thừa lớp khác mà cũng cần lọc — nó không kế thừa BoLoc được nữa.

Chuyển sang interface, giữ nguyên phần dùng chung bằng default:

interface BoLoc {
    boolean chapNhan(String dong);
    default String ten() { return getClass().getSimpleName(); }
    default void inTen() { System.out.println("Bộ lọc: " + ten()); }
}

Ba thay đổi: trường ten thành phương thức ten() — vì interface không giữ trường; mã dùng chung chuyển sang default; và giờ bất kỳ lớp nào cũng cài được, dù nó đang kế thừa ai.

Đổi lại, mọi lớp cài đặt phải tự lo phần trạng thái nếu cần. Đó là cái giá.

Interface một phương thức thì thành lambda

Interface chỉ có đúng một phương thức trừu tượng thì viết được bằng lambda:

interface CoTen {
    String ten();
    default String chao() { return "Xin chào " + ten(); }
    static CoTen cua(String t) { return () -> t; }
}
Xin chào Minh

Phương thức default và static không tính vào số phương thức trừu tượng, nên CoTen vẫn là giao diện hàm. Đây là nền tảng của toàn bộ phần lập trình hàm ở chặng sau — Runnable, Comparator, Function đều là interface một phương thức.

Lớp trừu tượng thì không bao giờ viết bằng lambda được, kể cả khi nó chỉ có một phương thức trừu tượng. Riêng điểm này cũng đủ là lý do chọn interface cho các kiểu callback.

Đánh dấu @FunctionalInterface lên interface bạn muốn dùng làm lambda. Nó không bắt buộc, nhưng trình biên dịch sẽ báo lỗi nếu ai đó lỡ thêm phương thức trừu tượng thứ hai — thay vì để mọi lambda đang dùng nó gãy hàng loạt.

abstract dùng thế nào

Lớp có ít nhất một phương thức abstract thì bản thân lớp phải abstract, và không tạo đối tượng trực tiếp được:

new NguonDuLieu("x");    // lỗi biên dịch: NguonDuLieu is abstract

Nhưng lớp trừu tượng vẫn có hàm khởi tạo, và lớp con gọi nó qua super(...). Nhiều người mới nhầm rằng lớp trừu tượng không có hàm khởi tạo vì không new được — hai chuyện khác nhau.

Lớp trừu tượng cũng được phép không có phương thức trừu tượng nào. Khi đó abstract chỉ mang nghĩa "lớp này là nền, đừng dùng trực tiếp".

Mẫu phương thức khuôn mẫu

Đây là chỗ lớp trừu tượng thắng rõ ràng nhất, và cũng là chỗ tôi vẫn dùng nó:

public final void doc() {        // final: lớp con không phá được khuôn
    soLanDoc++;
    return "..." + docThat();    // chỉ chỗ này để trống
}
protected abstract String docThat();

Lớp cha giữ trình tự, lớp con điền chi tiết. Ba chi tiết làm nên sự an toàn của mẫu này:

doc() là final — không ai đổi được trình tự.

docThat() là protected abstract — bắt buộc cài, và chỉ lớp con thấy.

Trạng thái soLanDoc là private — lớp con không đụng tới được, nên không phá hỏng.

So với bài kế thừa hôm trước, đây chính là "lớp cha được thiết kế để kế thừa": mọi thứ mở ra đều có chủ đích, mọi thứ còn lại đều khoá.

Bảng tổng kết

Lớp trừu tượng Interface
Trường thể hiện ✅ ❌ (chỉ hằng số)
Hàm khởi tạo ✅ ❌
Mã dùng chung ✅ ✅ (default, private)
Phương thức static ✅ ✅ (từ Java 8)
Một lớp dùng được mấy cái 1 không giới hạn
Viết bằng lambda ❌ ✅ (nếu một phương thức trừu tượng)
Mức truy cập của thành viên bốn mức public (và private từ Java 9)

Dòng cuối đáng chú ý: mọi phương thức trong interface đều ngầm public. Không có phương thức protected trong interface — nghĩa là bạn không giấu được gì trong hợp đồng công khai.

Chọn thế nào trong thực tế

Với mã ứng dụng thường ngày, tôi dùng interface cho gần như mọi ranh giới: giữa tầng nghiệp vụ và tầng lưu trữ, giữa mã của mình và thư viện ngoài, và cho mọi callback.

Lớp trừu tượng dành cho hai việc: mẫu khuôn mẫu như trên, và khi có nhiều lớp con chia sẻ một khối trạng thái thật.

Và một lời khuyên cuối: đừng tạo interface chỉ vì "để sau này dễ thay". Interface có đúng một bản cài đặt, tên là XService và XServiceImpl, là mẫu phổ biến tới mức thành thói quen — nhưng nó thêm một tầng gián tiếp mà không cho lại gì. Khi thật sự cần bản thứ hai, tách interface ra lúc đó cũng chỉ mất vài phút với IDE.

Muốn tự soi trong ba mươi giây, chạy grep -rl "interface " --include='*.java' src/main | wc -l rồi find src/main -name "*Impl.java" | wc -l. Hai con số gần bằng nhau nghĩa là phần lớn interface của bạn có đúng một bản cài đặt — chính mẫu XService/XServiceImpl mà bài này khuyên tránh. Thử gộp một cặp như vậy lại: xoá interface, đổi chỗ tiêm phụ thuộc sang lớp, chạy test. Mất chưa tới năm phút, và nếu chẳng mất gì cả thì đó là bằng chứng tầng gián tiếp ấy chưa bao giờ có tác dụng.

Mẫu số chung

Cái ranh "dòng máu hay tấm thẻ" không phải chuyện riêng của Java — mọi ngôn ngữ hướng đối tượng đều phải chia đôi giữa "thứ mang trạng thái, kế thừa một lần" và "vai trò nhiều loại đối tượng đều đóng được". C# gần như song sinh: lớp trừu tượng một cha, interface nhiều, và từ C# 8 interface cũng có phương thức mặc định — cùng chỗ mờ ranh giới. Nhưng thú vị là các ngôn ngữ mới hơn nghiêng hẳn về phía "tấm thẻ". Go không có lớp và kế thừa gì cả: chỉ có interface (còn khớp ngầm, không cần khai implements) cộng nhúng struct cho phần mã dùng chung — interface là trừu tượng duy nhất. Rust cũng bỏ kế thừa, dùng trait: trait có phương thức mặc định như default, đeo được nhiều như interface, và là cách duy nhất để trừu tượng hoá. Kotlin giữ cả hai như Java. Scala thì trait làm được cả hai — vừa mang trạng thái vừa đeo nhiều — xoá luôn ranh giới.

Điểm chung, và là thứ đáng mang theo: trục lựa chọn ở mọi ngôn ngữ là một — thứ này chia sẻ trạng thái và một dòng kế thừa (lớp trừu tượng) hay là một vai trò mà nhiều loại không liên quan đều đóng được (interface/trait, nhiều, ít hoặc không trạng thái). Và xu hướng của cả ngành nghiêng về vế thứ hai tới mức Go và Rust vứt hẳn kế thừa lớp đi. Nên lời khuyên thực dụng cũng hội tụ: khi lưỡng lự, chọn interface — suất kế thừa đơn là tài nguyên khan hiếm, tiêu nó phải có lý do, còn thẻ thì đeo bao nhiêu cũng được. Chỉ rút về lớp trừu tượng khi bạn thật sự cần cái nếp nhà chung: trạng thái, hàm khởi tạo, và một cái khuôn cố định mà lớp con chỉ được điền vào chỗ trống.

Ngày mai ta đi sâu vào default và static trong interface — vì sao Java 8 buộc phải thêm chúng, và bài toán kim cương được giải ra sao.