Java 21 模式匹配实战:switch 表达式与记录模式的正确用法

为什么 Java 21 的模式匹配值得认真对待

写过 Java 的人应该都有这种体验:一段业务逻辑里需要根据对象类型做分支处理,于是你写了五六层 if (obj instanceof SomeType) 嵌套,每层里面还要手动做一次强转才能拿到字段。代码能跑,但读起来像流水线作业,改起来更是提心吊胆——新增一个子类型,漏了一处分支判断,线上直接抛 ClassCastException。

Java 21 模式匹配实战:switch 表达式与记录模式的正确用法

Java 21 把 Pattern Matching for switchRecord Patterns 从预览特性正式转正(JEP 441 和 JEP 440),意味着这套语法终于可以放心用在生产代码里了。它不只是少写几行 instanceof 那么简单——真正改变的是类型分派的写法、可读性,以及编译器帮你做穷尽性检查的能力。但很多团队在实际使用时,要么用得不到位,要么过度使用,甚至引入了新的隐患。这篇文章就聊聊到底怎么用才算正确。

类型模式:switch 终于能匹配类型了

先看最基础的用法。假设有一个支付结果接口,有几种实现:

sealed interface PaymentResult permits Success, Failure, Pending {}

record Success(String txnId, BigDecimal amount) implements PaymentResult {}
record Failure(String reason, int code) implements PaymentResult {}
record Pending(String txnId, String callbackUrl) implements PaymentResult {}

在 Java 21 之前,处理这个结果大概是这样写的:

String describe(PaymentResult result) {
    if (result instanceof Success s) {
        return "支付成功,交易号:" + s.txnId() + ",金额:" + s.amount();
    } else if (result instanceof Failure f) {
        return "支付失败:" + f.reason() + "(错误码 " + f.code() + ")";
    } else if (result instanceof Pending p) {
        return "等待回调:" + p.callbackUrl();
    } else {
        return "未知状态";
    }
}

换成 switch 模式匹配后:

String describe(PaymentResult result) {
    return switch (result) {
        case Success s -> "支付成功,交易号:" + s.txnId() + ",金额:" + s.amount();
        case Failure f -> "支付失败:" + f.reason() + "(错误码 " + f.code() + ")";
        case Pending p -> "等待回调:" + p.callbackUrl();
    };
}

看起来只是把 if-else 换成了 switch,但有几个实质性的变化。第一,每个 case 同时完成了类型检查和变量绑定,不需要再手动强转。第二,因为 PaymentResult 是 sealed 接口,编译器知道它只有三个 permit 子类型,所以上面这段代码没有写 default 也能编译通过——这就是穷尽性检查。如果你后续新增了 Refunded 类型但忘了在 switch 里处理,编译直接报错。

这是一个比 Visitor 模式更轻量、更安全的替代方案。Visitor 靠运行时抛异常来提醒你漏了分支,而 sealed + switch 靠编译器在编译阶段就把问题暴露出来。

但这里有一个很多团队忽略的细节:穷尽性检查只在编译器能确认类型全集的前提下才生效。如果你的接口不是 sealed 的,或者 switch 的目标类型是 Object,编译器没法保证覆盖所有情况,就必须写 default 分支——这时候穷尽性检查就形同虚设了。

守卫模式:when 子句不是万能的

光靠类型匹配不够。很多时候你需要在匹配到某个类型之后,再根据字段值做进一步筛选。Java 21 引入了 when 守卫子句来解决这个问题:

String routeOrder(Order order) {
    return switch (order) {
        case NormalOrder o when o.amount().compareTo(BigDecimal.valueOf(10000)) > 0
            -> "大额普通订单,走人工审核";
        case NormalOrder o -> "普通订单,自动处理";
        case PreSaleOrder o when o.estimatedDelivery().isBefore(LocalDate.now())
            -> "预售订单已过期,自动退款";
        case PreSaleOrder o -> "预售订单正常处理";
        case GroupBuyOrder o -> "拼团订单处理";
    };
}

上面的代码里,NormalOrder 出现了两次,第一个带 when 条件,第二个不带。这是合法的,switch 会从上到下依次匹配。但这里有个陷阱:case 的顺序很重要。如果你把带条件的 case 放到不条件的后面,编译器不会报错,但逻辑就错了——大额订单永远被前面的无条件分支截获,when 条件形同虚设。

另一个需要注意的是,when 子句里的表达式是普通的 boolean 表达式,但它只在类型模式匹配成功之后才会求值。这意味着你可以在 when 里安全地使用前面绑定的模式变量。不过别在 when 里塞复杂的逻辑——如果守卫条件涉及数据库查询或远程调用,代码的可读性和可测试性都会急剧下降。守卫模式适合做简单字段判断,不适合承担业务规则引擎的角色。

记录模式:一次性解构字段

类型模式解决了”判断类型 + 绑定变量”的问题,但拿到变量后你还得手动调用访问器去取字段值。Java 21 的记录模式(Record Pattern)允许你在匹配的同时直接做解构,把字段值”拆”出来。

还是用上面的支付结果,换成记录模式:

String describe(PaymentResult result) {
    return switch (result) {
        case Success(String txnId, BigDecimal amount)
            -> "支付成功,交易号:" + txnId + ",金额:" + amount;
        case Failure(String reason, int code)
            -> "支付失败:" + reason + "(错误码 " + code + ")";
        case Pending(String txnId, String callbackUrl)
            -> "等待回调:" + callbackUrl;
    };
}

区别在于:类型模式 case Success s 绑定的是整个对象 s,你需要通过 s.txnId() 访问字段;而记录模式 case Success(String txnId, BigDecimal amount) 直接在匹配时把组件值提取到 txnIdamount 变量里,省去了一步访问器调用。

这里有个容易误解的地方:记录模式里每个位置的”模式”本身也是类型模式。比如 case Success(String txnId, BigDecimal amount) 中的 String txnIdBigDecimal amount 就是两个独立的类型模式,分别匹配 record 的第一个和第二个组件。这意味着你可以用 var 让编译器推断类型,也可以用父类型来做更宽松的匹配。

// 用 var 推断类型
case Success(var txnId, var amount) -> ...

// 组件类型可以匹配父类型
record Box(Object value) {}
case Box(CharSequence cs) -> ...  // 匹配实现了 CharSequence 的值

但要注意:记录模式不支持自动装箱和拆箱。如果 record 的组件是基本类型 int,你在模式里写 Integer code 是编译不过的,必须写成 int code。反过来也一样。

嵌套记录模式:解构的”套娃”能力

记录模式真正强大的地方在于嵌套。假设你的数据结构是多层的:

record Address(String city, String street) {}
record Person(String name, int age, Address address) {}
record Company(String name, Person owner) {}

record Organization(String orgName, Company mainCompany) {}

如果你想从 Organization 里直接拿到公司所在城市,在 Java 21 之前需要一层层调 getter。现在可以一步到位:

void printCity(Object obj) {
    if (obj instanceof Organization(
            String orgName,
            Company(String companyName,
                    Person(String ownerName, int age,
                           Address(String city, String street))))) {
        System.out.println(orgName + " 的主体公司位于:" + city);
    }
}

这段代码确实优雅,但也要注意一个边界:嵌套解构的前提是每一层都是 record 类型。如果你的 Person 是普通 class 而非 record,编译器会直接报错。因为 record 的访问器是编译器生成的、public 且无副作用的,这是安全解构的语义基础。普通 class 的 getter 可能有副作用,编译器不敢自动做解构。

在实际项目中,这意味着你的领域模型如果还在用传统 POJO 或者 Lombok 的 @Data class,记录模式就用不上。这是一个”先有 record 才能享受 record pattern”的硬性依赖。很多团队想直接在老项目里用模式匹配,一上来就卡在这里。

三件套的协同:Records + Sealed + Pattern Matching

真正让模式匹配发挥最大价值的,是 Records、Sealed Classes 和 Pattern Matching for Switch 三者配合使用。各自的角色很清晰:

特性 解决的问题 单独使用的局限
Records 不可变数据建模,自动生成访问器 没有类型封闭性,无法穷尽匹配
Sealed Classes 限定类型继承范围,让编译器知道全部子类型 不配合 switch 模式匹配意义不大
Pattern Matching for Switch 类型分派 + 变量绑定 + 穷尽检查 没有 sealed 时无法做编译器穷尽检查

三者组合的效果是:用 Records 定义数据形状,用 Sealed 限定类型范围,用 Pattern Matching 做分派逻辑。编译器帮你检查分支是否完整,漏一个就编译不过。这是传统 if-else 链和 Visitor 模式都无法达到的安全等级。

一个典型的电商订单场景就很适合用这套组合:

public sealed interface Order
    permits NormalOrder, PreSaleOrder, GroupBuyOrder {}

public record NormalOrder(String orderId, BigDecimal amount) implements Order {}
public record PreSaleOrder(String orderId, BigDecimal amount, LocalDate deliveryDate) implements Order {}
public record GroupBuyOrder(String orderId, BigDecimal amount, int groupSize) implements Order {}

// 新增类型时,如果不加这个 case,编译直接报错
String process(Order order) {
    return switch (order) {
        case NormalOrder(var id, var amount) -> "普通订单:" + id;
        case PreSaleOrder(var id, var amount, var date) -> "预售订单:" + id + ",预计发货:" + date;
        case GroupBuyOrder(var id, var amount, var size) -> "拼团订单:" + id + "," + size + "人成团";
    };
}

实际项目中容易踩的坑

模式匹配的语法并不复杂,但真正在项目里用起来,有几个问题反复出现。

第一个坑:case 顺序写反

switch 模式匹配是从上到下顺序匹配的,先匹配到的就执行。如果两个 case 之间存在包含关系——比如一个是 case Object o,另一个是 case String s——那么 String 的 case 必须放在前面,否则永远不会执行到。编译器对这种情况会报错提示,但如果两个 case 的类型不存在直接的父子关系,只是”语义上更具体的应该先匹配”,编译器就帮不了你了。

第二个坑:在 when 子句里做重操作

有些人在 when 守卫里调用外部服务或数据库查询,比如 case Order o when checkUserBlacklist(o.userId())。这会让 switch 的执行变得不可预测,性能开销也不好控制。守卫模式的设计意图是轻量的条件过滤,不是业务规则引擎。如果分支逻辑确实复杂,应该考虑提取成独立方法或者用策略模式。

第三个坑:密封接口的 permits 声明和模块边界

Sealed 类的子类必须和密封类在同一个模块(或同一个编译单元)中声明。在大型项目中,如果支付领域的 sealed 接口和订单领域的 record 分布在不同模块,你可能需要调整模块划分策略。这不是语法问题,是架构设计层面的约束——sealed 强制你把相关的类型定义放在一起,这对代码组织反而是一件好事,但迁移成本需要提前评估。

什么时候该用,什么时候要缓一缓

模式匹配不是银弹,以下场景建议谨慎引入或暂缓迁移:

  • 领域模型还是传统 POJO 的老项目:没有 record 做支撑,记录模式用不了,类型模式的价值也打了折扣。先迁移数据模型再谈 switch 模式匹配。
  • 分支逻辑频繁变动且不在编译时确定:如果类型集合在运行时才确定(比如插件化架构),sealed 的穷尽性检查反而会变成负担。
  • 团队还在 Java 17 及以下:虽然 Java 17 开始有 preview 版的模式匹配,但在生产环境使用 preview 特性风险较高。团队统一升级到 Java 21 是前置条件。

反过来,以下场景特别适合用:

  • 状态机建模:订单状态、支付流程、审批节点,这些天然适合 sealed + record + switch 模式匹配的组合。
  • 事件驱动架构中的事件分发:不同的事件类型用 sealed 接口限定,消费端用 switch 做分派,新增事件类型时编译器帮你检查遗漏。
  • API 响应解析:如果外部 API 返回的数据结构可以用 record 建模,嵌套记录模式能极大简化解析代码。

迁移建议:从 if-else 到 switch 的渐进路径

如果你决定开始用,建议按这个节奏来,而不是一次性全量替换:

第一步,把项目里有 if (x instanceof A) ... else if (x instanceof B) ... 这种链式结构的地方挑出来,优先改成分支数在 3 个以上的——这种场景 switch 模式匹配的收益最明显。

第二步,把接口改成 sealed,把实现类改成 record。这一步可能是最耗时的,因为涉及到领域模型的调整。建议先从新增的业务模块开始用 record + sealed,老模块按改动收益逐步迁移。

第三步,对 switch 表达式加上记录模式的解构。注意代码审查时检查 case 顺序和 when 子句的合理性。

第四步,去掉不必要的 default 分支。如果 sealed 接口的子类型已经全部覆盖,default 是多余的,反而会让编译器的穷尽性检查失效——因为编译器会认为你已经有了兜底分支,新增子类型时不再报错。

// 不要这样:default 会让穷尽性检查失效
switch (order) {
    case NormalOrder o -> ...;
    case PreSaleOrder o -> ...;
    case GroupBuyOrder o -> ...;
    default -> throw new IllegalStateException();  // 多余且有害
}

// 应该这样:让编译器帮你检查
switch (order) {
    case NormalOrder(var id, var amount) -> ...;
    case PreSaleOrder(var id, var amount, var date) -> ...;
    case GroupBuyOrder(var id, var amount, var size) -> ...;
    // 新增 Refunded 类型时,这里编译报错,提醒你补充分支
}

写在最后

Java 21 的模式匹配不是语法糖层面的”少写几行代码”,它改变的是类型分派的安全模型。sealed 让编译器知道类型全集,switch 模式匹配让分支逻辑更直接,记录模式让数据解构更优雅——三者组合后,很多之前需要 Visitor 模式或策略模式才能解决的类型分派问题,可以用更简洁、更安全的方式处理。

但也要清楚这套机制的限制:它依赖 record 数据建模、依赖 sealed 类型封闭、依赖团队对 case 顺序和 when 子句的纪律性使用。如果你的领域模型还在用传统 POJO,或者团队还没统一到 Java 21,不妨先从新模块开始小范围验证,等模型和习惯都到位了再逐步推广。

原创文章,作者:,如若转载,请注明出处:https://fudengji.cn/article/308/

(0)
上一篇 1天前
下一篇 1天前

相关推荐