Skip to content
Go back

Java Record与Sealed类——代数数据类型(ADT)的语言级支持

Record 与 Sealed:Java 的代数数据类型

一句话结论(30s)

Record + Sealed 是 Java 对代数数据类型(ADT)的语言级支持,因为 Record 一行声明即可由编译器自动生成构造器/accessor/equals/hashCode/toString 且字段不可变,Sealed 用 permits 在编译期限定子类集合。关键设计是把运行时的 NullPointerException 和缺失分支前移为编译期穷举检查;代价是 Record 不能继承、Sealed 新增子类型会让旧 switch 编译报错。

核心原理(2min)

Record 编译后自动生成 canonical 构造器与 name()/age() 风格 accessor(非 getName()),所有字段 private final 无 setter;Sealed 的 permits 名单写入 .classPermittedSubclasses 属性、JVM 类加载时验证,配合 switch 模式匹配实现无需 default 的编译期穷举——“和类型”用 sealed interface、“积类型”用 record。

底层深入(5-10min)

Record:不可变数据类的终极简写

// 之前
public class User {
    private final String name;
    private final int age;
    public User(String name, int age) { this.name = name; this.age = age; }
    public String name() { return name; }
    public int age() { return age; }
    public boolean equals(Object o) { ... }
    public int hashCode() { ... }
    public String toString() { ... }
}

// Record — 一行搞定
public record User(String name, int age) {}

编译器自动生成:canonical 构造器、accessor 方法(name()/age(),不是 getName())、equals()(基于所有字段)、hashCode()toString()

不可变:所有字段 private final,没有 setter。Record 不能继承(extends),但可以实现接口。

💭 思考:Record 明明帮你省了这么多样板代码,为什么还规定「不能继承、字段不可变」?因为 equals/hashCode 一旦基于字段生成,字段可变就会破坏它们在 HashMap/HashSet 里的稳定性——不可变是「自动生成全套方法」能成立的前提。

Sealed 类:限制子类集合

public sealed interface Result<T>
    permits Success, Failure, Loading {
}

record Success<T>(T data) implements Result<T> {}
record Failure<T>(String error) implements Result<T> {}
record Loading<T>() implements Result<T> {}

permits 名单在编译期通过 .class 文件中的 PermittedSubclasses 属性持久化。JVM 类加载时验证——不在名单中的子类拒绝加载(IncompatibleClassChangeError)。

💭 思考:Sealed 的 permits 名单为什么要写进 class 文件的 PermittedSubclasses 属性、还让 JVM 加载时验证?因为光靠编译期检查可以被「绕过」——把子类放进其他模块/JAR 也能加载。JVM 层再验一道,才能保证「子类集合封闭」这个承诺在运行时也成立。

结合模式匹配 switch

String message = switch (result) {
    case Success<T>(var data) -> "Got: " + data;
    case Failure<T>(var error) -> "Error: " + error;
    case Loading<T>() -> "Loading...";
    // 不需要 default — 编译期知道只有这3种子类型!
};

编译期穷举检查:Sealed 类告诉编译器”我只有这 3 个子类”。switch 覆盖全部子类型 → 不需要 default。新增子类型 → 旧的 switch 编译报错 → 不可能遗漏处理分支。

💭 思考:为什么 Sealed + switch 能去掉 default?因为编译器能从 permits 名单「数清」所有子类型,一旦你漏了某个分支就能在编译期报错;而普通类有无数潜在子类,编译器只能要求你补 default 兜底。

ADT(代数数据类型)的意义

Sealed 接口 + Record 子类 = ADT(Algebraic Data Type)——用类型系统表达”和类型”(sealed interface)和”积类型”(record)。这是函数式语言的核心构造,现在 Java 也有了。

错误处理、API 响应、表达式求值——这些天然只有固定几种情况的领域——适合用 ADT 建模。编译期的穷举检查把运行时的 NullPointerException 和缺失分支变成了编译期错误。

💭 思考:ADT 到底解决了什么实际问题?它把「这几种情况就是全部」这个领域事实写进类型系统,让编译器替你做穷举检查,把运行时的 NPE 和漏分支提前到编译期——错误越早暴露成本越低。

章末提问

追问 1:Record 和普通 final 类 + Lombok @Value 有什么区别?

结论先行:Record 是语言级/字节码级的不可变数据类,由编译器生成规范方法且 JVM 认可其语义;Lombok 是注解处理器在编译期「改写 AST」塞代码的第三方方案。

因为:Record 的 accessor 命名是 name() 而非 getName()equals/hashCode/toString 由 javac 直接生成并保证一致;Lombok 依赖插件、生成的代码在源码里不可见、一旦脱离 Lombok 就编译失败。Record 还天然支持模式匹配解构,这是注解方案做不到的。

追问 2:Sealed 类的 permits 名单是怎么被 JVM 强制执行的?

结论先行permits 名单被 javac 写进 class 文件的 PermittedSubclasses 属性,JVM 在类加载/验证阶段检查,不在名单内的子类抛 IncompatibleClassChangeError

因为:仅靠编译期检查能被恶意或跨模块字节码绕过,所以把约束下沉到 class 文件属性 + JVM 验证,保证运行时也封闭。这也意味着新增子类后旧的密封契约会变,已有的穷举 switch 可能因此编译报错。

追问 3:为什么说「Sealed interface + Record = 代数数据类型(ADT)」?

结论先行:ADT 由「和类型」与「积类型」构成,sealed interface 表达「和类型」(固定几种选择之一),record 表达「积类型」(多个字段的组合)。

因为sealed interface Result permits Success, Failure 告诉类型系统「Result 只能是 Success 或 Failure」,这就是「和」;record Success(T data) 把多个字段捆成一个不可变值,这就是「积」。二者叠加后,编译器就能对 switch 做穷举检查,把运行时错误前移为编译期错误。


Share this post on:

Previous Post
Java 21 结构化并发:让虚拟线程如虎添翼
Next Post
HashMap的哈希扰动与扩容机制