Sealed Classes 密封类

这一章我们看 Sealed Classes(密封类)——Java 17 正式发布的特性(JEP 409)。它和上一章的 Records 是”亲兄弟”——Record 解决”封闭的值对象”,Sealed 解决”封闭的继承层次”。两者配合 Pattern Matching,让 Java 第一次能优雅地建模函数式语言的核心概念——代数数据类型(Algebraic Data Type,ADT)

一、为什么需要密封类:开放继承的代价

1.1 Java 传统的”开放继承”

Java 的类继承默认是开放的——任何 public class 都能被任意 extends(只要不是 final)。这种”开放”在框架里是好事(可扩展),但在领域建模里经常是灾难:

// 你定义了一个 Shape 类
public class Shape { ... }

// 你以为只有 Circle/Square/Triangle 三个子类
// 但别人可以这样:
class WeirdShape extends Shape { ... }   // 你控制不了
class MaliciousShape extends Shape { ... }

问题:

  • switch 不敢省 default——你不知道未来会不会冒出 WeirdShapeswitch 必须写 default,编译器无法帮你检查”穷举所有情况”。
  • equals/hashCode 难写——Shape.equals 要不要考虑子类?怎么判断”是同类”?
  • 领域约束被破坏——业务规则”只有三种形状”在代码层面无法表达,全靠口头约定。

1.2 密封类要解决什么

Sealed Classes 让你显式声明”哪些类可以继承我”——把继承权”封死”在白名单里:

public sealed class Shape permits Circle, Square, Triangle {}

Shape 现在只允许 CircleSquareTriangle 三个类继承——别的类 extends Shape 直接编译错。这把”封闭的继承层次”作为语言特性固化下来。

1.3 历史背景

Sealed Classes 是 Java 17 正式发布的(JEP 409),经历了 Java 15 预览、Java 16 二次预览、Java 17 转正。它和 Records、Pattern Matching 是一组配套设计——三者协同构成 Java 的”现代数据建模”。

二、密封类的定义:sealed + permits

2.1 基本语法

public sealed class Shape permits Circle, Square, Triangle {}
//                ^^^^^^                ^^^^^^^^^^^^^^^^^^^^^^^^^
//                声明为密封类           允许继承的子类列表

sealed 是修饰符,permits 后面跟子类列表(逗号分隔)。子类必须在同一个模块或同一个包里——这是密封类的硬性约束。

2.2 子类的”三选一”修饰

permits 列表里的子类,必须用下面三种修饰之一:

修饰含义能否再被继承
final最终类,不能被继承
sealed密封类,继续 permits 自己的子类是,但必须声明白名单
non-sealed非密封类,回到开放继承是,任意继承
public sealed class Shape permits Circle, Square, Triangle {}

final class Circle extends Shape {}            // 终结, 不能再继承
sealed class Square extends Shape permits FilledSquare, OutlinedSquare {}
non-sealed class Triangle extends Shape {}     // 开放, 任意类可继承

// Square 的子类
final class FilledSquare extends Square {}
final class OutlinedSquare extends Square {}

// Triangle 是 non-sealed, 任意类可继承
class RightTriangle extends Triangle {}
class IsoscelesTriangle extends Triangle {}

为什么强制三选一? 因为如果不强制,子类默认开放继承,密封性就被悄悄破坏了。Java 选择”显式比隐式好”——你想开放就 non-sealed,想封死就 final,想半封就 sealed,必须明说。

2.3 省略 permits:同文件子类

如果子类和密封类在同一个源文件里,可以省略 permits——编译器自动找同文件的直接子类:

// Shape.java
public sealed class Shape {}   // 省略 permits

final class Circle extends Shape {}
final class Square extends Shape {}
final class Triangle extends Shape {}

编译器自动推断 Shape permits Circle, Square, Triangle。这是 Java 17 之后常见的紧凑写法。

2.4 接口也可以密封

sealed 不仅能修饰类,还能修饰接口

public sealed interface Currency permits CNY, USD, EUR {}

record CNY(long fen) implements Currency {}
record USD(long cents) implements Currency {}
record EUR(long cents) implements Currency {}

接口 + Record + Sealed 的组合,是建模 ADT 的标准范式(后面详讲)。

三、密封类的约束

3.1 同模块或同包

permits 列表里的子类,必须和密封类在同一个模块里(如果密封类在命名模块),或者同一个包里(如果在不命名模块)。这是为了防止”第三方恶意添加子类”。

// 模块 A
module A { exports com.example; }
public sealed class Shape permits com.example.Circle, com.other.Square {}  // 错! Square 不在模块 A

3.2 子类必须直接 extends

permits 列表里的类必须直接继承密封类——不能是”孙子类”。如果 Shape permits Circle,那 Circle 必须 extends Shape,不能 Circle extends Ellipse extends Shape

3.3 子类必须显式声明

子类必须final/sealed/non-sealed 修饰——不能省略。漏了就编译错。

sealed class Shape permits Circle {}
class Circle extends Shape {}   // 编译错! 必须是 final/sealed/non-sealed

四、代数数据类型(ADT):函数式语言的核心

4.1 什么是 ADT

代数数据类型(Algebraic Data Type,ADT) 是函数式语言(Haskell、OCaml、Scala)的核心特性——用”积类型(Product Type)“和”和类型(Sum Type)“组合出复杂的数据结构。

  • 积类型Point(int x, int y) ——一个点 = x y。两者都有,“乘法”组合。
  • 和类型Shape = Circle | Square | Triangle ——一个形状 = 圆 三角。“加法”组合。

Java 之前没有”和类型”——任何 class 都能被任意继承,无法表达”Shape 只有这三种”。Sealed Classes 让 Java 终于有了”和类型”。

4.2 Java 建模 ADT 的范式

// 和类型: Shape 只有这三种
public sealed interface Shape permits Circle, Rectangle, Triangle {}

// 积类型: 每种形状有自己的属性
record Circle(double radius) implements Shape {}
record Rectangle(double width, double height) implements Shape {}
record Triangle(double a, double b, double c) implements Shape {}

这就是经典的 ADT 建模——sealed interface 定义”和类型”,record 定义”积类型”。每个 record 是不可变的值对象,整个继承层次是封闭的。

4.3 ADT 的好处:穷举检查

ADT 最大的好处是 switch 穷举检查(exhaustiveness)——编译器能检查”是否处理了所有情况”:

double area(Shape shape) {
    return switch (shape) {
        case Circle(double r) -> Math.PI * r * r;
        case Rectangle(double w, double h) -> w * h;
        // case Triangle 漏了! 编译器警告: 没有穷举
    };
}

如果将来加 case Pentagon,所有 switch 都会编译警告——逼迫你更新所有处理 Shape 的地方。这是 ADT + Pattern Matching 的”编译期保证”,传统 Java 做不到。

五、Sealed + Record + Pattern Matching:组合拳

下面这个例子展示三者的经典组合——建模一个表达式树(AST),求值时用 Pattern Matching 解构。这是编译器、规则引擎、配置系统的经典场景。

// 抽象表达式: 只有三种
sealed interface Expr permits Num, Add, Mul {}

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

// 求值
double eval(Expr expr) {
    return switch (expr) {
        case Num(double v) -> v;
        case Add(Expr l, Expr r) -> eval(l) + eval(r);
        case Mul(Expr l, Expr r) -> eval(l) * eval(r);
        // 不需要 default! 编译器知道只有这三种
    };
}

// 表达式: (3 + 4) * 5
Expr expr = new Mul(new Add(new Num(3), new Num(4)), new Num(5));
System.out.println(eval(expr));   // 35.0

eval 不需要 default——Expr 是 sealed,编译器知道只有 Num/Add/Mul 三种。这是 ADT + Pattern Matching 的威力——编译期保证穷举,未来加 Sub 子类会立刻报错提醒你。

六、Sealed vs Final vs Abstract

修饰继承性何时用
final完全封闭,不能继承单一不可变值(如 String)
sealed白名单封闭,指定子类可继承领域建模的”和类型”,子类已知
non-sealed开放继承在密封层次里”放开口子”
(无修饰)默认开放继承传统 Java 类
abstract必须被继承才能用模板方法、抽象基类

Sealed 不是替代 final/abstract,而是填上中间的空白——介于”完全开放”和”完全封闭”之间的”白名单封闭”。

七、实战:用 Sealed + Record 建模领域

下面的例子演示用 Sealed + Record 建模一个”支付方式”的 ADT,配合 switch 模式匹配做不同支付的处理。这是真实业务里 ADT 的典型应用。

Java · 在线运行

观察重点

  • processdescribe 都没有 default——switch 穷举了所有 permits 的子类,编译器认可。
  • CreditCard 紧凑构造器校验卡号——ADT 的每个子类可以有自己的不变量。
  • PaymentMethod.class.isSealed()——反射能查 sealed 状态,getPermittedSubclasses() 拿白名单。
  • Squaresealed abstract——可以同时密封和抽象,让 Square 自己也是封闭层次的一部分。
  • Trianglenon-sealed——它的子类 RightTriangle/IsoscelesTriangle 不在 Shape.permits 列表里,但能”穿透”上来——case Triangle t 必须作为兜底,因为它的子类不可枚举。

八、和 Pattern Matching 的协作

这一章反复出现的 switch 模式匹配是 Java 21 的特性(下一章详讲)。Sealed Classes 是 Pattern Matching 的”最佳搭档”:

  • Sealed 提供”封闭”——编译器知道所有子类。
  • Pattern Matching 提供”解构”——直接拿到组件。
  • 穷举检查——switch 漏掉任何一种,编译器警告。

没有 Sealed 的 switch 必须写 default(因为可能有未知子类),有了 Sealed 才能享受”省略 default + 编译期穷举”。

九、Sealed 的实际应用场景

场景例子
领域建模支付方式、订单状态、用户角色——业务上”封闭枚举”的概念
AST(抽象语法树)表达式树、JSON/HTML 节点、规则引擎的规则
状态机有限状态机的状态集——sealed interface State permits Idle, Running, Paused, Done
API 返回值sealed interface ApiResult permits Success, Error<T>
配置项sealed interface Config permits YamlConfig, JsonConfig, EnvConfig

何时不用 Sealed

  • 子类是开放的、第三方可扩展的(如 Spring 的 BeanPostProcessor)。
  • 框架的扩展点——故意开放给用户继承。
  • 子类数量爆炸——sealed 适合”少而稳定”的层次。

十、本章小结

概念核心要点
sealed class声明密封类,permits 指定白名单子类
permits列出允许继承的子类
子类三选一final / sealed / non-sealed,必须显式声明
同模块/同包子类必须和密封类同模块或同包
接口也能 sealedsealed interface
ADT代数数据类型 = 和类型(sealed)+ 积类型(record)
穷举检查switch 模式匹配可省略 default,编译器检查穷举
反射 APIisSealed()getPermittedSubclasses()

记忆口诀

  • sealed 是”白名单继承”——只许 state-permits 的子类继承。
  • permits 是”邀请函”——被邀请的子类才能进门。
  • 子类三选一——final 封死、sealed 半封、non-sealed 放开。
  • 同模块同包——子类必须和密封类”住一起”。
  • sealed + record = ADT——和类型 + 积类型,函数式建模。
  • 穷举检查——编译器帮你查 switch 漏没漏。

结语:Java 的函数式建模终于成熟

Sealed Classes 是 Java 在”领域建模”上的重要补完——它和 Records、Pattern Matching 三者构成现代 Java 数据建模的”三件套”:

  • Records —— 不可变值对象(积类型)。
  • Sealed Classes —— 封闭继承层次(和类型)。
  • Pattern Matching —— 解构 + 穷举检查(用 ADT)。

这三者协同,让 Java 第一次能像 Scala、Haskell 一样优雅地建模代数数据类型。下一章我们详细讲 Pattern Matching 模式匹配——把这三件套的”最后一块拼图”讲透,看看 instanceof 和 switch 如何配合密封类和 Record,把丑陋的 if-else 链变成优雅的 switch 表达式。我们下一章见。