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。

javajava-21sealed-classespattern-matchingswitch