Ba bài vừa rồi đã dựng sẵn ba mảnh: record mô tả dữ liệu, sealed giới hạn danh sách, và switch biểu thức. Hôm nay ghép chúng lại.

Đích đến là một trình in JSON hoàn chỉnh, viết trong hai mươi dòng, không có một dấu ép kiểu nào và không có default nào — mà vẫn được trình biên dịch bảo đảm là đã xử lý hết mọi trường hợp.

Bắt đầu từ instanceof

Mã Java trước Java 16 đầy những đoạn thế này:

if (o instanceof String) {
    String s = (String) o;          // ép kiểu lại lần nữa
    if (s.length() > 2) { ... }
}

Ba dòng cho một việc. Và ép kiểu thủ công là chỗ có thể sai — gõ nhầm kiểu thì ClassCastException lúc chạy.

Giờ:

if (o instanceof String s && s.length() > 2) {
    System.out.println("dài " + s.length());
}
  dài 4

s được khai và gán ngay trong lúc kiểm kiểu. Phạm vi của nó cũng thông minh: nó chỉ tồn tại ở nơi trình biên dịch chắc chắn phép kiểm đã đúng. Nên viết && thì dùng được ở vế sau, còn viết || thì không — và trình biên dịch báo lỗi ngay.

Hệ quả tiện lợi nhất là ở equals:

@Override public boolean equals(Object o) {
    return o instanceof Diem d && x == d.x && y == d.y;
}

Một dòng, và nó xử lý luôn cả trường hợp onull — vì null instanceof bất cứ gì đều false.

switch theo kiểu

Từ Java 21, switch nhận pattern ở nhãn case:

static String moTa(Object o) {
    return switch (o) {
        case null                       -> "null";
        case Integer i when i < 0       -> "số âm " + i;
        case Integer i                  -> "số " + i;
        case String s when s.isBlank()  -> "chuỗi rỗng";
        case String s                   -> "chuỗi '" + s + "'";
        case int[] m                    -> "mảng int " + m.length + " phần tử";
        default                         -> "kiểu " + o.getClass().getSimpleName();
    };
}
  null
  số âm -5
  số 42
  chuỗi rỗng
  chuỗi 'xin chào'
  mảng int 3 phần tử
  kiểu Double

Bốn điều đáng chú ý.

case null xử lý null ngay trong switch. Trước Java 21, switch gặp null là ném NPE, nên luôn phải kiểm tra bên ngoài. Lưu ý: nếu không có case null thì hành vi cũ vẫn giữ — vẫn NPE.

when thêm điều kiện phụ. Nó gọi là "guard", và chỉ chạy sau khi kiểu đã khớp.

Kiểu mảng cũng khớp được: case int[] m.

default vẫn cần khi kiểu đầu vào là Object — vì Object thì vô hạn khả năng.

Luật che nhánh

Thứ tự các case có ý nghĩa, và trình biên dịch bắt bạn tuân theo:

case Integer i          -> "số";
case Integer i when i>0 -> "số dương";
Sai.java:5: error: this case label is dominated by a preceding case label
            case Integer i when i>0 -> "số dương";
                 ^

Nhánh chung đặt trước thì nhánh riêng không bao giờ tới được, và Java từ chối biên dịch thay vì để bạn tự phát hiện.

Quy tắc thực dụng: nhánh có when đặt trước nhánh không có; kiểu cụ thể đặt trước kiểu chung.

Record pattern: bóc dữ liệu ra

Đây mới là phần mạnh nhất. Thay vì khớp kiểu rồi gọi accessor, bóc thẳng thành phần:

record DiaChi(String pho, String thanhPho) {}
record NguoiDung(String ten, DiaChi diaChi) {}

static String thanhPho(NguoiDung n) {
    return switch (n) {
        case NguoiDung(String ten, DiaChi(String pho, String tp)) -> ten + " ở " + tp;
    };
}
  Minh ở Hà Nội

Chú ý pattern lồng vào nhau: NguoiDung(...) chứa DiaChi(...). Một dòng thay cho n.diaChi().thanhPho() kèm kiểm tra null ở giữa. Và nếu diaChinull thì nhánh đơn giản là không khớp — không có NPE.

Dùng var cho gọn khi kiểu đã rõ:

case NguoiDung(var ten, DiaChi(var pho, var tp)) -> ten + " ở " + tp;
Bạn sẽ thấy nhiều bài viết dùng dấu gạch dưới cho thành phần không cần tới: DiaChi(_, String tp). Trên JDK 21 thì chưa dùng được — javac báo "unnamed variables are a preview feature and are disabled by default". Biến vô danh chỉ chính thức từ Java 22. Trên 21, cứ đặt tên rồi bỏ không dùng.

Trình in JSON trong hai mươi dòng

Ghép tất cả lại. Mô hình dữ liệu:

sealed interface Json permits JNum, JStr, JMang, JDoiTuong {}
record JNum(double v) implements Json {}
record JStr(String v) implements Json {}
record JMang(List<Json> phanTu) implements Json {}
record JDoiTuong(Map<String, Json> truong) implements Json {}

Và toàn bộ trình in:

static String inJson(Json j) {
    return switch (j) {
        case JNum(double v)  -> v == Math.floor(v) ? String.valueOf((long) v) : String.valueOf(v);
        case JStr(String s)  -> "\"" + s + "\"";
        case JMang(List<Json> ps) -> "[" + ps.stream().map(Pat::inJson)
                                            .reduce((a,b) -> a + "," + b).orElse("") + "]";
        case JDoiTuong(Map<String, Json> t) -> "{" + t.entrySet().stream()
                .map(e -> "\"" + e.getKey() + "\":" + inJson(e.getValue()))
                .reduce((a,b) -> a + "," + b).orElse("") + "}";
    };
}
  {"ten":"Minh","diem":[8,9.5]}

Không default, không ép kiểu, không kiểm tra null. Và nếu mai bạn thêm record JBool(boolean v) vào permits, trình biên dịch chỉ ngay dòng switch này.

Đây là kiểu mã mà trước Java 21 phải viết bằng mẫu Visitor — một interface với bốn phương thức, mỗi lớp dữ liệu cài một phương thức accept, tổng cộng gấp năm lần số dòng.

Vài chỗ cần cẩn thận

Đừng biến switch thành nơi chứa logic. Mỗi nhánh nên là một biểu thức ngắn hoặc một lời gọi phương thức. Nhánh dài mười dòng thì tách hàm.

Pattern matching không thay thế đa hình. Nếu hành vi thuộc về đối tượng, hãy để đối tượng tự cài phương thức. Pattern matching hợp khi thao tác nằm ngoài dữ liệu — như trình in JSON ở trên, nơi các record không nên biết gì về việc in ấn.

Guard when không tính vào tính vét cạn. case Tron t when t.r() > 0 không phủ hết Tron, nên trình biên dịch vẫn đòi thêm nhánh. Luôn để một nhánh không guard cho mỗi kiểu.

Bảng tổng kết theo phiên bản

Tính năng Từ phiên bản
instanceof có khai biến 16
switch biểu thức, yield 14
record 16
sealed 17
Pattern trong switch, case null, when 21
Record pattern 21
Biến vô danh _ 22

Bảy dòng này là lý do tôi chọn JDK 21 cho cả loạt bài. Từ 21 trở đi, năm mảnh đầu tiên đã đủ dùng cùng nhau, và cách viết Java thay đổi hẳn so với thời Java 8.

Thử ba mươi giây

Lấy inJson ở trên, thêm record JBool(boolean v) implements Json {} vào permits, rồi biên dịch.

Đọc thông báo lỗi, thêm một nhánh, xong. Toàn bộ quy trình mất ba mươi giây — và đó là điểm khác biệt giữa một hệ thống nói cho bạn biết chỗ cần sửa, với một hệ thống để bạn tự tìm.

Ngày mai ta khép lại chặng hướng đối tượng bằng SOLID — năm nguyên tắc, mỗi nguyên tắc một đoạn mã trước và sau khi sửa.