record 与 sealed:用 Java 写代数数据类型
学完你要能自己回答
- record 与 sealed 分别对应代数数据类型的哪一半?
- record 的 equals/hashCode/toString 在字节码里是怎么生成的?
- sealed 的 PermittedSubclasses 在 class 文件与反射里如何体现?
- 为什么 17 的 sealed switch 仍要 --enable-preview?穷尽性由谁检查?
- 给 sealed 层级加 default 为什么会失去保护?
组合与选择01
代数数据类型(ADT)由两种构造组成:积类型把多个值组合成一个(record 的组件列表),和类型表示「若干变体之一」(sealed 的 permitted 子类型)。枚举是 and 类型的退化形式:所有变体共享同一形状。
| 概念 | Java 17 的表达 | 回答的问题 |
|---|---|---|
| 积类型 | record 的组件 | 这个值由哪些字段组成? |
| 和类型 | sealed 的 permitted 子类型 | 它可能是哪几种形状? |
| 模式匹配 | instanceof 模式 / 预览 switch | 当前是哪种形状,字段是什么? |
| 穷尽性 | sealed + 预览 switch 编译检查 | 所有形状都覆盖了吗? |
下面用 sealed + record 表达一个最小表达式树。Expr 是选择,Num 与 Add 是组合;eval 不需要 default,因为编译器知道只有两种变体。
public class AdtDemo {
sealed interface Expr permits Num, Add {}
record Num(int value) implements Expr {}
record Add(Expr left, Expr right) implements Expr {}
static int eval(Expr e) {
return switch (e) { // 17 预览:--enable-preview
case Num n -> n.value();
case Add a -> eval(a.left()) + eval(a.right());
};
}
public static void main(String[] args) {
Expr e = new Add(new Num(3), new Add(new Num(4), new Num(5)));
System.out.println(eval(e));
}
}
12
case Add a 后调用 a.left(),不能写 case Add(Num l, Num r)。三个心智锚点02
锚点一:record 是透明的积类型
record 的组件就是它的全部状态。没有隐藏字段、没有 setter,equals / hashCode 按组件生成,因此它可以安全地作为模式匹配的载体。需要隐藏状态的对象不应该用 record。
锚点二:sealed 是封闭的和类型
sealed 不提供多态行为,只提供「变体集合有限且已知」这一事实。这个事实让编译器可以在 switch 上做穷尽性检查,也让读者一眼看到所有可能。
锚点三:穷尽性把变化变成编译错误
新增一个 permitted 子类型后,所有没有 default 的穷尽 switch 都会编译失败。这不是麻烦,而是把「漏处理新变体」从运行时故障提前到编译期。
先猜再看record 能替代所有 DTO 吗?
record 的编译形态01
record 编译成 final class,隐式继承 java.lang.Record。组件变成 private final 字段和同名访问器;equals / hashCode / toString 不是逐行手写,而是通过 invokedynamic 链接到 ObjectMethods 的启动方法。
$ javap -p Point.class
Compiled from "Point.java"
public final class Point extends java.lang.Record {
private final int x;
private final int y;
public Point(int, int);
public Point translate(int, int);
public final java.lang.String toString();
public final int hashCode();
public final boolean equals(java.lang.Object);
public int x();
public int y();
}
$ javap -p -c Point.class | grep invokedynamic
1: invokedynamic #26, 0 // InvokeDynamic #0:toString:(LPoint;)Ljava/lang/String;
1: invokedynamic #30, 0 // InvokeDynamic #0:hashCode:(LPoint;)I
2: invokedynamic #34, 0 // InvokeDynamic #0:equals:(LPoint;Ljava/lang/Object;)Z
ObjectMethods.bootstrap 接收组件名、访问器句柄和 toString 模板,在第一次调用时生成方法体。这样 JVM 不需要为每个 record 复制一份 equals 逻辑,语义也由语言规范统一保证。
- record 是 final,不能通过继承改变 equals/hashCode 的契约。
- 不能添加实例字段,保证「组件列表 = 全部状态」。
- 紧凑构造器在字段赋值前执行,适合校验和归一化。
- 数组组件的 equals 按引用比较;需要内容比较时必须显式重写。
sealed 的 class 文件与约束02
sealed 在 class 文件里体现为 PermittedSubclasses 属性。JVM 用它记录允许的直接子类型;反射的 getPermittedSubclasses() 读取的也是同一份信息。
$ javap -v Shape.class | grep -A2 "PermittedSubclasses:"
PermittedSubclasses:
Circle
Square
permits省略时,编译器把同一编译单元中的直接子类型写入PermittedSubclasses。- 命名模块中,子类型必须同模块;未命名模块中,必须同包。
- 每个直接子类型必须声明
final、sealed或non-sealed,不能留空。 non-sealed会重新打开继承,这一支不再参与穷尽性计算。- sealed 只约束继承,不约束实例化;构造器可见性才决定能否 new。
sealed 的封闭性主要在编译期和 class 文件层面表达,不是 JVM 的运行期访问控制。不要把它当成安全边界;它约束的是类型设计,不是恶意代码。
穷尽性由谁检查03
穷尽性由 javac 在编译期检查,依据是 selector 的静态类型和 permitted 子类型集合。17 的 switch 类型模式是预览,编译后会通过 invokedynamic 链接到 SwitchBootstraps.typeSwitch,运行时按模式顺序匹配。
sealed interface Expr permits Num, Add {}
record Num(int value) implements Expr {}
record Add(Expr left, Expr right) implements Expr {}
static int eval(Expr e) {
return switch (e) {
case Num n -> n.value();
};
}
javac -J-Duser.language=en --release 17 --enable-preview MissingCase.java
MissingCase.java:7: error: the switch expression does not cover all possible input values
return switch (e) {
^
1 error
如果补上 case Add a,编译通过;如果加 default,也能通过,但新增变体时不会再报错。default 把「穷尽性检查」降级成「兜底逻辑」,这是设计上的取舍,不是语法问题。
$ javap -p -c AdtDemo.class | grep typeSwitch
11: invokedynamic #13, 0 // InvokeDynamic #0:typeSwitch:(Ljava/lang/Object;I)I
case Num n && n.value() > 0 不能代表覆盖了 Num,仍需要一个无守卫的 Num 分支或 default。建模:事件与状态01
订单事件是典型的「有限变体,每个变体数据不同」。用 sealed interface 定义变体集合,用 record 定义每个变体的数据,再用预览 switch 做分支,可以把「漏处理事件」变成编译错误。
sealed interface Event permits Created, Paid, Shipped {}
record Created(String id) implements Event {}
record Paid(String id, int cents) implements Event {}
record Shipped(String id, String carrier) implements Event {}
static String describe(Event e) {
return switch (e) {
case Created c -> "created " + c.id();
case Paid p && p.cents() > 0 -> "paid " + p.cents();
case Paid p -> "invalid payment";
case Shipped s -> "shipped via " + s.carrier();
};
}
Event[] events = {
new Created("A1"),
new Paid("A1", 1200),
new Paid("A1", 0),
new Shipped("A1", "SF")
};
for (Event e : events) {
System.out.println(describe(e));
}
created A1 paid 1200 invalid payment shipped via SF
注意 Paid 出现了两次:带守卫的分支在前,无守卫的兜底分支在后。守卫分支不证明穷尽,编译器仍要求后面有覆盖 Paid 的分支。
渐进迁移:从 if 链到 ADT02
- 先识别「有限变体」:状态机、事件、AST、协议消息。变体由你控制、不会第三方扩展,才适合 sealed。
- 把每个变体改成 record,组件只保留该变体真正需要的数据;不要把所有字段堆进一个大类。
- 用 sealed interface 封闭变体集合,先保留现有 if / instanceof 逻辑,确保行为不变。
- 在测试覆盖后,把 if 链改成预览 switch(17)或等升级到 21 再正式使用;预览代码隔离在适配层。
- 新增变体时先让编译失败,再逐个补分支;不要用 default 图省事。
| 旧写法 | ADT 写法 | 收益 |
|---|---|---|
| 一个类 + type 字段 + 大量可空字段 | sealed interface + 每个变体一个 record | 非法状态不可表示 |
| if (e instanceof ...) 长链 | 穷尽 switch | 漏分支在编译期暴露 |
| 每个分支强转后取字段 | 类型模式绑定访问器 | 少一层强转 |
| default 抛异常 | 不写 default | 新增变体时编译失败 |
边界与反模式03
- 不要为了用 sealed 而封闭公共 SPI:第三方无法实现,扩展点就死了。
- 不要把所有领域对象都改成 record:需要生命周期、缓存、连接、监听器的对象不适合。
- 不要在 sealed switch 里加 default 后又声称「穷尽性受保护」;default 已经让保护失效。
- 不要把预览 switch 暴露到公共 API;升级 JDK 时语法和类文件都会变。
- 不要忽略二进制兼容:新增 permitted 子类型会让外部已编译的穷尽 switch 不再覆盖新类型。
- record 的数组组件要显式处理 equals/hashCode;默认按引用比较。
ADT 的价值在「非法状态不可表示」和「新增变体必须处理」,不在语法本身。如果类型集合其实是开放的,或者变体之间没有共同语义,强行 sealed 只会增加维护成本。
先猜再看01
先猜再看record 和 sealed 谁是「积」,谁是「和」?
Expr = Num | Add。