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 预览)
原始类型(int、long、boolean、double 等)进入模式匹配:截至 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); System.out.println(p.equals(new Point (1 , 2 ))); System.out.println(p.x() + p.y()); }
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 )); System.out.println(new Money (5 ));
附加构造器必须首句用 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(); }
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 不同:@Data 是 getX()/setX(),record 是 x() 且没有 setter
equals 语义有细微差别:@Data 会生成 canEqual 做子类友好的比较,数组字段走 Arrays.deepEquals 一类的深比较;record 的 equals 是逐组件 Objects.equals,数组组件按引用比较(见后面「常见坑」)
实践上的分工:DTO、消息、查询结果、值对象优先 record;JPA 实体、需要可变/继承的老模型继续用 Lombok 或手写。两者共存完全正常
record 不适用的场景
需要可变状态 :组件是 final,改一个字段就得造新对象。实体、会话状态、需要频繁增量修改的大对象不适合
需要继承或需要被继承 :record 隐式 final 且隐式 extends Record,没法替换既有继承体系里的父类,也没法被代理类继承
JPA 实体 :JPA 规范要求实体有 public/protected 的无参构造器、字段非 final,并能通过生成子类做延迟加载与脏检查,record 三条都不满足。Hibernate 6.2 起支持把 record 当作 @Embeddable(用规范构造器实例化),HQL/JPQL 的构造器表达式也可以直接把查询结果投影进 record,但 @Entity 本身不行
依赖无参构造器 + 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 子类必须显式标注 final、sealed 或 non-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 曾支持),现在记录模式只出现在 instanceof 与 switch 里
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 )); System.out.println(classify(-3 )); System.out.println(classify("" )); System.out.println(classify("abc" )); System.out.println(classify(3.0 ));
要点逐条列清楚:
when 守卫 :写在模式之后、-> 之前,用于对已提取的值做进一步判断。守卫里可以用到同一个标签里声明的模式变量(作用域包含守卫)
null 的处理 :switch 历史上对 null 一律抛 NullPointerException;JDK 21 起可以写 case null 显式接住,不写则行为不变 ,仍然抛 NPE。default 不匹配 null,要把两者合并必须写 case null, default(一个 switch 里不能同时存在 case null, default 和 default)
穷尽性 :使用了模式标签或 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" )); 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)。想”匹配任何东西”在顶层只能用 default 或 case 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<?>,记录模式仍然能匹配(捕获转换);orElseThrow 的 Err 分支用 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)) 不需要先 instanceof 再 getOperand() 再递归判断;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 上编译不过)。
eval、simplify、render 三个方法都没有 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:那等于主动放弃了”新增类型时编译器提醒你”的好处,把编译期错误变成运行期异常
与其他语言的粗略对照
Kotlin :sealed class / sealed interface + data class,when 对 sealed 类型穷尽(Kotlin 1.5 起子类可以在同一模块、同一包的多个文件里)。结构上与 Java 最接近:类型封闭与数据载体也是两个正交特性
Scala :sealed trait + case class + match,穷尽性由编译器给出警告。case class 自带 copy,record 要手工写一遍拷贝方法
Rust :enum 的每个变体自带数据,一个 enum 同时承担了”有哪几种可能”和”每种可能带什么数据”;match 强制穷尽。Java 需要 sealed + record 两个特性拼出同样的建模能力
共同点:这些语言都提供了”封闭类型层次 + 解构 + 穷尽匹配”这套组合,也都把”漏掉一种情况”变成编译期问题。Java 的差别在于它是在既有类型系统上增量加出来的,所以 record 与 sealed 是两个独立关键字,写法上比 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()); } }
record 的字段是 final,但 final 只保证引用不变,不保证对象内部状态不变。List、Map、数组、Date 这类可变组件必须在构造时拷贝(List.copyOf、Map.copyOf、clone()),或者干脆换成不可变类型;否则调用方一改,”不可变对象”的 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)); System.out.println(Arrays.equals(a.values(), b.values()));
实测输出就是 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 null、case null, default)把消费端的穷尽性交给编译器,记录模式把多层嵌套的解构压缩到一行。
使用上的几条结论:数据载体优先 record,但可变模型、需要继承、JPA 实体仍然得用普通类;受控继承优先 sealed,但只在”确实要穷尽分发”或”确实要封住扩展”时用;分发逻辑用不带 default 的穷尽 switch,让新增类型变成编译错误而不是运行期异常。版本上,记录模式与 switch 模式匹配从 JDK 21 起可用,_ 从 JDK 22 起可用,原始类型模式截至 JDK 26 仍是预览。
参考资料
系列索引:Java 系列 ,语言特性与运行时的长文集