Sealed Class 是什么?permits、sealed、non-sealed 如何配合?
简化版
Sealed Class 是 Java 17 正式加入的受限继承机制,sealed 类或接口只允许指定的直接子类型扩展或实现。每个获准的直接子类型必须明确选择 final、sealed 或 non-sealed,分别表示终止继承、继续限制继承或重新开放继承。
详细版
父类型通常使用 permits 列出直接子类型;若允许的子类型与父类型在同一源文件中,编译器可以推断并省略 permits。命名模块中,允许的直接子类型必须与父类型位于同一模块;未命名模块中则必须位于同一包。
密封层次适合支付结果、语法树、领域命令等候选类型可控的模型,并可帮助模式 switch 做穷尽性检查。它限制的只是直接继承关系:non-sealed 子类下方可以再次出现任意后代,因此密封类型不是安全边界或权限控制机制。
完整版教学
一、为什么要限制继承集合
普通接口允许任意代码增加实现,调用方无法假设自己知道所有子类型。某些领域的候选集合本来就是有限的,例如支付结果只有成功、失败和处理中;把这种约束写进类型系统,编译器就能帮助维护模型。
public sealed interface PaymentResult
permits Success, Failure, Pending {}
public record Success(String tradeNo) implements PaymentResult {}
public record Failure(String reason) implements PaymentResult {}
public final class Pending implements PaymentResult {}
Record 隐式为 final,因此可以直接作为获准的最终实现。
二、三种子类型选择
获准的直接子类型不能保持态度不明,必须声明以下修饰符之一:
final:到此为止,不能再产生子类。sealed:仍允许继承,但继续用 permits 限制下一层直接子类型。non-sealed:主动退出限制,这个分支重新变为开放继承。
non-sealed 只能用在 sealed 父类型的直接子类或子接口上,不能给任意普通类添加。父类型只控制下一层,后续是否封闭由每个分支继续决定。
三、位置约束与 permits 推断
在命名模块中,父类型与所有获准直接子类型必须属于同一模块,可以分布在不同包;在未命名模块中,它们必须在同一包。这个约束使编译器和 JVM 能可靠识别封闭层次,同时避免跨任意模块声明许可关系。
sealed interface Node {
record NumberNode(int value) implements Node {}
record AddNode(Node left, Node right) implements Node {}
}
直接子类型都声明在同一个源文件中时,可省略 permits,由编译器推断。大型模型显式列出 permits 往往更便于阅读。
四、与模式 switch 配合
当 switch 的选择器是密封类型,编译器可以根据允许的子类型判断分支是否完整:
static String describe(PaymentResult result) {
return switch (result) {
case Success s -> "成功:" + s.tradeNo();
case Failure f -> "失败:" + f.reason();
case Pending p -> "处理中";
};
}
以后给密封层次增加新的直接子类型,原有穷尽 switch 可能在重新编译时失败,从而提醒维护者处理新情况。这比依赖一个宽泛的 default 分支更利于发现模型变化。
五、它不等于安全机制
Sealed Class 控制的是哪些类型能直接继承,并不限制谁能创建对象、调用方法、反射读取数据,也不能替代模块封装和权限校验。只要某条分支声明为 non-sealed,外部还可在满足普通 Java 可见性规则的地方继续扩展它。
选择 sealed 表示领域设计者确信候选集合应受控。插件接口、驱动接口等天然希望第三方扩展的 SPI,通常不应该密封。
六、用类型树看“只限制直接孩子”
sealed Payment permits Card, Cash
├─ final Card // 分支终止
└─ non-sealed Cash // 从这里重新开放
├─ CouponCash
└─ OtherCash
父类型的 permits 只列直接子类型,不需要也不能把所有孙类型都塞进去。若允许类型 Cash 选择 non-sealed,那么任意符合普通可见性规则的代码都可继续继承 Cash,switch 对 Payment 的穷尽分支只需处理 Cash 这一大类,但在 Cash 分支内部不能假设只有当前已知的两个后代。
| 子类型修饰符 | 还能否扩展 | 下一层是否受控 | 适用意图 |
|---|---|---|---|
final | 否 | 分支结束 | 完全封闭变体 |
sealed | 是 | 继续 permits | 分层受限模型 |
non-sealed | 是 | 重新开放 | 刻意保留扩展点 |
若一个领域模型原有 3 个最终变体,新增第 4 个直接变体会让依赖穷尽 switch 的源码在重新编译时暴露遗漏。这是 sealed 的维护价值,但并不替代跨版本部署测试;调用方与模型 JAR 版本不一致时仍可能出现运行时匹配失败。
记忆钩子:sealed 管“谁有资格直接继承”,final/sealed/non-sealed 管“这个分支下一步关、控、开”。
七、常见误区与追问
- 误区:permits 要列出所有层级的后代。 它只列允许的直接子类或直接子接口。
- 误区:sealed 父类型的每个后代最终都必须封闭。 non-sealed 分支可以明确重新开放继承。
- 误区:密封类型可以阻止未授权对象被创建或方法被调用。 它约束继承结构,不是鉴权、模块封装或安全沙箱。
- 误区:同一源文件时仍必须写 permits。 直接子类型位于同一编译单元时可由编译器推断,但显式写出有时更易读。
- 追问:命名模块和未命名模块的位置要求有什么区别? 命名模块要求同模块;未命名模块要求同包。
- 追问:Record 为什么适合作为 sealed 的叶子? Record 隐式 final,天然表达一个封闭数据变体。
- 追问:插件 SPI 适合 sealed 吗? 通常不适合,因为 SPI 的目的正是允许未知第三方实现。
八、加强记忆
sealed 父类型列出允许的直接孩子,孩子必须三选一:final 关闭、sealed 继续受控、non-sealed 重新开放。它最适合有限领域模型并能增强 switch 穷尽检查,但只约束继承层次,不提供权限安全。