record 是透明的数据载体,sealed 是受控的继承层次,模式匹配是”检查类型 + 提取数据”的一体化写法。
单独看,它们各自只是语法糖;合起来才是 Java 版的代数数据类型(ADT):sealed 说明”一共有哪几种可能”,record 说明”每种可能携带哪些数据”,switch 模式匹配负责把这两种信息穷尽地消费掉。
本文的代码在 JDK 21.0.8 与 JDK 25 上编译运行通过;凡是与版本绑定的结论都标注了对应的 JEP 编号与转正版本。

三个特性的版本坐标

写 Java 最容易踩的坑是”我记忆里 JDK 8 就有/就删了”。先把版本对齐:

  • record:JEP 395,JDK 16 转正(此前 JDK 14 的 JEP 359、JDK 15 的 JEP 384 两次预览)
  • instanceof 模式:JEP 394,JDK 16 转正(JEP 305 / JDK 14、JEP 375 / JDK 15 预览)
  • sealed 类与接口:JEP 409,JDK 17 转正(JEP 360 / JDK 15、JEP 397 / JDK 16 预览)
  • record 解构模式:JEP 440,JDK 21 转正(JEP 405 / JDK 19、JEP 432 / JDK 20 预览)
  • switch 的模式匹配:JEP 441,JDK 21 转正(JEP 406 / JDK 17、JEP 420 / JDK 18、JEP 427 / JDK 19、JEP 433 / JDK 20 四次预览)
  • 未命名变量与未命名模式 _:JEP 456,JDK 22 转正(JEP 443 / JDK 21 预览)
  • 原始类型(intlongbooleandouble 等)进入模式匹配:截至 JDK 26 仍是预览(JEP 455 / JDK 23 → JEP 488 / JDK 24 → JEP 507 / JDK 25 → JEP 530 / JDK 26,第四次预览),所以生产代码里暂时不要指望 case int i -> 能不用 --enable-preview 编译

record:透明的数据载体

record 自动生成了什么

1
2
public record Point(int x, int y) {
}

这一行等价于一个手写了几十行的值对象:

  • 两个 private final 字段,名字与组件一一对应
  • 规范构造器(canonical constructor),参数顺序与组件列表一致
  • 访问器 x()y(),注意不叫 getX()
  • equals / hashCode / toString:逐组件用 Objects.equals / Objects.hashCode 计算,toString 输出 Point[x=1, y=2] 这种形式
  • 类本身隐式 final,隐式继承 java.lang.Record
  • 没有 setter,也没有无参构造器
1
2
3
4
5
6
public static void main(String[] args) {
Point p = new Point(1, 2);
System.out.println(p); // Point[x=1, y=2]
System.out.println(p.equals(new Point(1, 2))); // true
System.out.println(p.x() + p.y()); // 3
}

record 里只能有静态字段,除组件之外的实例状态一律不允许;也不能有实例初始化块。这两条限制换来的是”看到构造调用就知道对象全部状态”的可读性。

规范构造器里做校验

需要校验、规范化入参时,显式写出规范构造器:

1
2
3
4
5
6
7
8
9
10
public record Range(int lo, int hi) {

public Range(int lo, int hi) {
if (lo > hi) {
throw new IllegalArgumentException("lo > hi: " + lo + " > " + hi);
}
this.lo = lo;
this.hi = hi;
}
}
  • 显式写规范构造器时,参数类型与顺序必须与组件列表完全一致,并且必须给每个字段赋值
  • 这是唯一能写 this.lo = ... 的地方;换到紧凑构造器里写同样的赋值,编译直接失败(无法为 final 变量 lo 分配值

紧凑构造器

校验、防御性拷贝这类”只是调整入参”的逻辑,用紧凑构造器更省事:

1
2
3
4
5
6
7
8
public record Range(int lo, int hi) {

public Range {
if (lo > hi) {
throw new IllegalArgumentException("lo > hi: " + lo + " > " + hi);
}
}
}
  • 省略参数列表,参数被隐式声明;编译器在构造器末尾自动补 this.lo = lo; this.hi = hi;
  • 构造器体内可以重新给参数赋值(做规范化),但不能给字段赋值
  • 组件是对象引用时的标准写法是防御性拷贝:
1
2
3
4
5
6
7
8
import java.util.List;

record Order(String id, List<String> lines) {

Order {
lines = List.copyOf(lines);
}
}

静态工厂与附加构造器

record 可以有静态字段、静态方法、附加构造器,也可以覆写自动生成的方法:

1
2
3
4
5
6
7
8
9
10
11
12
public record Money(long cents, String currency) {

static final Money ZERO_CNY = new Money(0, "CNY");

Money(long cents) {
this(cents, "CNY");
}

static Money ofYuan(long yuan) {
return new Money(yuan * 100, "CNY");
}
}
1
2
System.out.println(Money.ofYuan(3));   // Money[cents=300, currency=CNY]
System.out.println(new Money(5)); // Money[cents=5, currency=CNY]
  • 附加构造器必须首句用 this(...) 委托给规范构造器,不能自己直接给字段赋值
  • 静态工厂适合做校验、缓存、命名(Money.ofYuan(3)new Money(300, "CNY") 更能表达意图)

record 可以实现接口,不能继承类

1
2
3
4
5
6
7
8
9
10
11
12
interface Describable {

String describe();
}

record Circle(double r) implements Describable {

@Override
public String describe() {
return "circle r=" + r;
}
}
  • record 隐式继承 java.lang.Record,因此不能再 extends 任何类,也不能被继承(隐式 final
  • 实现接口不受限制,可以带实例方法、静态方法、嵌套类型
  • JDK 16 起(JEP 395)局部 record 与嵌套 record 都可用,嵌套 record 隐式 static
1
2
3
4
5
static String tag() {
record Pair(int a, int b) {
}
return new Pair(1, 2).toString(); // Pair[a=1, b=2]
}

record 与 Lombok @Data 的取舍

同一个值对象用 Lombok 写是这样的(需要 Lombok 依赖):

1
2
3
4
5
6
7
@Data
@AllArgsConstructor
public class PointLombok {

private int x;
private int y;
}
  • record 是语言级能力:编译期确定、零依赖、不需要 IDE 插件;Lombok 需要依赖注解处理器,换 IDE 或升级编译器时多一份维护成本
  • 能力边界不同:@Data 给你可变对象、setter、builder、继承;record 只给你不可变数据载体
  • 生成的 API 不同:@DatagetX()/setX(),record 是 x() 且没有 setter
  • equals 语义有细微差别:@Data 会生成 canEqual 做子类友好的比较,数组字段走 Arrays.deepEquals 一类的深比较;record 的 equals 是逐组件 Objects.equals,数组组件按引用比较(见后面「常见坑」)
  • 实践上的分工:DTO、消息、查询结果、值对象优先 record;JPA 实体、需要可变/继承的老模型继续用 Lombok 或手写。两者共存完全正常

record 不适用的场景

  1. 需要可变状态:组件是 final,改一个字段就得造新对象。实体、会话状态、需要频繁增量修改的大对象不适合
  2. 需要继承或需要被继承:record 隐式 final 且隐式 extends Record,没法替换既有继承体系里的父类,也没法被代理类继承
  3. JPA 实体:JPA 规范要求实体有 public/protected 的无参构造器、字段非 final,并能通过生成子类做延迟加载与脏检查,record 三条都不满足。Hibernate 6.2 起支持把 record 当作 @Embeddable(用规范构造器实例化),HQL/JPQL 的构造器表达式也可以直接把查询结果投影进 record,但 @Entity 本身不行
  4. 依赖无参构造器 + setter 做反射赋值的框架:某些老式 JSON/Bean 拷贝配置需要额外适配才能处理 record

sealed:把继承层次封起来

sealed / permits / non-sealed

1
2
3
4
5
6
7
8
9
10
11
sealed interface Shape permits Circle, Square, FreeForm {
}

record Circle(double r) implements Shape {
}

record Square(double side) implements Shape {
}

non-sealed class FreeForm implements Shape {
}
  • sealed 声明”只有名单上的类型可以直接继承我”;名单由 permits 给出
  • 每个 permitted 子类必须显式标注 finalsealednon-sealed 之一(record 隐式 final,所以 record 子类可以省略)
  • non-sealed 是”重新开放”:这一支可以被任意扩展,封闭性到这一层为止。适合”大部分情况封闭,但有一类需要留给外部扩展”的层次
  • 如果 permitted 子类都写在同一个编译单元里,permits 可以省略,编译器自行推断。最常见的写法就是把子类型作为嵌套 record 放进 sealed 接口:
1
2
3
4
5
6
7
8
public sealed interface Expr {

record Lit(int v) implements Expr {
}

record Add(Expr l, Expr r) implements Expr {
}
}

子类必须在同一个模块(或同一个包)

  • 有命名模块时:permits 名单里的类必须与该 sealed 类型在同一个模块
  • 类路径(未命名模块)时:必须在同一个包
  • 这条限制决定了 sealed 的语义是「模块内封闭」:它没法阻止别的 jar 定义自己的实现,只能阻止别的 jar 继承你的类型

sealed 与穷尽性检查

sealed 是编译器判断穷尽性的信息源:只有知道全部直接子类型,switch 才可能在不写 default 的情况下被判为覆盖了所有取值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
sealed interface Shape permits Circle, Square, Rect {
}

record Circle(double r) implements Shape {
}

record Square(double side) implements Shape {
}

record Rect(int w, int h) implements Shape {
}

static double area(Shape s) {
return switch (s) {
case Circle(var r) -> Math.PI * r * r;
case Square(var side) -> side * side;
case Rect(int w, int h) -> w * h;
};
}
  • 少写一个分支是编译错误,而不是运行时的”未处理类型”
  • 非 sealed 的接口也能用模式匹配,只是必须补一个 default(等于放弃穷尽性检查);枚举同样提供穷尽性
  • 穷尽性是编译期的近似:编译器仍然会为”分离编译下可能出现的未知子类型”合成一个抛异常的分支(见后面「常见坑」)

为 sealed 而 sealed

几个真实存在的误用:

  • 给本来就要被下游扩展的 SPI / 插件接口加 sealed,等于把第三方挡在门外;这种情况要么别 sealed,要么把那一支标成 non-sealed
  • 为了”能穷尽”而给 sealed 层次补 default -> throw new IllegalStateException():这等于把编译期检查退回运行期,还不如老老实实把分支写全
  • 把一个只有一种实现的接口标成 sealed:封闭一个永远只有单实现的层次,徒增约束
  • 反过来也有一种正当用法:在模块内部给”状态机 / 树节点 / 协议消息”这类类型封上 sealed,然后让所有分发逻辑都通过穷尽 switch 写,新增一种消息时编译器会把所有需要改的地方逐个报出来

模式匹配:从 instanceof 到 switch

instanceof 模式与流式作用域(JEP 394,JDK 16)

1
2
3
4
5
6
static String describe(Object o) {
if (o instanceof String s && s.length() > 3) {
return "long string: " + s;
}
return "other";
}
  • 把”类型检查 + 强转”合成一步,模式变量 s 直接就是 String
  • 作用域是”流式”的:只在编译器能确定匹配成功的代码路径里可见。所以 && 的右侧可以用 s|| 的右侧不行——写成 o instanceof String s || s.isEmpty() 是编译错误
  • 提前返回的写法仍然有效:if (!(o instanceof String s)) { return; } 之后 s 在作用域内,因为反向分支已经返回,剩下路径必然匹配成功

record 解构模式(JEP 440,JDK 21)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
enum Color { RED, GREEN, BLUE }

record Point(int x, int y) {
}

record ColoredPoint(Point p, Color c) {
}

record Rectangle(ColoredPoint upperLeft, ColoredPoint lowerRight) {
}

static void printUpperLeft(Rectangle r) {
if (r instanceof Rectangle(ColoredPoint(Point(int x, int y), Color c), ColoredPoint ul)) {
System.out.println(x + "," + y + " " + c);
}
}
  • 记录模式 Point(int x, int y) 匹配时自动调用访问器 x() / y(),把结果绑定到模式变量
  • 变量名不必与组件名一致,Point(int a, int b) 同样合法;也可以用 var 让编译器推断:Point(var x, var y)
  • 嵌套模式可以任意深,一次匹配失败整条模式都不匹配,不用自己层层判空
  • null 不匹配任何记录模式
  • 泛型 record 的类型参数会被推断:Box(Box(var s)) 等价于写全所有类型参数(JEP 440 有专门示例),但类型模式本身不做这种推断,List l 永远是裸类型模式
  • 访问器抛异常时,匹配以 MatchException 结束(JEP 441 的运行时语义):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
record R(int i) {
@Override
public int i() {
return i / 0;
}
}

static void demoMatchException() {
try {
switch (new R(42)) {
case R(var i) -> System.out.println(i);
}
} catch (MatchException e) {
System.out.println("MatchException: " + e.getMessage()); // 包住算术异常
}
}
  • JEP 440 在转正时移除了预览期间的”在增强 for 头里写记录模式”的能力(JEP 432 曾支持),现在记录模式只出现在 instanceofswitch

switch 模式匹配(JEP 441,JDK 21)

1
2
3
4
5
6
7
8
9
10
static String classify(Object o) {
return switch (o) {
case null -> "null";
case Integer i when i < 0 -> "negative";
case Integer i -> "non-negative";
case String s when s.isEmpty() -> "empty string";
case String s -> "string of length " + s.length();
default -> "other";
};
}
1
2
3
4
5
System.out.println(classify(null));    // null
System.out.println(classify(-3)); // negative
System.out.println(classify("")); // empty string
System.out.println(classify("abc")); // string of length 3
System.out.println(classify(3.0)); // other

要点逐条列清楚:

  • when 守卫:写在模式之后、-> 之前,用于对已提取的值做进一步判断。守卫里可以用到同一个标签里声明的模式变量(作用域包含守卫)
  • null 的处理switch 历史上对 null 一律抛 NullPointerException;JDK 21 起可以写 case null 显式接住,不写则行为不变,仍然抛 NPE。default 不匹配 null,要把两者合并必须写 case null, default(一个 switch 里不能同时存在 case null, defaultdefault
  • 穷尽性:使用了模式标签或 null 标签的 switch 语句表达式都必须穷尽(选择器类型不属于 char/byte/short/int/String/enum 这几个传统类型时也要求穷尽)。sealed 层次写全分支即可,且不该再写 default——有了 default 就失去了”新增子类型时编译器提醒你”的收益
  • 支配(dominance)规则:被前面的标签覆盖的标签是编译错误。推荐的排列顺序是常量标签 → 带守卫的模式 → 不带守卫的模式:
1
2
3
4
5
6
7
8
9
10
11
12
13
static String ordering(Integer i) {
switch (i) {
case -1, 1 -> {
return "special";
}
case Integer j when j > 0 -> {
return "positive";
}
case Integer j -> {
return "rest";
}
}
}
  • 枚举与常量:JDK 21 起 case 里可以写限定名的枚举常量(case Suit.HEARTS ->);枚举 switch 表达式的穷尽性检查不变
  • 守卫与穷尽性的关系:带守卫的标签不算覆盖——case Integer i when i > 0 -> 之后仍然需要 case Integer i ->default,因为守卫在编译期无法证明为真

未命名变量与未命名模式(JEP 456,JDK 22)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import java.util.List;

static int count(List<String> names) {
int count = 0;
for (String _ : names) { // 循环变量不用
count++;
}
names.forEach(_ -> System.out.println("item")); // lambda 参数不用
return count;
}

static void ignoreBadNumber(String text) {
try {
Integer.parseInt(text);
} catch (NumberFormatException _) { // 异常对象不用
System.out.println("bad number");
}
}
  • _ 是”显式声明不使用”,读它会编译失败;_ 从 Java 9 起就是保留标识符(JEP 213),JEP 456 才给了它新语义
  • 在模式里的用法是”省略组件”,只能出现在记录模式的组件列表中:
1
2
3
4
5
6
7
8
9
record Box(Object value) {
}

static String describeBox(Box box) {
return switch (box) {
case Box(Shape s) -> "形状:" + s;
case Box(_) -> "任意内容";
};
}
  • 顺序不能反:case Box(_) 能匹配任何 Box,写在前面会支配(dominate)后面的 case Box(Shape s),编译器报”此 case 标签由前一个 case 标签支配”
  • 限制_ 不能作为顶层模式。case _ -> 编译失败,顶层类型模式也不能写成 var _(顶层模式必须是具体的引用类型,不能是 var)。想”匹配任何东西”在顶层只能用 defaultcase Object o
  • JDK 22 起,一个 case 标签可以并列多个模式,前提是这些模式都不声明模式变量;守卫作用于整个标签:
1
2
3
4
5
6
static String roundish(Shape s) {
return switch (s) {
case Circle _, Square _ -> "roundish";
case Rect r -> r.w() + "x" + r.h();
};
}
  • 并列多个模式时如果写了模式变量(case Circle c, Square q ->)是编译错误——要么都不命名,要么拆成两个标签

组合:用 sealed + record 定义代数数据类型

完整示例一:Result 类型

sealed 接口给出”两种可能”,两个 record 给出”每种可能的数据”,switch 做穷尽分发:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
import java.util.function.Function;

public class ResultDemo {

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

record Ok<T>(T value) implements Result<T> {
}

record Err<T>(String code, String message) implements Result<T> {
}

static <T> Result<T> ok(T value) {
return new Ok<>(value);
}

static <T> Result<T> err(String code, String message) {
return new Err<>(code, message);
}

static <T, R> Result<R> map(Result<T> result, Function<? super T, ? extends R> mapper) {
return switch (result) {
case Ok(var value) -> new Ok<>(mapper.apply(value));
case Err(var code, var message) -> new Err<>(code, message);
};
}

static <T> Result<T> flatMap(Result<T> result, Function<? super T, ? extends Result<T>> mapper) {
return switch (result) {
case Ok(var value) -> mapper.apply(value);
case Err(var code, var message) -> new Err<>(code, message);
};
}

static <T> T orElseThrow(Result<T> result) {
return switch (result) {
case Ok(var value) -> value;
case Err(var code, var message) -> throw new IllegalStateException(code + ": " + message);
};
}

static String describe(Result<?> result) {
return switch (result) {
case Ok(var value) -> "ok: " + value;
case Err(var code, var message) -> "err: " + code + " (" + message + ")";
};
}

public static void main(String[] args) {
Result<Integer> parsed = ok(21);
Result<Integer> doubled = map(parsed, v -> v * 2);
Result<Integer> plusOne = map(doubled, v -> v + 1);
System.out.println(describe(plusOne));
System.out.println(orElseThrow(plusOne));

Result<Integer> failed = flatMap(err("E_PARSE", "not a number"), v -> ok(v * 2));
System.out.println(describe(failed));
}
}

输出:

1
2
3
ok: 43
43
err: E_PARSE (not a number)

case Ok(var value) 里的类型参数由编译器推断,不用写 Ok<Integer>(var value)describe 的入参是 Result<?>,记录模式仍然能匹配(捕获转换);orElseThrowErr 分支用 throw 表达式直接抛异常,这是 switch 表达式里唯一合法的”非赋值”分支形式。

完整示例二:表达式求值器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
public class ExprEval {

sealed interface Expr permits Lit, Neg, Add, Mul, Div {
}

record Lit(double value) implements Expr {
}

record Neg(Expr operand) implements Expr {
}

record Add(Expr left, Expr right) implements Expr {
}

record Mul(Expr left, Expr right) implements Expr {
}

record Div(Expr left, Expr right) implements Expr {
}

static double eval(Expr e) {
return switch (e) {
case Lit(double v) -> v;
case Neg(var operand) -> -eval(operand);
case Add(var l, var r) -> eval(l) + eval(r);
case Mul(var l, var r) -> eval(l) * eval(r);
case Div(var l, var r) -> eval(l) / eval(r);
};
}

static Expr simplify(Expr e) {
return switch (e) {
case Lit(var v) -> e;
case Neg(Lit(var a)) -> new Lit(-a);
case Neg(Neg(var inner)) -> inner;
case Neg(var operand) -> new Neg(simplify(operand));
case Add(Lit(var a), Lit(var b)) -> new Lit(a + b);
case Add(var l, var r) -> new Add(simplify(l), simplify(r));
case Mul(var l, Lit(var b)) when b == 0.0 -> new Lit(0.0);
case Mul(Lit(var a), Lit(var b)) -> new Lit(a * b);
case Mul(var l, var r) -> new Mul(simplify(l), simplify(r));
case Div(Lit(var a), Lit(var b)) when b != 0.0 -> new Lit(a / b);
case Div(var l, var r) -> new Div(simplify(l), simplify(r));
};
}

static String render(Expr e) {
return switch (e) {
case Lit(double v) -> String.valueOf(v);
case Neg(var operand) -> "(-" + render(operand) + ")";
case Add(var l, var r) -> "(" + render(l) + " + " + render(r) + ")";
case Mul(var l, var r) -> "(" + render(l) + " * " + render(r) + ")";
case Div(var l, var r) -> "(" + render(l) + " / " + render(r) + ")";
};
}

public static void main(String[] args) {
Expr e = new Add(new Mul(new Lit(2), new Lit(3)),
new Neg(new Neg(new Lit(4))));
System.out.println(render(e) + " = " + eval(e));

Expr folded = simplify(e);
System.out.println(render(folded) + " = " + eval(folded));

Expr zero = new Mul(new Add(new Lit(1), new Lit(2)), new Lit(0));
System.out.println(render(simplify(zero)) + " = " + eval(simplify(zero)));
}
}

输出:

1
2
3
((2.0 * 3.0) + (-(-4.0))) = 10.0
(6.0 + 4.0) = 10.0
0.0 = 0.0

simplify 里的嵌套模式直接描述了「匹配到什么形状该怎么重写」:case Neg(Lit(var a))case Neg(Neg(var inner)) 不需要先 instanceofgetOperand() 再递归判断;case Mul(var l, Lit(var b)) when b == 0.0 则是守卫 + 模式变量的配合。如果你在 JDK 22+,这一行可以写成 case Mul(_, Lit(var b)) when b == 0.0,把不参与判断的 l 省掉(_ 是 JEP 456 的能力,JDK 21 上编译不过)。

evalsimplifyrender 三个方法都没有 default 分支:新增一种表达式节点时,三个方法都会在编译期报错,一个都不会漏。

与 Visitor 模式 / 传统多态方案的对比

同一个”对表达式做操作”的需求,三种写法各有权衡:

  • 多态(方法放回各类型里)Shape.area()。加新类型只要实现接口,加新操作要改动所有类型类
  • Visitor 模式:把操作集中到访问者里,加新操作容易,但每加一个类型就要改访问者接口和全部实现
  • sealed + switch:逻辑集中在一处,加新操作就是加一个方法/分支;加新类型时所有 switch 都会编译报错

用模式匹配做 Visitor 的分发环节,是两者结合得最自然的写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
interface ShapeVisitor<R> {
R visitCircle(Circle c);
R visitSquare(Square s);
R visitRect(Rect r);
}

static <R> R accept(Shape s, ShapeVisitor<R> v) {
return switch (s) {
case Circle c -> v.visitCircle(c);
case Square q -> v.visitSquare(q);
case Rect r -> v.visitRect(r);
};
}

选择建议:

  • 类型集合稳定、操作会不断新增(编译器 AST、协议消息、状态机事件):用 sealed + 穷尽 switch。新增操作是局部的,且编译器保证不漏分支
  • 类型集合经常新增、操作基本固定(插件体系、领域模型的多种实现):用多态或 Visitor。新增类型不应该惊动既有代码
  • 两者不是互斥的:sealed 层次里可以给少数稳定操作提供多态方法(如 Shape.area()),把不稳定的操作留给 switch
  • 用 sealed + switch 时不要写 default:那等于主动放弃了”新增类型时编译器提醒你”的好处,把编译期错误变成运行期异常

与其他语言的粗略对照

  • Kotlinsealed class / sealed interface + data classwhen 对 sealed 类型穷尽(Kotlin 1.5 起子类可以在同一模块、同一包的多个文件里)。结构上与 Java 最接近:类型封闭与数据载体也是两个正交特性
  • Scalasealed trait + case class + match,穷尽性由编译器给出警告。case class 自带 copy,record 要手工写一遍拷贝方法
  • Rustenum 的每个变体自带数据,一个 enum 同时承担了”有哪几种可能”和”每种可能带什么数据”;match 强制穷尽。Java 需要 sealed + record 两个特性拼出同样的建模能力
  • 共同点:这些语言都提供了”封闭类型层次 + 解构 + 穷尽匹配”这套组合,也都把”漏掉一种情况”变成编译期问题。Java 的差别在于它是在既有类型系统上增量加出来的,所以 recordsealed 是两个独立关键字,写法上比 Rust 冗长

常见坑

浅不可变:record 不是深不可变

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import java.util.ArrayList;
import java.util.List;

record Order(String id, List<String> lines) {

Order {
lines = List.copyOf(lines);
}
}

class Demo {

public static void main(String[] args) {
List<String> mutable = new ArrayList<>(List.of("a"));
Order order = new Order("o1", mutable);
mutable.add("b");
System.out.println(order.lines()); // [a] —— 防御性拷贝生效
}
}

record 的字段是 final,但 final 只保证引用不变,不保证对象内部状态不变。ListMap、数组、Date 这类可变组件必须在构造时拷贝(List.copyOfMap.copyOfclone()),或者干脆换成不可变类型;否则调用方一改,”不可变对象”的 equals / hashCode 结论就跟着变,放进 HashSet / HashMap 之后会直接失效。

equals 与数组字段

1
2
3
4
5
6
7
8
9
import java.util.Arrays;

record Tag(String[] values) {
}

Tag a = new Tag(new String[] { "a" });
Tag b = new Tag(new String[] { "a" });
System.out.println(a.equals(b)); // false
System.out.println(Arrays.equals(a.values(), b.values())); // true

实测输出就是 false / true,且两个对象的 hashCode 不同。原因是 record 的 equals / hashCode 逐组件调用 Objects.equals / Objects.hashCode,而数组没有覆写 equals,比的是引用。对策有三条:换成 List;在 record 里显式覆写 equals / hashCode(record 允许覆写,只是不再自动生成);或者构造时 values.clone() 并接受”内容相同也不算相等”的语义。

switch 里 null 的默认行为

  • JDK 21 之前:选择器是 null 一律 NullPointerException,没得商量
  • JDK 21 起(JEP 441):可以写 case null 接住;不写则行为和以前完全一样(仍然 NPE),default 不匹配 null
  • 想要”null 与其余情况一起兜底”,必须显式写 case null, default
  • 反过来,如果选择器是不可能为 null 的类型(例如 record 组件被规范构造器校验过),那就什么都不用写——NPE 也是一种有效的失败信号

穷尽性带来的二进制兼容风险

sealed 层次允许库作者新增一个 permitted 子类(这是向后兼容的:老代码不会因此加载失败),但下游未重新编译的穷尽 switch 会在遇到新子类型时抛 MatchException——因为编译器为穷尽 switch 合成了一个”抛异常”的兜底分支。实测(JDK 25):库端新增一个 permitted 子类型、只重新编译库、不重编译调用方的 switch,运行到新值时报 java.lang.MatchException

配套的一条变化是枚举:JDK 21 起(JEP 441),枚举 switch 表达式在运行期没有任何标签匹配时改抛 MatchException,取代了以前的 IncompatibleClassChangeError

实践建议:

  • 跨版本发布的库不要把”穷尽 switch”当成永久的完备性保证;升级依赖后重新编译下游代码
  • 这正是 sealed 想要的”强制重新检查”效果,但只在编译期成立。想在没有重新编译的情况下也安全,就得处理 MatchException,或者对可能变化的类型留 default
  • 反过来,sealed 层次内部(同一个模块、一起发布)用穷尽 switch 是最划算的:漏分支编译不过,重构时编译器全程护航

总结

record、sealed、模式匹配是三个独立转正的特性,它们的价值在于组合:sealed interface 描述”有哪几种可能”,record 描述”每种可能带哪些数据”,switch 模式匹配(含 when 守卫、case nullcase null, default)把消费端的穷尽性交给编译器,记录模式把多层嵌套的解构压缩到一行。

使用上的几条结论:数据载体优先 record,但可变模型、需要继承、JPA 实体仍然得用普通类;受控继承优先 sealed,但只在”确实要穷尽分发”或”确实要封住扩展”时用;分发逻辑用不带 default 的穷尽 switch,让新增类型变成编译错误而不是运行期异常。版本上,记录模式与 switch 模式匹配从 JDK 21 起可用,_ 从 JDK 22 起可用,原始类型模式截至 JDK 26 仍是预览。

参考资料

系列索引:Java 系列,语言特性与运行时的长文集