Sealed classes và pattern matching cho switch trong Java 21
1. Tổng quan
Khi một kiểu dữ liệu chỉ có vài biến thể cố định, ví dụ thanh toán bằng thẻ, chuyển khoản hoặc ví điện tử, ta thường xử lý chúng bằng chuỗi if (x instanceof ...). Cách này dài dòng. Tệ hơn, nó dễ bỏ sót trường hợp mà không ai phát hiện.
Java 21 giải quyết vấn đề này bằng ba tính năng đi cùng nhau: sealed classes (chính thức từ Java 17), pattern matching cho switch (JEP 441) và record patterns (JEP 440). Đọc xong bài này, bạn sẽ biết cách mô hình hóa một tập kiểu đóng, viết switch mà compiler tự kiểm tra đủ trường hợp, và tránh các lỗi hay gặp.
2. Vấn đề với cách làm cũ
Giả sử ta cần tính phí cho từng loại thanh toán. Trước Java 21, code thường trông như sau:
public long fee(Object payment) {
if (payment instanceof CardPayment c) {
return c.amount() * 2 / 100;
} else if (payment instanceof BankTransfer b) {
return 5_500;
}
throw new IllegalArgumentException("Không hỗ trợ: " + payment);
}
Đoạn code trên có ba vấn đề:
- Đã quên
EWallet, nhưng code vẫn compile bình thường. Lỗi chỉ lộ ra lúc runtime. - Nhánh
throwcuối cùng là bắt buộc, dù về mặt nghiệp vụ nó không bao giờ nên xảy ra. - Khi thêm loại thanh toán mới, bạn phải tự tìm mọi chỗ có chuỗi
ifnày để sửa.
3. Sealed classes
3.1. Khai báo một sealed interface
Một sealed class hoặc sealed interface liệt kê chính xác những lớp được phép kế thừa nó:
public sealed interface Payment permits CardPayment, BankTransfer, EWallet {
long amount();
}
public record CardPayment(String cardNumber, long amount) implements Payment {}
public record BankTransfer(String bankCode, long amount) implements Payment {}
public record EWallet(String provider, long amount) implements Payment {}
Từ khóa sealed cùng mệnh đề permits nói với compiler rằng Payment chỉ có đúng ba kiểu con. Nếu một lớp khác, chẳng hạn class Crypto implements Payment, cố kế thừa, bạn sẽ nhận lỗi compile.
Nếu các lớp con nằm cùng một file với lớp cha, bạn có thể bỏ permits. Compiler sẽ tự suy ra danh sách.
3.2. Quy tắc cho lớp con
Mỗi lớp con trực tiếp của một sealed type phải khai báo một trong ba modifier:
| Modifier | Ý nghĩa |
|---|---|
final |
Không ai được kế thừa tiếp |
sealed |
Tiếp tục giới hạn bằng permits riêng |
non-sealed |
Mở lại cho mọi lớp kế thừa |
Record mặc định là final, nên ví dụ ở trên không cần ghi thêm gì. Với class thường, bạn phải ghi rõ:
public sealed abstract class Notification permits EmailNotification, SmsNotification {}
public final class EmailNotification extends Notification {}
public non-sealed class SmsNotification extends Notification {}
// Hợp lệ vì SmsNotification là non-sealed
public class ZaloNotification extends SmsNotification {}
Ở đây EmailNotification bị khóa hoàn toàn. Ngược lại, SmsNotification mở cho bất kỳ ai kế thừa. Hệ quả là khi switch trên Notification, nhánh SmsNotification sẽ bao luôn ZaloNotification.
3.3. Ràng buộc về package và module
Các lớp trong permits phải nằm cùng module với lớp cha. Nếu project không dùng module (unnamed module), chúng phải nằm cùng package. Đây là chủ đích thiết kế: tác giả của lớp cha kiểm soát toàn bộ hệ phân cấp.
4. Pattern matching cho switch
4.1. Type pattern
Từ Java 21, nhãn case có thể là một pattern kiểu kèm biến:
public class FeeCalculator {
public long fee(Payment payment) {
return switch (payment) {
case CardPayment c -> c.amount() * 2 / 100;
case BankTransfer b -> 5_500;
case EWallet w -> 0;
};
}
}
Mỗi case vừa kiểm tra kiểu, vừa gán biến đã được ép kiểu sẵn (c, b, w). Biến chỉ có phạm vi trong nhánh của nó. Đoạn code không có default và không có throw.
4.2. Exhaustiveness: compiler kiểm tra đủ trường hợp
Vì Payment là sealed, compiler biết chính xác ba kiểu con. Nếu bạn xóa dòng case EWallet w -> 0;, code sẽ không compile:
FeeCalculator.java:5: error: the switch expression does not cover all possible input values
return switch (payment) {
^
Đây là lợi ích lớn nhất. Khi bạn thêm QrPayment vào permits, mọi switch trên Payment trong codebase sẽ báo lỗi cho đến khi được cập nhật. Compiler đóng vai trò danh sách việc cần làm.
Lưu ý: Nếu các lớp được compile riêng rẽ và lúc runtime xuất hiện một kiểu con mà
switchchưa biết, JVM sẽ némMatchException. Compiler tự chèn nhánh này cho bạn.
4.3. Guard với when
Bạn có thể thêm điều kiện cho một pattern bằng when:
public long fee(Payment payment) {
return switch (payment) {
case CardPayment c when c.amount() >= 10_000_000 -> c.amount() * 1 / 100;
case CardPayment c -> c.amount() * 2 / 100;
case BankTransfer b -> 5_500;
case EWallet w -> 0;
};
}
Giao dịch thẻ từ 10 triệu đồng trở lên chịu phí 1%, còn lại chịu 2%. Nhánh có guard không được tính là bao phủ toàn bộ CardPayment. Vì vậy nhánh case CardPayment c thứ hai vẫn bắt buộc phải có.
Chạy thử với vài giao dịch:
public class Main {
public static void main(String[] args) {
var calculator = new FeeCalculator();
List<Payment> payments = List.of(
new CardPayment("4111-1111", 500_000),
new CardPayment("4111-2222", 20_000_000),
new BankTransfer("VCB", 1_000_000),
new EWallet("momo", 200_000));
payments.forEach(p -> System.out.println(p + " -> phí " + calculator.fee(p)));
}
}
Kết quả:
CardPayment[cardNumber=4111-1111, amount=500000] -> phí 10000
CardPayment[cardNumber=4111-2222, amount=20000000] -> phí 200000
BankTransfer[bankCode=VCB, amount=1000000] -> phí 5500
EWallet[provider=momo, amount=200000] -> phí 0
4.4. Thứ tự case và dominance
switch khớp pattern từ trên xuống. Nếu một nhánh tổng quát đứng trước một nhánh cụ thể hơn, nhánh sau sẽ không bao giờ được chạy tới. Compiler bắt được lỗi này:
return switch (payment) {
case CardPayment c -> c.amount() * 2 / 100;
case CardPayment c when c.amount() >= 10_000_000 -> c.amount() / 100; // lỗi
case BankTransfer b -> 5_500;
case EWallet w -> 0;
};
error: this case label is dominated by a preceding case label
Quy tắc dễ nhớ: đặt nhánh hẹp (có guard hoặc kiểu con) lên trước, nhánh rộng xuống sau.
4.5. Xử lý null
Theo mặc định, switch gặp null sẽ ném NullPointerException, giống hành vi cũ. Nếu muốn xử lý null tường minh, bạn thêm case null:
public String label(Payment payment) {
return switch (payment) {
case null -> "Chưa chọn phương thức";
case CardPayment c -> "Thẻ";
case BankTransfer b -> "Chuyển khoản";
case EWallet w -> "Ví " + w.provider();
};
}
Bạn cũng có thể gộp case null, default -> khi switch có nhánh default.
5. Record patterns
Record patterns cho phép tách các thành phần của record ngay trong case. Nhờ đó bạn không cần gọi accessor:
public String describe(Payment payment) {
return switch (payment) {
case CardPayment(String number, long amount) ->
"Thẻ ****" + number.substring(number.length() - 4) + ": " + amount;
case BankTransfer(var bank, var amount) -> "Chuyển khoản " + bank + ": " + amount;
case EWallet(var provider, var amount) -> "Ví " + provider + ": " + amount;
};
}
CardPayment(String number, long amount) khớp khi payment là CardPayment, đồng thời gán number và amount. Bạn có thể dùng var để compiler tự suy kiểu. Với new CardPayment("4111-1111", 500_000), kết quả là Thẻ ****1111: 500000.
Sức mạnh thật sự của record patterns nằm ở khả năng lồng nhau:
public sealed interface Transaction permits Charge, Refund {}
public record Charge(Payment payment) implements Transaction {}
public record Refund(Payment original, String reason) implements Transaction {}
public String audit(Transaction tx) {
return switch (tx) {
case Charge(CardPayment(var number, var amount)) when amount >= 10_000_000 ->
"Cần duyệt: thẻ " + number;
case Charge(Payment p) -> "Thu " + p.amount();
case Refund(var original, var reason) -> "Hoàn " + original.amount() + " (" + reason + ")";
};
}
Nhánh đầu tiên khớp một Charge chứa một CardPayment có giá trị lớn, chỉ trong một dòng. Compiler vẫn kiểm tra exhaustiveness cho pattern lồng: Charge(Payment p) bao phủ mọi Charge, còn Refund(...) bao phủ mọi Refund.
Lưu ý: Unnamed pattern
_, ví dụcase Refund(_, var reason), chỉ là preview trong Java 21 và phải bật--enable-preview. Tính năng này chính thức từ Java 22. Nếu dự án cố định ở Java 21, hãy dùngvarvới tên biến bình thường.
6. Kiểm thử
Bạn cần cấu hình Java 21 cùng JUnit 5 và AssertJ. Nếu dùng Spring Boot 3.x, spring-boot-starter-test đã có sẵn cả hai. Với project Maven thuần:
<properties>
<maven.compiler.release>21</maven.compiler.release>
</properties>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.25.3</version>
<scope>test</scope>
</dependency>
</dependencies>
Test cho FeeCalculator ở mục 4.3 và describe ở mục 5:
class FeeCalculatorTest {
private final FeeCalculator calculator = new FeeCalculator();
@Test
void cardPaymentBelowThreshold_charges2Percent() {
assertThat(calculator.fee(new CardPayment("4111-1111", 500_000))).isEqualTo(10_000);
}
@Test
void cardPaymentAboveThreshold_charges1Percent() {
assertThat(calculator.fee(new CardPayment("4111-2222", 20_000_000))).isEqualTo(200_000);
}
@Test
void bankTransferAndWallet_haveFixedFee() {
assertThat(calculator.fee(new BankTransfer("VCB", 1_000_000))).isEqualTo(5_500);
assertThat(calculator.fee(new EWallet("momo", 200_000))).isZero();
}
@Test
void describe_masksCardNumber() {
assertThat(calculator.describe(new CardPayment("4111-1111", 500_000)))
.isEqualTo("Thẻ ****1111: 500000")
.doesNotContain("4111-");
}
}
Test nên tập trung vào ranh giới guard (ngay dưới và trên 10 triệu), vì đó là chỗ thứ tự case dễ sai nhất. Exhaustiveness thì không cần test: compiler đã lo phần đó.
7. Lỗi hay gặp và khi nào không nên dùng
Thêm default vào switch trên sealed type. Code vẫn compile, nhưng bạn đánh mất lợi ích chính. Khi thêm kiểu con mới, nó sẽ lặng lẽ rơi vào default thay vì gây lỗi compile. Với sealed type, hãy liệt kê đủ các nhánh và bỏ default.
Quên rằng nhánh có when không bao phủ toàn bộ kiểu. Nếu mọi case CardPayment đều có guard, compiler sẽ báo thiếu trường hợp. Luôn cần một nhánh không guard, hoặc nhánh tổng quát hơn, ở cuối.
Dùng switch thay cho đa hình ở mọi nơi. Nếu hành vi thật sự thuộc về chính đối tượng, một phương thức abstract trong lớp cha vẫn là lựa chọn tốt hơn. Pattern matching phù hợp khi logic nằm ngoài hệ phân cấp, ví dụ tính phí, render, map sang DTO, và bạn không muốn nhồi mọi thứ vào record.
Lưu ý: Không nên dùng sealed classes khi:
- Bạn viết thư viện hoặc điểm mở rộng (plugin) mà người dùng cần tự thêm kiểu con.
- Lớp đó là JPA entity cần lazy loading. Hibernate tạo proxy bằng cách kế thừa entity, nên sealed hoặc final sẽ cản trở cơ chế này.
- Các kiểu con nằm rải rác ở nhiều module. Sealed yêu cầu chúng cùng module, hoặc cùng package nếu không dùng module.
Serialize JSON. Jackson không tự biết cách deserialize một Payment thành đúng kiểu con chỉ vì interface là sealed. Bạn vẫn cần cấu hình thông tin kiểu, ví dụ @JsonTypeInfo kết hợp @JsonSubTypes.
8. Kết luận
Sealed classes cho phép bạn khai báo rõ ràng rằng một kiểu chỉ có một tập biến thể cố định. Pattern matching cho switch và record patterns biến tập đó thành code ngắn gọn, đồng thời được compiler kiểm tra đủ trường hợp. Bạn có thể dùng guard when để thêm điều kiện, case null để xử lý null tường minh, và pattern lồng nhau để đi sâu vào dữ liệu.
Ba nguyên tắc cần nhớ: bỏ default khi switch trên sealed type, đặt nhánh hẹp trước nhánh rộng, và chỉ dùng sealed cho những hệ phân cấp mà bạn thực sự muốn đóng.