Hình dung máy phân loại thư ở bưu điện: mỗi loại bưu phẩm rơi vào một ngăn, và cái hay nhất không phải việc phân loại nhanh, mà là khi có một loại thư mới lọt vào mà chưa có ngăn nào nhận, máy dừng lại báo động thay vì lặng lẽ vứt nó đi. Pattern matching trong Java 21 biến switch thành đúng cái máy đó: nó vừa bóc dữ liệu ra khỏi "bưu phẩm", vừa bắt trình biên dịch canh rằng bạn đã có ngăn cho mọi loại. Thêm một loại mới mà quên làm ngăn? Không biên dịch được. Đó là khác biệt lớn nhất so với chuỗi if-instanceof cũ — nơi loại thiếu ngăn chỉ lặng lẽ trượt xuống else.
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 o là null — 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 diaChi là null 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;
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.
Muốn tự thấy cái máy phân loại thư báo động, hãy thử ngay: 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ộ mất ba mươi giây. Đó là 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ự mò.
Mẫu số chung
Pattern matching với kiểm tra vét cạn không phải Java nghĩ ra — Java là kẻ đến muộn, và biết nó học từ đâu làm cho tính năng này sáng nghĩa hơn hẳn.
- Gốc rễ nằm ở họ ML/Haskell từ thập niên 1970–1990: kiểu dữ liệu đại số (
sealed interface+ cácrecordchính là nó) cộngmatchvét cạn là bánh mì hằng ngày của lập trình hàm suốt nửa thế kỷ. Cái "máy báo động khi thiếu ngăn" chính là exhaustiveness checking, có từ thời đó. - Rust đưa nó vào dòng chính:
matchtrênenum, trình biên dịch từ chối nếu thiếu nhánh, vàif let/while letđúng làinstanceofcó khai biến của ta. Nhiều người gặp khái niệm này lần đầu qua Rust chứ không phải Haskell. - Scala (nơi
case class+matchtruyền cảm hứng trực tiếp chorecord+ switch của Java), Kotlin (whentrênsealed class), và Swift (switchtrênenumcó giá trị kèm theo) đều có đúng bộ ba này. TypeScript làm phiên bản của riêng mình bằng discriminated union cộng kiểm tra vét cạn qua kiểunever.
Sợi chỉ chung đáng mang theo: hai cách mô hình hoá "một trong nhiều loại" là đa hình (hành vi sống cùng dữ liệu) và pattern matching (hành vi sống ngoài dữ liệu) — và ngôn ngữ hiện đại nào cũng cho cả hai vì mỗi cái mạnh ở một phía. Thêm một loại mới thì đa hình tiện hơn (chỉ viết một lớp); thêm một thao tác mới thì pattern matching tiện hơn (chỉ viết một hàm, và trình biên dịch canh đủ nhánh). Java mất hai mươi năm mới có vế thứ hai; biết nó là công cụ lâu đời của cả ngành chứ không phải trò mới, bạn sẽ dùng nó đúng chỗ thay vì thay thế mù quáng cho đa hình.
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.