Administrator
发布于 2022-10-24 / 5680 阅读
116

Sealed Class 密封类的使用场景

从一次支付状态建模说起

早些年写支付结果,我习惯用一个 enum 加一堆 if:

enum PayStatus { SUCCESS, FAILED, PENDING, REFUNDED }
void handle(PayStatus s) {
    if (s == SUCCESS) { ... }
    else if (s == FAILED) { ... }
    // 漏写分支编译器一声不吭
}

新增一种状态时,所有 switch 都可能悄悄漏改,编译器帮不上忙,只能等线上炸。这种"靠人记得改全"的写法,在状态一多就必然出事。后来我们因为一个退款中状态没在统计里处理,漏算了一笔对账,才痛下决心改模型。

Sealed Class 的限制继承

Java 17 里 sealed class 允许我精确声明"哪些类能继承我",其他一律不行。这对"变体集合有限且已知"的场景是天作之合:

public sealed interface PayEvent
    permits PaySuccess, PayFailed, PayPending, PayRefunded {

    String orderId();
}

public final class PaySuccess implements PayEvent {
    public final long amount;
    public PaySuccess(String orderId, long amount) {
        this.amount = amount;
    }
    public String orderId() { return ...; }
}

注意 permits 里列出的每个类,必须声明自己是 final、sealed 或 non-sealed 之一,不能再用 abstract 裸着。我们统一用 final,因为支付事件是不可变的快照,不该再被继承。

和模式匹配简直是绝配

JDK 17 的模式匹配让 switch 处理密封类型时强制穷尽:

String desc = switch (event) {
    case PaySuccess s -> "成功 " + s.amount;
    case PayFailed f  -> "失败 " + f.reason;
    case PayPending p -> "处理中";
    case PayRefunded r -> "已退款";
};

哪天加了 PayClosed 子类却忘了改 switch,编译直接报错,而不是线上炸。sealed 把"变体集合"钉死在编译期,模式匹配把"是否覆盖全"也交给编译器检查,两者结合等于给状态机上了双保险。

领域建模的好处

把"有限且已知"的变体用 sealed + permits 钉死,是代数数据类型(ADT)的写法。它适合表达:

  • HTTP 响应:Ok / Created / Error(code, msg);
  • 状态机:支付、订单流转的各个节点;
  • 协议报文:不同指令类型的载荷。

比起散落的 enum+if,ADT 把"有哪些可能"写成类型系统的一部分。新接手的人看 interface 的 permits 就能穷尽所有情况,不用去翻文档猜还有没有漏的状态。

和 record 搭配更香

密封类经常和 record 一起用,因为事件/响应天然是不可变数据载体:

public sealed interface ApiResult<T>
    permits Ok, Err;

public record Ok<T>(T data) implements ApiResult<T> {}
public record Err<T>(int code, String msg)
        implements ApiResult<T> {}

这样统一返回结构,前端和调用方都能用模式匹配安全解包,再也不用猜 result 里到底有没有 data。

什么时候不该用

sealed 适合"封闭集合"。如果你的变体是开放的——比如插件体系、用户可扩展的策略——那它反而不合适,因为每加一个实现都要改 permits。那种场景还是用普通接口 + 注册表更灵活。

迁移老代码的实战

把老的 PayStatus enum 改成 sealed 不是一步到位。我们分两步走:先加 sealed 接口和四个 final 实现,老的 enum 暂时保留做兼容,调用方逐步从 switch(enum) 迁到 switch(sealed);等所有调用方迁完,删掉 enum。期间新老并存,编译期就能发现"还有谁没迁"——因为 enum 上加字段不会影响 sealed 的穷尽检查,反过来迁到 sealed 后漏处理新状态立刻编译报错。这种"编译器当测试"的体验,是 sealed 最大的隐性价值。

和枚举的取舍

那什么时候还用 enum?状态没有附加数据、纯标签时,enum 更轻。比如"订单来源:APP/WEB/API"只是个标签,不需要带字段,enum 足够。sealed 胜在"每个变体能带不同结构的数据"——成功带金额、失败带原因,失败和成功的数据形状不一样,enum 做不到。所以判断标准就一条:变体之间数据形状是否不同。不同用 sealed,相同用 enum。

和 record 搭配的实战收益

把支付事件用 sealed interface + record 实现后,最大的收益是"不可变 + 穷尽"。事件一旦创建不能改字段,避免了老代码里有人中途 setStatus 导致状态混乱;模式匹配强制穷尽,加状态忘处理编译就拦。我们接手的新人改支付逻辑时,编译器替他把"所有状态都要处理"这件事盯住了,没再出现漏分支的线上 bug。这点投入,在一次资损排查里就回本了。

小结

sealed 不是用来炫技的,它把"哪些子类存在"这件事从运行时提前到了编译期。模型封闭时,它比 enum 更灵活(能带不同结构的字段),比自由继承更可控(不会冒出意料之外的子类)。配合 record 和模式匹配,是 Java 17 里最值得用起来的组合之一。

参考