Java 21 中的 sealed classes 和 switch 模式匹配
1. 概述
当一个数据类型只有几个固定的变体时,例如用卡支付、转账或电子钱包,我们通常用一系列 if (x instanceof ...) 来处理它们。这种方式冗长繁琐。更糟的是,容易遗漏某些情况而无人察觉。
Java 21 通过三个相辅相成的特性解决了这个问题:sealed classes(从 Java 17 开始正式推出)、switch 的模式匹配(JEP 441)和 record patterns(JEP 440)。读完这篇文章,你将学会如何建模一个封闭的类型集合,编写让编译器自动检查所有情况的 switch 语句,以及避免常见错误。
2. 旧方法的问题
假设我们需要为每种支付方式计算手续费。在 Java 21 之前,代码通常看起来像这样:
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("不支持: " + payment);
}
这段代码有三个问题:
- 忘记了
EWallet,但代码仍然可以正常编译。错误只会在运行时才会暴露。 - 最后的
throw分支是强制的,尽管从业务角度来看它不应该发生。 - 当添加新的支付类型时,你必须手动查找并修复所有的
if链。
3. Sealed classes
3.1. 声明一个 sealed interface
一个 sealed class 或 sealed interface 精确列出允许继承它的类:
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 {}
关键字 sealed 和 permits 子句告诉编译器 Payment 恰好有三个子类型。如果其他类(比如 class Crypto implements Payment)试图继承它,你会得到编译错误。
如果子类与父类在同一个文件中,你可以省略 permits。编译器会自动推断列表。
3.2. 子类的规则
直接继承 sealed type 的每个子类必须声明以下三个修饰符之一:
| 修饰符 | 含义 |
|---|---|
final |
不允许任何人继续继承 |
sealed |
用自己的 permits 继续限制 |
non-sealed |
对所有继承类开放 |
Record 默认是 final,所以上面的例子不需要添加任何内容。对于普通类,你必须显式声明:
public sealed abstract class Notification permits EmailNotification, SmsNotification {}
public final class EmailNotification extends Notification {}
public non-sealed class SmsNotification extends Notification {}
// 有效,因为 SmsNotification 是 non-sealed
public class ZaloNotification extends SmsNotification {}
这里 EmailNotification 是完全锁定的。相反,SmsNotification 对任何人继承开放。因此,当在 Notification 上进行 switch 时,SmsNotification 分支也会覆盖 ZaloNotification。
3.3. 包和模块的限制
permits 中的类必须与父类在同一个模块中。如果项目不使用模块(未命名模块),它们必须在同一个包中。这是设计的目的:父类的作者控制整个继承层次。
4. Switch 的模式匹配
4.1. 类型模式
从 Java 21 开始,case 标签可以是一个带变量的类型模式:
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;
};
}
}
每个 case 既检查类型,又分配已经类型转换的变量(c、b、w)。该变量的作用域仅限于其分支。代码没有 default,也没有 throw。
4.2. 穷举性:编译器检查所有情况
因为 Payment 是 sealed,编译器知道确切的三个子类型。如果你删除了 case EWallet w -> 0; 这一行,代码将不会编译:
FeeCalculator.java:5: error: the switch expression does not cover all possible input values
return switch (payment) {
^
这是最大的好处。当你将 QrPayment 添加到 permits 时,代码库中对 Payment 的所有 switch 语句都将报告错误,直到被更新。编译器充当了待办事项清单的角色。
注意: 如果类是单独编译的,运行时出现
switch不知道的子类型,JVM 将抛出MatchException。编译器会自动为你插入这个分支。
4.3. 用 when 进行守卫
你可以用 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;
};
}
金额 1000 万或以上的卡交易收费 1%,其余收费 2%。有守卫的分支不被视为覆盖所有 CardPayment。因此第二个 case CardPayment c 分支仍然是必需的。
用几笔交易试运行一下:
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 + " -> 费用 " + calculator.fee(p)));
}
}
输出结果:
CardPayment[cardNumber=4111-1111, amount=500000] -> 费用 10000
CardPayment[cardNumber=4111-2222, amount=20000000] -> 费用 200000
BankTransfer[bankCode=VCB, amount=1000000] -> 费用 5500
EWallet[provider=momo, amount=200000] -> 费用 0
4.4. Case 的顺序和主导性
switch 从上到下匹配模式。如果一个更通用的分支位于一个更具体的分支之前,后者将永远不会被执行。编译器会捕捉这个错误:
return switch (payment) {
case CardPayment c -> c.amount() * 2 / 100;
case CardPayment c when c.amount() >= 10_000_000 -> c.amount() / 100; // 错误
case BankTransfer b -> 5_500;
case EWallet w -> 0;
};
error: this case label is dominated by a preceding case label
容易记住的规则:把狭窄的分支(有守卫或子类型)放在前面,把宽泛的分支放在后面。
4.5. 处理 null
默认情况下,switch 遇到 null 会抛出 NullPointerException,与旧行为相同。如果你想显式处理 null,添加 case null:
public String label(Payment payment) {
return switch (payment) {
case null -> "未选择支付方式";
case CardPayment c -> "卡片";
case BankTransfer b -> "转账";
case EWallet w -> "钱包 " + w.provider();
};
}
你也可以合并 case null, default -> 当 switch 有 default 分支时。
5. Record patterns
Record patterns 允许你解构 record 的组件直接在 case 中。这样你就不需要调用 accessor:
public String describe(Payment payment) {
return switch (payment) {
case CardPayment(String number, long amount) ->
"卡 ****" + number.substring(number.length() - 4) + ": " + amount;
case BankTransfer(var bank, var amount) -> "转账 " + bank + ": " + amount;
case EWallet(var provider, var amount) -> "钱包 " + provider + ": " + amount;
};
}
CardPayment(String number, long amount) 匹配当 payment 是 CardPayment 时,同时分配 number 和 amount。你可以用 var 让编译器自动推断类型。对于 new CardPayment("4111-1111", 500_000),结果是 卡 ****1111: 500000。
Record patterns 的真正威力在于嵌套能力:
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 ->
"需要审批:卡 " + number;
case Charge(Payment p) -> "收取 " + p.amount();
case Refund(var original, var reason) -> "退款 " + original.amount() + " (" + reason + ")";
};
}
第一个分支匹配一个包含 CardPayment 且金额很大的 Charge,仅用一行代码。编译器仍然检查嵌套模式的穷举性:Charge(Payment p) 覆盖所有 Charge,Refund(...) 覆盖所有 Refund。
注意: 未命名模式
_,例如case Refund(_, var reason),在 Java 21 中只是预览版本,必须启用--enable-preview。此功能从 Java 22 开始正式推出。如果项目固定在 Java 21,请用var搭配普通变量名。
6. 测试
你需要配置 Java 21 以及 JUnit 5 和 AssertJ。如果使用 Spring Boot 3.x,spring-boot-starter-test 已经包含了两者。对于纯 Maven 项目:
<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>
第 4.3 节中 FeeCalculator 和第 5 节中 describe 的测试:
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("卡 ****1111: 500000")
.doesNotContain("4111-");
}
}
测试应该关注守卫边界(正好在 1000 万下方和上方),因为那是 case 顺序最容易出错的地方。穷举性则不需要测试:编译器已经处理了这部分。
7. 常见错误和何时不应使用
给 sealed type 的 switch 添加 default。 代码仍然会编译,但你失去了主要优势。当添加新的子类型时,它会默默地落入 default 而不是触发编译错误。对于 sealed type,列出所有分支并忽略 default。
忘记有 when 的分支不覆盖整个类型。 如果所有 case CardPayment 都有守卫,编译器会报告缺少情况。始终需要一个无守卫的分支,或者一个更通用的分支,放在末尾。
用 switch 替代到处的多态。 如果行为确实属于对象本身,类中的抽象方法仍然是更好的选择。模式匹配适合逻辑在层次结构之外的情况,例如计算费用、渲染、映射到 DTO,且你不想把一切都放入 record。
注意: 不应该使用 sealed classes 当:
- 你编写的是库或扩展点(插件),用户需要添加自己的子类型。
- 这个类是 JPA entity 需要延迟加载。Hibernate 通过继承 entity 来创建代理,所以 sealed 或 final 会阻止这种机制。
- 子类型分散在多个模块中。Sealed 要求它们在同一个模块中,或者在不使用模块的情况下在同一个包中。
JSON 序列化。 Jackson 不会自动知道如何将 Payment 反序列化为正确的子类型,仅因为 interface 是 sealed。你仍需要配置类型信息,例如 @JsonTypeInfo 结合 @JsonSubTypes。
8. 总结
Sealed classes 允许你明确声明一个类型仅有一组固定的变体。Switch 的模式匹配和 record patterns 将该集合转变为简洁的代码,同时被编译器检查所有情况。你可以用 when 守卫添加条件,用 case null 显式处理 null,以及用嵌套模式深入数据。
要记住的三个原则:在 sealed type 上的 switch 中省略 default,把狭窄的分支放在宽泛的分支前面,只对你真正想要封闭的类型层次使用 sealed。