Trước Java 8, câu trả lời rất gọn: interface chỉ khai phương thức, lớp trừu tượng có mã. Chọn cái nào là rõ ngay.

Từ Java 8, interface có phương thức default — tức là có mã. Java 9 thêm phương thức private trong interface. Ranh giới mờ đi đáng kể, và câu hỏi "dùng cái nào" trở nên đáng bàn thật sự.

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.

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. Đâ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 defaultstatic 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()final — không ai đổi được trình tự.

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

Trạng thái soLanDocprivate — 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à XServiceXServiceImpl, 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.

Ngày mai ta đi sâu vào defaultstatic 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.