Có một loại lỗi mà mọi lập trình viên đều gặp: bạn thêm một trường hợp mới vào hệ thống — một loại thanh toán, một trạng thái đơn hàng, một định dạng file — rồi quên cập nhật một trong mười chỗ đang xử lý các trường hợp đó.

Mã vẫn biên dịch. Test cũ vẫn xanh, vì chúng không biết về trường hợp mới. Lỗi chỉ hiện ra khi có dữ liệu thật đi qua đúng nhánh bị quên.

sealed biến loại lỗi đó thành lỗi biên dịch.

Khai một hệ phân cấp đóng

sealed interface Hinh permits Tron, ChuNhat, TamGiac {}

record Tron(double r) implements Hinh {}
record ChuNhat(double rong, double cao) implements Hinh {}
record TamGiac(double day, double cao) implements Hinh {}

Từ khoá permits liệt kê tất cả lớp được phép cài đặt Hinh. Không ai khác được, kể cả trong dự án của bạn:

Lau.java:1: error: class is not allowed to extend sealed class: Hinh
        (as it is not listed in its 'permits' clause)
record Lau(double x) implements Hinh {}
^

Danh sách này còn đọc được lúc chạy:

  các lớp con được phép: [class Tron, class ChuNhat, class TamGiac]
  Hinh có sealed không : true

Phần thưởng: switch không cần default

static double dienTich(Hinh h) {
    return switch (h) {
        case Tron t     -> Math.PI * t.r() * t.r();
        case ChuNhat c  -> c.rong() * c.cao();
        case TamGiac t  -> t.day() * t.cao() / 2;
    };
}
  Tron[r=1.0]                  dt=    3.14
  ChuNhat[rong=2.0, cao=3.0]   dt=    6.00
  TamGiac[day=4.0, cao=5.0]    dt=   10.00

Không có default, và trình biên dịch vẫn chấp nhận — vì nó biết Hinh chỉ có đúng ba lớp con, và cả ba đều đã được xử lý. switch như vậy gọi là vét cạn.

Nghe như tiện lợi vặt. Nhưng giá trị thật nằm ở chuyện tiếp theo.

Phép thử: thêm một loại hình mới

Giờ tôi thêm Vuong vào hệ phân cấp và cố tình không đụng vào dienTich:

sealed interface Hinh permits Tron, ChuNhat, TamGiac, Vuong {}
record Vuong(double canh) implements Hinh {}

Biên dịch:

Dien.java:3: error: the switch expression does not cover all possible input values
        return switch (h) {
               ^
1 error

Trình biên dịch chỉ thẳng vào dòng cần sửa.

So sánh với cách viết không có sealed:

static double dienTich(Hinh h) {
    if (h instanceof Tron t) return Math.PI * t.r() * t.r();
    if (h instanceof ChuNhat c) return c.rong() * c.cao();
    if (h instanceof TamGiac t) return t.day() * t.cao() / 2;
    throw new IllegalArgumentException("hình lạ: " + h);   // hoặc return 0
}

Thêm Vuong thì mã này vẫn biên dịch. Lỗi chỉ xuất hiện lúc chạy, khi có một hình vuông thật đi qua — có thể là trên máy chủ sản xuất, ba tuần sau.

Đó là toàn bộ lý do sealed tồn tại: chuyển việc phát hiện lỗi từ lúc chạy sang lúc biên dịch.

Với một dự án có mười chỗ switch trên cùng một hệ phân cấp, thêm một lớp con là mười lỗi biên dịch, và bạn sửa hết trong mười phút. Không có sealed, đó là mười quả mìn rải rác.

Ba luật khi khai sealed

Mỗi lớp con phải khai một trong ba từ khoá:

final — không ai kế thừa tiếp. Đây là lựa chọn thường gặp nhất, và record thì tự động final.

sealed — tiếp tục đóng, kèm permits riêng. Dùng khi cần cây phân cấp nhiều tầng.

non-sealed — mở lại cho bất kỳ ai. Nó phá vỡ tính vét cạn ở nhánh đó, nên chỉ dùng khi có lý do rõ ràng, ví dụ một nhánh dành cho người dùng thư viện tự mở rộng.

sealed interface Hinh permits Tron, DaGiac {}
final record Tron(double r) implements Hinh {}
sealed interface DaGiac extends Hinh permits ChuNhat, TamGiac {}   // đóng tiếp

Lớp con phải cùng module, hoặc cùng gói nếu không dùng module. Không thể có một lớp con nằm ở jar khác — điều này bảo đảm trình biên dịch nhìn thấy toàn bộ danh sách.

permits bỏ được nếu mọi lớp con nằm cùng file. Với hệ phân cấp nhỏ thì viết chung một file rất gọn:

sealed interface KetQua {
    record ThanhCong(String duLieu) implements KetQua {}
    record ThatBai(String lyDo) implements KetQua {}
}

sealed + record + pattern matching

Ba tính năng này được thiết kế cùng nhau, và chỉ khi dùng chung mới thấy hết giá trị:

static String moTa(Hinh h) {
    return switch (h) {
        case Tron(double r) when r > 10   -> "tròn lớn, r=" + r;
        case Tron(double r)               -> "tròn nhỏ, r=" + r;
        case ChuNhat(double a, double b)  -> (a == b ? "vuông " : "chữ nhật ") + a + "x" + b;
        case TamGiac t                    -> "tam giác đáy " + t.day();
    };
}
  Tron[r=1.0]                  tròn nhỏ, r=1.0
  Tron[r=20.0]                 tròn lớn, r=20.0
  ChuNhat[rong=2.0, cao=3.0]   chữ nhật 2.0x3.0
  ChuNhat[rong=4.0, cao=4.0]   vuông 4.0x4.0

sealed cho biết có những gì. record cho biết mỗi thứ chứa gì. Pattern matching bóc ra trong một bước. Cộng lại, đoạn mã trên vừa ngắn vừa được trình biên dịch bảo vệ.

Người quen ngôn ngữ hàm sẽ nhận ra ngay: đây chính là kiểu tổng — sum type — thứ mà Kotlin gọi là sealed class, Rust gọi là enum, và Haskell có từ ba mươi năm trước. Java đến muộn nhưng đến đủ.

Dùng ở đâu trong mã thật

Mô hình kết quả có nhiều dạng — thay cho việc trả về null hoặc ném ngoại lệ:

sealed interface KetQuaThanhToan {
    record ThanhCong(String maGiaoDich) implements KetQuaThanhToan {}
    record ThieuTien(long conThieu) implements KetQuaThanhToan {}
    record TheHetHan(LocalDate ngay) implements KetQuaThanhToan {}
}

Người gọi buộc phải xử lý cả ba, và mỗi dạng mang đúng dữ liệu nó cần — ThieuTien có số tiền thiếu, TheHetHan có ngày hết hạn. Không còn kiểu trả về một đối tượng có mười trường mà tám cái luôn null.

Cây cú pháp, cấu trúc lồng nhau — biểu thức, tài liệu, quy tắc lọc. Đây là chỗ pattern matching lồng nhau toả sáng.

Máy trạng thái khi mỗi trạng thái mang dữ liệu riêng. Nếu các trạng thái không mang dữ liệu gì thì enum đơn giản hơn và vẫn cho switch vét cạn.

Khi nào không dùng

Khi bạn muốn người khác mở rộng. Một interface plugin mà sealed thì vô nghĩa.

Khi danh sách thay đổi liên tục. sealed bắt bạn sửa mọi chỗ switch mỗi lần thêm loại — đó là tính năng, nhưng nếu danh sách phình ra hằng tuần thì có lẽ mô hình đang sai, hoặc nên chuyển sang đa hình thường: để mỗi lớp tự cài phương thức của mình thay vì switch từ ngoài.

Ranh giới giữa hai cách: thêm loại mới thường xuyên thì dùng đa hình (thêm lớp, không sửa gì khác). Thêm thao tác mới thường xuyên thì dùng sealed + switch (thêm một hàm, không sửa các lớp). Đây là một bài toán cân bằng cũ, gọi là "bài toán biểu thức", và Java giờ có công cụ cho cả hai hướng.

Bỏ default đi khi switch đã vét cạn là có chủ ý, không phải lười. Nếu để default -> throw ..., trình biên dịch coi như bạn đã xử lý hết, và nó sẽ không báo lỗi khi bạn thêm lớp con mới. Bạn tự tay vứt bỏ đúng thứ giá trị nhất mà sealed mang lại.

Thử ba mươi giây

Lấy ví dụ đầu bài, thêm record Vuong(double canh) implements Hinh {} vào permits, rồi biên dịch mà không sửa gì khác.

Đọc kỹ thông báo lỗi. Rồi thêm default -> 0; vào switch và biên dịch lại — lỗi biến mất, và bạn vừa tự tay tắt cái lưới an toàn.

Ngày mai ta đi sâu vào pattern matching: instanceof có khai biến, record pattern lồng nhau, và luật thứ tự nhánh mà trình biên dịch bắt bạn tuân theo.