Java 密封类实战:封闭层次与穷尽模式匹配

Java 密封类(Sealed Classes)自 Java 17 正式转正,它让你可以声明「一个类或接口只允许指定的少数子类继承」。这种封闭层次(closed hierarchy)把”有哪些子类型”从运行时约定变成编译期契约,再配合模式匹配的穷尽检查,就能在编译阶段消灭遗漏分支、非法子类这两类最常见的类型安全隐患。本文用可运行的代码,讲清密封类怎么写、什么时候该用、以及它和枚举、普通继承的边界。

一、什么是密封类(Sealed Classes / Interfaces)

普通类的继承是开放的:任何人都能写 extends 派生新子类。这在框架里很灵活,但在”领域模型”场景下恰恰是 bug 温床——你本以为某个类型的子类只有三种,半年后同事加了一种,所有 switch 分支悄无声息地漏掉了它。密封类就是用来把这种”封闭世界假设”写进类型系统的:父类用 sealed 修饰,并显式列出被允许的子类(permits)。

从 Java 17 起,sealed、non-sealed、final 三者共同构成一套完整的权限修饰,让层次结构的开放程度一目了然。

二、为什么需要封闭层次

开放继承有两个典型痛点。其一,分支遗漏:对父类做 switch 或 instanceof 判断时,编译器无法知道”是不是还有没处理的子类”,只能靠人肉保证。其二,非法扩展:一个表示”支付状态”的类,本不该被业务代码随便派生出第 N 种状态,否则校验逻辑会失控。密封类从语言层面同时解决这两个问题:子类集合固定,且编译器能据此要求 switch 必须穷尽。

如果你对 Java 的现代语法演进感兴趣,这篇 Java 模式匹配实战 讲透了 instanceof 与 switch 类型模式,本文的穷尽 switch 正是建立在它之上。

三、语法:sealed / permits / non-sealed / final

3.1 三种权限修饰

  • sealed:封闭类,必须用 permits 列出允许的子类。
  • final:叶子子类,不能再被继承(最严格的封闭)。
  • non-sealed:反向开放,允许后续任意继承(在密封层次中”开个口子”)。

注意:密封类的直接子类必须三选一,不能用普通的”既不 final 也不 sealed”——编译器会报错,逼你显式决策开放程度。

3.2 同一个编译单元约束

被 permits 的子类,要么写在同一个 Java 文件(同一个编译单元)里,要么显式声明自己也是 sealed/non-sealed/final。下面的例子把整套层次放在一个文件内,最直观:

// 封闭接口:只允许 Circle 与 Rectangle 两种实现
public sealed interface Shape permits Circle, Rectangle, Triangle {

    double area();
}

// final 叶子:彻底封闭,不能再被继承
public final class Circle implements Shape {
    private final double r;
    public Circle(double r) { this.r = r; }
    @Override public double area() { return Math.PI * r * r; }
}

// 继续 sealed:Triangle 还可以再限制自己的子类
public sealed class Rectangle implements Shape permits Square {
    protected final double w, h;
    public Rectangle(double w, double h) { this.w = w; this.h = h; }
    @Override public double area() { return w * h; }
}

// non-sealed:在封闭层次里"开个口子",允许外部随意扩展
public non-sealed class Triangle implements Shape {
    @Override public double area() { return 0; /* 简化 */ }
}

这里 Square 只能继承 Rectangle(因为 Rectangle 是 sealed 且 permits 了它),而不能直接实现 Shape——封闭约束在每一层都生效。

四、密封类 + 模式匹配:编译器保证穷尽

密封类的真正威力,是让 switch 表达式具备”穷尽性检查”。因为编译器确切知道 Shape 只有三种可能,当你写 switch 漏掉任一分支时,它会直接编译报错,而不是留到线上才爆 NullPointerException 或默认分支。

public String describe(Shape s) {
    return switch (s) {
        case Circle c   -> "圆形,半径 " + c.r();
        case Rectangle r -> "矩形," + r.w() + "x" + r.h();
        case Triangle t -> "三角形";
        // 如果将来给 Shape 新增第 4 个 permits 子类,
        // 这里会立即编译失败,逼你补齐分支
    };
}

对比传统写法:用 if (s instanceof Circle) 链或普通 switch,编译器根本不知道还有没有别的子类,漏处理只能靠 review 和人肉测试兜底。密封类把这种”结构性保证”交给了编译器。

五、实战场景一:结果类型 Result 替代异常滥用

很多团队用”抛异常”表达业务失败,导致正常分支和错误分支混在一起。用密封类定义一个 Result<T>,成功与失败成为两个显式子类型,调用方必须处理失败——编译器强制,逃不掉。

public sealed interface Result<T> permits Ok, Err {

    static <T> Result<T> ok(T value)  { return new Ok<>(value); }
    static <T> Result<T> err(String msg) { return new Err<>(msg); }
}

public final class Ok<T> implements Result<T> {
    public final T value;
    public Ok(T value) { this.value = value; }
}

public final class Err<T> implements Result<T> {
    public final String message;
    public Err(String message) { this.message = message; }
}

// 使用时编译器要求处理两种可能
public <T> T unwrap(Result<T> r) {
    return switch (r) {
        case Ok<T> ok -> ok.value;
        case Err<T> err -> throw new IllegalStateException(err.message);
    };
}

这套手法在 Scala、Rust 里是标配,Java 17 之后用密封类也能干净地落地,比到处 try/catch 更易读,也更好测试。

六、实战场景二:表达式 / AST 建模

解释器、规则引擎、配置 DSL 经常需要表示一棵表达式树。密封类 + record 模式(Java 21 正式)能把”求值”写成穷尽且解构的 switch,新增节点类型时旧代码必然编译失败,不会悄悄漏算。

public sealed interface Expr permits Num, Add, Mul { }

public record Num(int value) implements Expr { }
public record Add(Expr left, Expr right) implements Expr { }
public record Mul(Expr left, Expr right) implements Expr { }

public int eval(Expr e) {
    return switch (e) {
        case Num n        -> n.value();
        case Add a        -> eval(a.left()) + eval(a.right());
        case Mul m        -> eval(m.left()) * eval(m.right());
        // 新增 Sub 节点时,这里编译报错,杜绝漏算
    };
}

把这部分逻辑放进 Spring Boot 服务时,注意 JDK 版本要与运行环境一致,Spring Boot 3 升级踩坑实录 里有一份 Jakarta 与 JDK 17+ 的兼容清单可参考。

七、实战场景三:订单状态机

订单状态如果用字符串或枚举表达,状态流转常常散落在各处 if 里。用密封类把”状态”建模为类型,再把”流转”写成穷尽 switch,非法流转在编译期就被挡住。

public sealed interface OrderState permits Created, Paid, Shipped, Done { }
public final class Created implements OrderState { }
public final class Paid    implements OrderState { }
public final class Shipped implements OrderState { }
public final class Done    implements OrderState { }

public boolean canShip(OrderState s) {
    return switch (s) {
        case Created c -> false;   // 未付款不能发货
        case Paid p    -> true;    // 已付款可发货
        case Shipped s -> false;   // 已发货不可重复
        case Done d    -> false;
    };
}

如果业务后来又引入 Refunded 状态,所有 switch 都会立刻编译失败,提示你补全每个分支——这正是封闭层次相对于”字符串 + 注释文档”的核心优势。

八、与枚举、普通继承的取舍

三者不是替代关系,而是按”是否需要携带不同结构的数据”来选:

方案适合场景能否携带不同字段子类能否带状态
枚举 enum固定常量集合,如星期、状态码每个常量结构相同有限(靠字段)
密封类 sealed固定但结构各异的子类型能,每个子类自由定义能,可含字段与方法
普通继承需要长期、开放地扩展能能

经验法则:当”子类型的种类固定,但每个子类型的数据结构不同”时,密封类是最优解;如果只是几个同构常量,用 enum 更轻;只有当未来确实要开放扩展,才用普通继承。

九、常见坑与版本边界

  • JDK 版本:密封类是 Java 15 预览、17 正式,低于 17 无法使用,部署前确认运行时 JDK。
  • permits 必须穷尽:声明 sealed 却漏写某个子类,或子类不在同一编译单元且未显式修饰,都会编译失败。
  • 不要过度密封:把本该开放扩展的 SPI / 插件接口也封死,会牺牲框架的扩展性,只在”领域模型”层面使用。
  • 序列化风险:密封类被反序列化时仍可能被构造出未预期实例,安全敏感场景配合 readObject 防护。

在 Java 21 上,密封类还能与虚拟线程、record 模式组合出更现代的写法;想了解 Java 21 的新特性落地,Java 21 虚拟线程实战 值得一读。若你用 Java 做 AI 应用编排,LangChain4j 实战 也用到了密封风格的结果建模思路。

十、落地检查清单

  • 这个父类”子类型集合”是否真的固定?固定才用 sealed。
  • 每个直接子类是否都显式声明了 final / sealed / non-sealed?
  • 对所有父类的 switch 是否利用了穷尽检查(不写 default 分支,让编译器兜底)?
  • 新增子类型时,是否确认了所有 switch 都已重新编译通过?
  • CI 是否锁定 JDK 17+?建议在流水线里加一条 javac -source 17 的编译门禁。

密封类不是语法糖,而是把”领域边界”写进类型系统的工程手段。它让”有哪些可能”从文档约定升级为编译器契约,配合模式匹配,把一大类运行时隐患消灭在编译期。下次当你想用字符串或枚举表达一个”固定但结构各异”的模型时,先想想:这能不能用密封类?

上一篇 容器内时区错乱:定时任务跑错时间的排查复盘
下一篇 大模型工具调用可靠性实战:超时、重试与降级
最新评论
Docker 网络模式详解:bridge/host 生产选型 - FSData
Docker 网络模式详解:bridge/host 生产选型 - FSData
8月20日
[…] 注意最后那条:把端口绑定到 127.0.0.1,再由宿主机上的 Nginx 反代进来,比直接 -p 80:80 暴露安全得多,也方便统一做
Vue3 + TypeScript 工程化:类型安全的前端架构 - FSData
Vue3 + TypeScript 工程化:类型安全的前端架构 - FSData
8月20日
[…] Pinia 的工程化用法,可以参考我们之前的实战文章 《Vue3 组合式 API + Pinia:状态管理从入门到实战》,那里覆盖了 Sto
2026 年最值得装的 10 个 VS Code 插件(AI/前端/后端/效率全覆盖) - FSData
2026 年最值得装的 10 个 VS Code 插件(AI/前端/后端/效率全覆盖) - FSData
8月19日
[…] 状态,点一下就能起停容器,配合我们讲过的 Docker Compose 编排 […]
Redis 缓存设计:穿透、击穿、雪崩与最佳实践 - FSData
Redis 缓存设计:穿透、击穿、雪崩与最佳实践 - FSData
8月19日
[…] 缓存三大坑的本质都是”缓存失效导致请求穿透到数据库”。记住口诀:穿透用布隆、击穿用锁、雪崩用错峰。配合合理的过期策略
GitHub Actions 实战:从零搭建 CI/CD 流水线 - FSData
GitHub Actions 实战:从零搭建 CI/CD 流水线 - FSData
8月19日
[…] Docker 镜像部署。结合我们之前写的 Docker Compose 一键编排实战,可以在 CI […]