Java模式匹配与密封类实战:把类型判断从一堆if-else变成编译期安全的状态机

2026-07-17 0 702

传统 Java 项目里,用 instanceof 做类型判断然后强转的写法几乎无处不在。每个写过两年以上 Java 的人都能在自己的代码库里找到几十处这样的模式:先判断是不是某个类型,再强转,再取值。JDK 16 到 21 这五个版本,把模式匹配和密封类的拼图一块块补齐了。现在你可以在 switch 里直接解构对象、让编译器帮你检查分支是否穷举完整,甚至把状态建模成一整套封闭的类型体系。本文用一个订单处理引擎的完整重构案例,把这三项特性的组合威力展示清楚。

一、三块拼图各自解决什么问题

在开始写代码之前,先把三个特性的角色说清楚。它们单独拿出来都有用,但合在一起才真正改变了 Java 处理类型分支的方式。

密封类是类型的”门禁”。sealed 关键字声明一个类或接口,然后明确列出所有允许的子类。编译器知道这个类型体系是封闭的,不会有未知的外部子类冒出来。这意味着当你遍历所有子类型时,编译器能判断你是否漏掉了某个分支。

模式匹配是把拆箱动作内置到判断里。以前写 if (obj instanceof String s) { ... } 还需要单独声明变量再强转。模式匹配让 instanceof 直接绑定变量,更关键的是它在 switch 里可以同时对类型和字段做解构——一条 case 语句既判断类型又取出你需要的值。

Record 类是数据的”快照”。不可变、字段自动生成、equals 和 hashCode 自动实现。用它做密封类的子类型特别合适,因为状态机里的每个状态节点本质就是一组不可变的数据。

三者叠加在一起,传统的”一个状态字段加一堆 if-else”的写法,就能被重构为类型安全、编译期可验证的状态机。

二、先看老写法:用一个字段驱动所有分支

假设有一个订单处理系统,订单在创建后依次经过待支付、已支付、配送中、已完成四个状态,如果用户取消则进入已取消状态。传统的实现大概长这样:

public class Order {
    private String orderId;
    private String status; // "pending", "paid", "shipping", "completed", "cancelled"
    private BigDecimal amount;
    private String paymentMethod;
    private String trackingNumber;
    private String cancelReason;

    // 根据状态执行不同的操作
    public String getDisplayInfo() {
        if ("pending".equals(status)) {
            return "订单待支付,金额:" + amount;
        } else if ("paid".equals(status)) {
            return "已支付,支付方式:" + paymentMethod;
        } else if ("shipping".equals(status)) {
            return "配送中,快递单号:" + trackingNumber;
        } else if ("completed".equals(status)) {
            return "已完成";
        } else if ("cancelled".equals(status)) {
            return "已取消,原因:" + cancelReason;
        }
        throw new IllegalStateException("未知状态");
    }

    public boolean canRefund() {
        return "paid".equals(status) || "shipping".equals(status);
    }
}

这段代码的问题不是一眼就能看出来的——它看起来挺简单。真正的问题在于:每个状态需要的字段完全不同。待支付状态需要金额,配送中状态需要快递单号,已取消状态需要取消原因。但它们全塞在一个类里,你没法从类型上区分哪些字段在哪个状态下是有效的。一个 Order 对象的 trackingNumber 在待支付状态下有意义吗?没有,但代码里任何地方都能访问它。

另一个隐藏问题是分支的穷举性。如果有人加了一个新状态 "refunding"getDisplayInfo()canRefund() 里都得记得加上对应的分支。这种遗漏编译器不会提醒你,只能在运行时通过抛异常来发现——前提是测试覆盖到了这条路径。

三、用密封类和 Record 重新建模状态

新设计的核心思路:每个状态是一个独立的类型,只携带自己需要的字段。所有状态共享一个密封接口作为父类型。

import java.math.BigDecimal;

// 密封接口:列出所有合法的状态类型
public sealed interface OrderState
    permits OrderState.Pending,
            OrderState.Paid,
            OrderState.Shipping,
            OrderState.Completed,
            OrderState.Cancelled {

    String orderId(); // 所有状态都有的公共字段

    // 用 Record 定义每个具体状态
    record Pending(String orderId, BigDecimal amount) implements OrderState {}
    record Paid(String orderId, String paymentMethod, BigDecimal paidAmount) implements OrderState {}
    record Shipping(String orderId, String trackingNumber, String carrier) implements OrderState {}
    record Completed(String orderId, String completedAt) implements OrderState {}
    record Cancelled(String orderId, String reason, String cancelledAt) implements OrderState {}
}

现在每个状态都是一个不可变的 Record,只携带自己需要的字段。Pending 有金额,Shipping 有快递单号和承运商,Cancelled 有原因和时间。任何拿到一个 OrderState 对象的代码,都能确定某个字段到底存不存在——如果类型是 Shipping,快递单号就一定存在;如果类型是 Pending,根本就没有快递单号这个字段。

这个设计把以前靠”约定和注释”保证的东西,变成了靠”类型系统”强制保证的东西。

四、用模式匹配的 switch 替换 instanceof 链条

有了类型化的状态,接下来把业务逻辑写成 switch 表达式。JDK 21 的 switch 支持模式匹配,能直接解构 Record 的字段:

public class OrderService {

    // 生成展示信息——switch 模式匹配直接取出字段
    public String getDisplayInfo(OrderState state) {
        return switch (state) {
            case OrderState.Pending(var id, var amount) ->
                "订单 %s 待支付,金额:¥%s".formatted(id, amount);
            case OrderState.Paid(var id, var method, var paid) ->
                "订单 %s 已支付,方式:%s,实付:¥%s".formatted(id, method, paid);
            case OrderState.Shipping(var id, var tracking, var carrier) ->
                "订单 %s 配送中,%s:%s".formatted(id, carrier, tracking);
            case OrderState.Completed(var id, var at) ->
                "订单 %s 已于 %s 完成".formatted(id, at);
            case OrderState.Cancelled(var id, var reason, var at) ->
                "订单 %s 已取消,原因:%s,时间:%s".formatted(id, reason, at);
        };
    }

    // 判断是否可退款——同样穷举所有状态
    public boolean canRefund(OrderState state) {
        return switch (state) {
            case OrderState.Paid _ -> true;
            case OrderState.Shipping _ -> true;
            case OrderState.Pending _ -> false;
            case OrderState.Completed _ -> false;
            case OrderState.Cancelled _ -> false;
        };
    }

    // 状态流转:返回下一个状态
    public OrderState transition(OrderState current, String event) {
        return switch (current) {
            case OrderState.Pending p when "pay".equals(event) ->
                new OrderState.Paid(p.orderId(), "alipay", p.amount());
            case OrderState.Paid d when "ship".equals(event) ->
                new OrderState.Shipping(d.orderId(), "SF123456", "顺丰");
            case OrderState.Shipping s when "complete".equals(event) ->
                new OrderState.Completed(s.orderId(), "2025-01-15");
            case OrderState.Pending p when "cancel".equals(event) ->
                new OrderState.Cancelled(p.orderId(), "用户取消", "2025-01-14");
            default ->
                throw new IllegalStateException("不支持的状态流转: %s -> %s".formatted(current, event));
        };
    }
}

这段代码有几点值得单独拿出来说。

第一,switch 里的每个 case 直接解构了 Record 的字段。case OrderState.Pending(var id, var amount) 这一行同时做了三件事:判断类型是不是 Pending、把 Pending 强转、取出里面的 orderId 和 amount。以前需要三行代码,现在一行就表达清楚了。而且如果 Pending 的字段变了——比如加了一个 createdAt——所有解构 Pending 的 case 都会因为参数数量不匹配而编译失败,一个都不会漏。

第二,canRefund() 里用了未命名变量 _。当一个 case 不需要用到字段值时,用下划线表示”我知道你在,但我不需要”。这是 JDK 21 的功能,让代码意图更明确——不是忘了写变量名,是真的不需要。

第三,transition() 里用了 when 守卫条件。case OrderState.Pending p when "pay".equals(event) 的意思是”当前状态是 Pending 并且事件是 pay 时,走这个分支”。守卫条件把额外的判断逻辑嵌进了模式匹配里,不需要在 case 体内再写 if。

五、编译器替你检查分支完整性

密封类最有价值的地方还没说到:当你新增一个状态时,编译器会告诉你哪些 switch 需要修改。

假设产品经理要求增加一个”退款中”状态 Refunding。你需要做的第一步是在 OrderStatepermits 列表里加上它:

public sealed interface OrderState
    permits OrderState.Pending,
            OrderState.Paid,
            OrderState.Shipping,
            OrderState.Completed,
            OrderState.Cancelled,
            OrderState.Refunding {  // 新增

    // ... 现有内容不变

    record Refunding(String orderId, String reason, BigDecimal refundAmount) implements OrderState {}
}

加上这一行之后,项目里所有使用 switch 穷举 OrderState 的地方,编译时都会直接报错——因为你漏掉了 Refunding 这个分支。IDE 会立刻标红,告诉你哪些文件需要补上对 Refunding 的处理。这不是运行时抛异常,不是 code review 时互相提醒,是编译器强制要求。

对于维护周期超过两年的项目,这个特性能省掉大量的回归测试和线上事故。状态流转逻辑越复杂、涉及的业务方越多,密封类 + 模式匹配的编译期检查就越有价值。

六、一个完整的调用示例

把上面的各段代码串起来,看一个完整的业务流程:

public class OrderWorkflowDemo {
    public static void main(String[] args) {
        var service = new OrderService();

        // 创建订单,初始状态为 Pending
        OrderState order = new OrderState.Pending("ORD-001", new BigDecimal("299.00"));
        System.out.println(service.getDisplayInfo(order));

        // 支付
        order = service.transition(order, "pay");
        System.out.println(service.getDisplayInfo(order));
        System.out.println("可退款: " + service.canRefund(order));

        // 发货
        order = service.transition(order, "ship");
        System.out.println(service.getDisplayInfo(order));

        // 完成
        order = service.transition(order, "complete");
        System.out.println(service.getDisplayInfo(order));
        System.out.println("可退款: " + service.canRefund(order));
    }
}

输出结果:

订单 ORD-001 待支付,金额:¥299.00
订单 ORD-001 已支付,方式:alipay,实付:¥299.00
可退款: true
订单 ORD-001 配送中,顺丰:SF123456
订单 ORD-001 已于 2025-01-15 完成
可退款: false

整个过程中,order 变量的声明类型是 OrderState 接口,但每次流转后实际指向的具体 Record 实例都不同。调用方不需要做任何类型判断,getDisplayInfo()canRefund() 内部用 switch 模式匹配自动分发到正确的分支。

七、嵌套模式匹配:解构多层级数据结构

真实业务里经常遇到嵌套的数据结构。比如一个配送信息里包含了地址,地址又包含省市。JDK 21 的模式匹配支持嵌套解构,可以一次把多层数据都取出来:

record Address(String province, String city, String district) {}
record Shipment(String trackingNumber, Address destination) {}

public String extractCityFromShipment(Object obj) {
    return switch (obj) {
        // 嵌套解构:一层层拆到 city 字段
        case Shipment(_, Address(_, String city, _)) -> city;
        default -> "未知城市";
    };
}

这种嵌套解构在处理复杂 DTO、解析外部 API 返回的 JSON 树时很有用。不需要写层层判空和 getter 调用,一条 case 模式就把你关心的字段直接绑定到变量上。

八、什么时候还不需要用这套方案

不是所有的状态判断都需要重构为密封类加模式匹配。以下场景保持传统写法反而更合适:

状态数量少且不会增长。比如一个只有”启用”和”禁用”两种状态的开关,用布尔值或简单枚举就足够了。密封类的优势在于防止新增状态时的遗漏,两个状态不会新增的场景,投入产出比不高。

状态之间的字段差异不大。如果所有状态携带的数据结构几乎一样,用 Record 区分反而增加了类型数量,不如用一个类加一个状态字段来得直接。

状态经常需要序列化到数据库或缓存。密封类 + Record 的层级结构在序列化时比单一对象复杂。虽然 Jackson 等库已经支持得很好,但如果团队里有人不熟悉这些新特性,排查序列化问题会多花时间。

判断标准很简单:如果你的 instanceof 链条超过三个分支,或者每次加新状态你都要全文搜索改 N 个文件,那就值得考虑迁移。

九、总结

密封类、Record 和模式匹配三者组合在一起,把 Java 的类型判断从”运行时靠自觉”变成了”编译期强制验证”。状态机代码从一个扁平的类加一堆 if-else,变成了有层次结构的类型体系。每个状态只携带自己需要的字段,每个业务方法必须显式处理所有已知状态,任何遗漏都会在编阶段就暴露出来。

这不是炫技式的语法糖。这些特性解决的是一个很具体的问题:业务复杂度增长时,如何让类型系统帮你记住那些容易忘记的分支。订单处理只是一个例子,同样的模式可以用在审批流、游戏状态机、网络协议解析、支付回调处理等任何需要根据类型分发的场景。

文中的所有代码都需要 JDK 21 及以上版本。如果你是 Spring Boot 3.2 的项目,JDK 21 已经是官方推荐的基线版本,这些特性开箱即用。

Java模式匹配与密封类实战:把类型判断从一堆if-else变成编译期安全的状态机
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请联系本站我们会及时删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 java Java模式匹配与密封类实战:把类型判断从一堆if-else变成编译期安全的状态机 https://www.taomawang.com/server/java/2376.html

下一篇:

已经没有下一篇了!

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务