Năm 2014, nhóm phát triển Java đứng trước một bài toán khó.

Họ muốn thêm Stream vào Java 8. Để làm được, mọi Collection phải có phương thức stream(). Nhưng Collection là interface, và thêm một phương thức vào interface nghĩa là mọi lớp cài đặt nó trên toàn thế giới lập tức không biên dịch được nữa — kể cả những lớp trong mã của bạn, viết từ năm 2004, mà không ai còn nhớ ở đâu.

Có hai lựa chọn: bỏ Stream, hoặc phá vỡ tính tương thích ngược — thứ mà Java giữ gìn suốt ba thập kỷ.

Họ chọn lựa chọn thứ ba: cho phép interface mang mã sẵn.

default: phương thức có thân trong interface

interface CoTheLamMoi {
    default void lamMoi() {
        System.out.println("làm mới mặc định");
    }
}

Lớp cài đặt không bắt buộc viết lamMoi() — nếu không viết thì dùng bản mặc định. Nhờ vậy Collection thêm được stream(), forEach(), removeIf() mà không lớp nào gãy.

Đây không phải mẹo vặt: nó thay đổi hẳn cách interface tiến hoá. Trước Java 8, thêm phương thức vào một interface công khai là chuyện gần như không thể. Sau Java 8, thư viện thêm được tính năng mà người dùng không phải sửa gì.

Nhưng bài toán kim cương quay lại

Interface mang mã, và một lớp cài được nhiều interface. Vậy nếu hai interface cùng có default trùng tên?

interface Bay { default String diChuyen() { return "bay"; } }
interface Boi { default String diChuyen() { return "bơi"; } }
class Vit implements Bay, Boi { }
KimCuong.java:3: error: types Bay and Boi are incompatible;
class Vit implements Bay, Boi { }
^
  class Vit inherits unrelated defaults for diChuyen() from types Bay and Boi

Java không đoán. Nó bắt bạn tự quyết:

static class Vit implements Bay, Boi {
    @Override public String diChuyen() {
        return Bay.super.diChuyen() + " và " + Boi.super.diChuyen();
    }
}
Vịt: bay và bơi

Cú pháp TenInterface.super.phuongThuc() là thứ chỉ dùng được trong đúng tình huống này. Nó cho phép gọi bản mặc định của một interface cụ thể.

Đây là lý do Java vẫn nói mình "không có đa kế thừa": nó có đa kế thừa hành vi qua default, nhưng không có đa kế thừa trạng thái. Xung đột hành vi thì trình biên dịch bắt bạn giải quyết ngay lúc biên dịch; xung đột trạng thái thì không có cách nào giải quyết sạch sẽ — đó chính là lý do C++ khổ sở với đa kế thừa, và là lý do interface Java không giữ trường.

Ba luật ưu tiên khi có xung đột

Khi cùng một phương thức đến từ nhiều nguồn, Java giải theo thứ tự:

Lớp thắng interface. Nếu lớp cha có phương thức đó, bản của lớp cha được dùng, mọi default bị bỏ qua. Người ta gọi luật này là "class wins".

Interface con thắng interface cha. B extends A mà cả hai cùng có default thì bản của B thắng — vì nó cụ thể hơn.

Còn lại thì lỗi biên dịch, và bạn phải ghi đè tường minh như ví dụ con vịt ở trên.

Luật thứ nhất đáng nhớ nhất, vì nó tạo ra một tình huống bất ngờ:

interface Chao { default String noi() { return "từ interface"; } }
class Co { public String noi() { return "từ lớp cha"; } }
class Ket extends Co implements Chao { }

new Ket().noi();     // "từ lớp cha"

Ngay cả khi Chao được khai sau, bản của lớp cha vẫn thắng. Luật này bảo vệ tính tương thích: thêm một default vào interface không bao giờ làm đổi hành vi của lớp đã có mã sẵn.

static trong interface

Java 8 cũng cho phép phương thức tĩnh trong interface:

interface Bay {
    static String loai() { return "phương tiện trên không"; }
}
static của interface: phương tiện trên không

Khác default ở hai chỗ quan trọng: nó không được kế thừa — lớp cài đặt không gọi được loai() như phương thức của mình, phải gọi qua Bay.loai(). Và nó không bị ghi đè.

Dùng để làm gì? Chủ yếu để đặt hàm nhà máy ngay cạnh kiểu:

interface CoTen {
    String ten();
    static CoTen cua(String t) { return () -> t; }
}

CoTen x = CoTen.cua("Minh");

Trước Java 8, kiểu hàm này phải nằm trong một lớp tiện ích riêng — Collections cho Collection, Arrays cho mảng. Giờ chúng nằm cùng chỗ với interface, dễ tìm hơn hẳn. List.of(), Map.entry(), Comparator.comparing() đều là phương thức tĩnh của interface.

private trong interface, từ Java 9

default rồi thì sớm muộn cũng có hai default dùng chung một đoạn mã. Java 9 cho phép phương thức private trong interface để chứa phần chung đó:

interface BoLoc {
    default boolean chapNhanTatCa(List<String> ds) { return ds.stream().allMatch(this::kiem); }
    default boolean chapNhanMot(List<String> ds)   { return ds.stream().anyMatch(this::kiem); }
    private boolean kiem(String s) { return s != null && !s.isBlank(); }
}

kiem không lọt ra ngoài hợp đồng công khai. Đây là cách duy nhất để giấu chi tiết trong interface.

Đừng biến interface thành lớp trừu tượng

default là công cụ mạnh, và như mọi công cụ mạnh, nó dễ bị lạm dụng. Vài ranh giới tôi tự đặt:

default sinh ra để tiến hoá interface, không phải để chứa logic nghiệp vụ. Thấy một interface có năm phương thức default dài ba chục dòng thì đó nên là lớp trừu tượng.

default không truy cập được trạng thái, nên mọi thứ nó làm chỉ dựa trên các phương thức trừu tượng khác. Nếu bạn thấy mình muốn có trường, đó là tín hiệu đã chọn sai công cụ.

Cẩn thận khi thêm default vào interface đã có nhiều bản cài đặt. Nó biên dịch được, nhưng bản mặc định có thể sai với một số lớp con — và chúng sẽ im lặng dùng bản sai đó thay vì báo lỗi. Trước Java 8, thêm phương thức trừu tượng làm mã gãy ngay lúc biên dịch, mà gãy sớm thì tốt hơn sai muộn.

Có một chỗ default không giúp được: không ghi đè được phương thức của Object. Interface không khai default equals(), hashCode() hay toString() được — trình biên dịch từ chối. Lý do là luật "lớp thắng interface": Object là cha của mọi lớp, nên bản của nó luôn thắng, và một default như vậy sẽ không bao giờ chạy.

Nhìn lại Collection của bạn

Mở java.util.Collection trong IDE và xem có bao nhiêu phương thức default. Bạn sẽ thấy stream(), parallelStream(), removeIf(), spliterator(), forEach() (kế thừa từ Iterable).

Tất cả đều được thêm vào Java 8 mà không một dòng mã nào trên thế giới phải sửa. Đó là toàn bộ lý do default tồn tại — và cũng là ví dụ đáng học về việc thiết kế ngôn ngữ phải cân giữa tính năng mới và hàng tỷ dòng mã đã viết.

Thử ba mươi giây

Viết hai interface có default trùng tên, cho một lớp cài cả hai, và đọc thông báo lỗi của javac. Rồi sửa bằng A.super.x().

Sau đó thử thêm một lớp cha có phương thức cùng tên và bỏ phần ghi đè đi — bạn sẽ thấy mã lại biên dịch được, và bản của lớp cha thắng. Ba mươi giây đó dạy luật "class wins" chắc hơn đọc mười lần.

Ngày mai: equalshashCode — hợp đồng năm điều khoản, và một ví dụ làm mất dữ liệu trong HashMap khi phá vỡ nó.