记录类

用记录类表达透明的数据载体,理解编译器派生的成员、紧凑构造器、浅层不可变性,以及记录模式的边界。

难度 进阶 时长 标准深度约 14分钟
版本 Java 25 LTS
what

记录类(record class) 用一个头部声明固定的数据组件,并由编译器派生构造器、访问器和值语义方法。

trap

组件字段是 final,但组件引用的对象仍可能可变;记录类只提供浅层不可变性。

fix

在规范构造器中校验并复制可变输入,让组件、访问器、相等性和记录模式共同遵守同一个状态契约。

是什么,为什么存在

记录类是一种受约束的类,用来表示一组透明的值。声明 record Point(int x, int y) {} 时,头部同时给出状态、构造参数和公开访问接口。读者不必在字段、构造器、getter 与 equals() 之间核对重复代码,类型的主要数据形状一眼可见。

这里的“透明”比“少写代码”更重要。记录类承诺其状态由头部列出的组件完整描述,编译器据此派生规范构造器、同名访问器、equals()hashCode()toString()。它适合坐标、解析结果、请求边界对象、复合键和领域消息等以数据为中心的类型。

记录类不是自动生成器,也不是普通 JavaBean 的缩写。组件访问器名为 name(),不是 getName();类型隐式为 final,不能继承其他类;实例状态也不能绕过组件另加字段。需要可扩展继承层次、可变身份或隐藏状态的对象通常仍应使用普通类。

记录类在 Java 16 成为正式特性。记录模式在 Java 21 成为正式特性,可在 instanceof 和模式 switch 中解构记录值。面向 Java 25 LTS 编写这两类语法时不需要启用预览特性。

合适的建模边界

选择记录类之前,先问组件是否就是对象的完整逻辑状态。如果从所有组件重新调用规范构造器,理应得到与原对象相等的值,这个类型就符合记录类的设计方向。组件名称与顺序都是公开 API,而不只是存储细节。

记录类可以实现接口、声明泛型参数,并拥有实例方法、静态成员和嵌套类型。它因此可以携带与数据紧密相关的行为,例如单位换算或格式化,但不应把远程调用、数据库更新或跨聚合工作流藏进看似纯粹的数据值。

不应仅因字段很多就选择记录类。实体可能需要稳定身份而不是按全部字段相等,框架可能要求受控的生命周期,安全模型也可能不允许 toString() 暴露所有组件。先确定契约,再决定语法。

工作原理

记录头部中的每个组件都会对应一个同名、同类型的 private final 实例字段,以及一个无参数的 public 访问器。编译器还提供规范构造器和三个最终对象方法。对象方法直接读取组件字段,而不是调用可能被重写的访问器。

下面的结构图展示记录头部派生出的主要 API。记录体可以补充行为,但不能再声明非 static 实例字段。

编译器派生的成员具有这些可观察属性:

  • 每个组件对应一个 private final 非静态字段。
  • 每个组件对应一个同名、同类型、无参数的 public 访问器。
  • 规范构造器按头部顺序接收全部组件。
  • equals() 按同一记录类型的全部组件比较。
  • hashCode() 由全部组件的哈希值派生。
  • toString() 包含记录类名、组件名和组件的字符串表示。

所有记录类都直接继承抽象类 java.lang.Record,但声明中不能写 extends Record。记录类隐式为 final,所以不能作为基类。它可以实现接口;配合密封接口时,每个记录实现天然满足直接子类型必须封闭的要求。

规范构造器

规范构造器(canonical constructor) 的参数类型与组件按顺序一一对应。完全省略它时,编译器生成构造器并把每个参数赋给对应字段。显式声明普通形式时,参数名和类型必须与组件匹配,并由代码完成字段赋值。

紧凑构造器(compact constructor) 省略参数列表。构造器体内的组件名表示隐式参数,可以先校验或重新绑定;构造器体正常结束后,编译器再按头部顺序把最终参数值赋给字段。这个形式适合把对象不变量集中在唯一入口。

额外构造器可以提供便利输入,但第一条语句必须通过 this(...) 委托给另一个构造器,最终到达规范构造器。这样不会出现某条构造路径绕过组件初始化或校验。记录类不能用无参数构造器制造“稍后再填”的半成品,除非它明确为全部组件提供值并完成委托。

规范构造器建立的是组件引用本身的约束,而不是引用对象的深层不可变性。对列表使用 List.copyOf()、对数组使用 clone()Arrays.copyOf(),都是在构造边界取得所有权的方式。具体复制策略取决于元素是否也可变。

相等性与数据形状

默认 equals() 只会接受同一个记录类的实例,然后按组件比较。引用组件采用其 equals(),基本类型采用对应包装类型的比较语义;hashCode() 使用同一组组件。相等性与哈希因此随头部一起定义,不能把某个组件当作“仅供展示”而又期待它不参与相等。

数组是一个重要例外。数组继承 Object.equals(),默认比较身份,所以两个内容相同但实例不同的数组组件会让两个记录值不相等。若逻辑状态是一段序列,通常用不可修改的 List 更贴近值语义;确实需要数组时,则要复制数组并明确设计 equals()hashCode()

组件顺序也属于契约。它决定规范构造器参数顺序、反射返回的记录组件顺序、toString() 的展示顺序,以及记录模式的解构位置。重排头部不是无害的内部重构。

记录模式

记录模式(record pattern) 先检查值是否属于指定记录类型,再调用组件访问器,把结果交给嵌套模式或模式变量。模式变量名不必与组件名一致,var 也可以让编译器推断组件类型。嵌套记录模式能够按对象的数据形状一次解构多层记录。

记录模式与密封层次结合时,编译器可以检查模式 switch 是否穷尽。不要用宽泛的 default 隐藏新子类型;由一个模块控制全部分支时,列出允许的记录实现,能让后续扩展在重新编译消费者时暴露遗漏。

null 不匹配任何记录模式。模式 switch 若没有 case null,选择器为 null 时会抛出 NullPointerExceptiondefault 也不会替代显式的空值策略。API 边界应先决定拒绝、转换还是专门处理空值。

成员与限制

记录体适合声明派生计算、静态工厂、常量和接口实现。显式访问器必须保持与组件相同的返回类型,且必须是无参数、非泛型、无 throwspublic 实例方法。能重写不代表应随意改变访问器语义。

这些限制维护“头部就是状态”的承诺:

  • 不能声明额外的非 static 实例字段。
  • 不能声明 abstractnative 实例方法。
  • 不能显式继承其他类,也不能被继承。
  • 组件不能使用若干会与 Object 无参数方法冲突的名称,例如 hashCodewait
  • 局部记录类和成员记录类隐式为 static,不捕获外围实例。

示例

由头部派生值语义

第一个示例只声明两个组件。两个独立实例因为组件相同而相等,所以第二个实例可以查到第一个实例写入 HashMap 的值。

Java
import java.util.HashMap;
import java.util.Map;

public class BasicRecords {
    record InventoryItem(String sku, int quantity) {}

    public static void main(String[] args) {
        var first = new InventoryItem("A-17", 12);
        var same = new InventoryItem("A-17", 12);
        Map<InventoryItem, String> locations = new HashMap<>();
        locations.put(first, "aisle-3");

        System.out.println(first);
        System.out.println("quantity=" + first.quantity());
        System.out.println("same=" + first.equals(same));
        System.out.println("lookup=" + locations.get(same));
    }
}
InventoryItem[sku=A-17, quantity=12]
quantity=12
same=true
lookup=aisle-3

输出同时展示了派生的 toString()、组件访问器和 equals()HashMap 查找还依赖与相等性一致的派生 hashCode(),不需要额外样板代码。

把记录值用作键之前,仍要确认所有组件在进入映射后保持哈希稳定。这里只包含 Stringint,它们不会在外部修改后改变键的逻辑状态。

在构造边界建立不变量

下一个记录用紧凑构造器规范化标识,并复制调用方提供的列表。构造完成后修改原列表,不会改变记录持有的组件;访问器返回的列表也拒绝结构修改。

Java
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Objects;

public class ValidatedRecords {
    record OrderBatch(String id, List<String> items) {
        OrderBatch {
            id = Objects.requireNonNull(id).strip().toUpperCase(Locale.ROOT);
            items = List.copyOf(items);
            if (items.isEmpty()) {
                throw new IllegalArgumentException("items must not be empty");
            }
        }
    }

    public static void main(String[] args) {
        var source = new ArrayList<>(List.of("tea", "coffee"));
        var batch = new OrderBatch(" b-17 ", source);
        source.add("cocoa");

        System.out.println(batch);
        System.out.println(batch.items());
        try {
            batch.items().add("juice");
        } catch (UnsupportedOperationException error) {
            System.out.println(error.getClass().getSimpleName());
        }
    }
}
OrderBatch[id=B-17, items=[tea, coffee]]
[tea, coffee]
UnsupportedOperationException

紧凑构造器中的 iditems 是隐式参数。对它们重新赋值后,编译器把新引用写入组件字段,因此派生方法看到的是规范化后的状态。

List.copyOf() 只复制列表结构,不会深复制元素。示例使用不可变字符串;若元素本身可变,还要决定复制元素、转换为不可变值,还是把可变性明确留在类型契约中。

解构密封的记录层次

第三个示例让两个记录实现同一个密封接口。模式 switch 解构组件,守卫优先识别快速配送,最后一个 Shipment 分支覆盖其余运输值。

Java
import java.util.Objects;

public class PatternRecords {
    sealed interface Delivery permits Pickup, Shipment {}

    record Pickup(String orderId) implements Delivery {}

    record Address(String city, String country) {}

    record Shipment(String orderId, Address address, int days)
            implements Delivery {
        Shipment {
            Objects.requireNonNull(address, "address");
        }
    }

    static String label(Delivery delivery) {
        return switch (delivery) {
            case Pickup(String orderId) -> orderId + ": collect";
            case Shipment(String orderId, Address(String city, String country), int days)
                    when days <= 2 -> orderId + ": express to " + city;
            case Shipment(String orderId, Address(String city, String country), int days) ->
                    orderId + ": " + days + " days to " + city;
        };
    }

    public static void main(String[] args) {
        System.out.println(label(new Pickup("A-10")));
        System.out.println(label(
                new Shipment("B-20", new Address("Paris", "FR"), 2)));
        System.out.println(label(
                new Shipment("C-30", new Address("Oslo", "NO"), 5)));
    }
}
A-10: collect
B-20: express to Paris
C-30: 5 days to Oslo

分支顺序有语义:带守卫的具体情况必须出现在无守卫的 Shipment 模式之前。更宽的模式放在前面会支配后续分支,编译器会拒绝不可达的模式标签。

这里没有 default,因为密封接口的两个允许实现已经全部覆盖。以后新增允许的直接子类型时,重新编译这段消费者代码会要求处理新分支。

Shipment 构造器还会拒绝空的 Address。如果没有这个不变量,两个嵌套 Address 模式都会因组件为 null 而失败,穷尽 switch 随后会在运行时抛出 MatchException

检查组件注解的传播

最后一个示例把一个运行时注解声明为可用于记录组件、字段、方法和参数。编译器只会把组件注解传播到其 @Target 允许的位置,反射因此能在四处分别看到它。

Java
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

public class AnnotatedRecords {
    @Target({ElementType.RECORD_COMPONENT, ElementType.FIELD,
            ElementType.METHOD, ElementType.PARAMETER})
    @Retention(RetentionPolicy.RUNTIME)
    @interface Boundary {}

    record Account(@Boundary String id) {}

    public static void main(String[] args) throws ReflectiveOperationException {
        var component = Account.class.getRecordComponents()[0];
        var field = Account.class.getDeclaredField("id");
        var accessor = Account.class.getDeclaredMethod("id");
        var parameter = Account.class.getDeclaredConstructor(String.class)
                .getParameters()[0];

        System.out.println("component=" + component.isAnnotationPresent(Boundary.class));
        System.out.println("field=" + field.isAnnotationPresent(Boundary.class));
        System.out.println("accessor=" + accessor.isAnnotationPresent(Boundary.class));
        System.out.println("parameter=" + parameter.isAnnotationPresent(Boundary.class));
    }
}
component=true
field=true
accessor=true
parameter=true

如果注解只把 RECORD_COMPONENT 列入目标,就只能通过 RecordComponent 读取,不会自动出现在字段或访问器上。注解还必须采用 RUNTIME 保留策略,运行时反射才能观察到它。

显式声明访问器时,组件上的注解不会自动传播到该访问器。依赖方法注解的校验器或序列化器需要集成测试,不能从组件位置猜测框架最终读取的声明。

陷阱

final 只禁止字段改指向另一个引用,不禁止引用对象内部变化。变化会影响访问器、toString()、相等性和哈希结果,作为映射键时甚至可能再也无法按同一个对象查到条目。

修复方法: 在规范构造器中执行空值检查并取得所有权。使用适合组件的防御性复制(defensive copy),必要时连元素一起复制;对数组还要考虑在显式访问器中返回副本。

数组的默认相等性按引用身份判断。两个记录即使分别持有内容相同的数组,也可能不相等;数组内容变化还会让自定义哈希策略难以维持。

修复方法: 逻辑上表示序列时优先存储不可修改的 List。必须使用数组时,在构造和访问边界复制,并成对实现基于 Arrays.equals()Arrays.hashCode() 的对象方法,同时为此编写契约测试。

派生的 equals()hashCode()toString() 直接读取字段,而记录模式会调用访问器。访问器返回不同表示时,同一个对象在比较、打印、复制和模式匹配中可能呈现互相冲突的状态。

修复方法: 让组件访问器返回组件状态本身,把派生表示放进另一个有明确名称的方法。测试复制不变量:用全部访问器结果重新构造的记录应与原值相等。

这些成员要么不允许编译,要么破坏记录类表达固定数据形状的目的。某些框架版本只查找 getName() 或依赖可变填充流程,也不会因为类型改成记录类就自动兼容。

修复方法: 在真实框架版本上验证构造、命名和注解发现规则。若集成契约本质上要求可变 Bean,就保留普通类,并在边界把它转换为内部记录值。

记录类从 Java 16 起正式可用,记录模式与模式 switch 从 Java 21 起正式可用。仅在较新的 JDK 上运行构建,不会阻止生成代码偷偷提高项目的最低源码版本。

修复方法: 用项目承诺的 javac --release 值编译,并且不要为稳定语法启用预览。对拥有全部子类型的密封层次列出每个分支,让重新编译承担完整性检查。

深入 相等性与复制不变量

相等性与复制不变量

记录类的核心语义不是“自动生成若干方法”,而是组件列表定义值的状态描述。对记录类 R 的值 r1,依次读取组件并调用规范构造器得到 r2,通常应满足 r1.equals(r2)。这个复制不变量约束显式访问器与规范化逻辑的设计。

规范化应在构造器内完成。若构造器把标识转成大写,那么字段、访问器和对象方法都会看到大写值,使用访问器复制也会再次得到相等对象。若访问器才临时转大写,字段仍保存原值,复制结果就可能与原对象不相等。

默认 equals() 要求运行时记录类型相同,不会让两个组件恰好相同的不同记录类相等。这避免了不同领域类型因为结构偶然而混在一起。需要跨类型比较时,应比较明确的共享值对象或业务键,而不是削弱记录类的类型边界。

浮点组件遵循对应包装类的比较语义,而不是简单使用 ==。这让派生相等性保持自反,并与派生哈希一致。涉及数值领域规则时,仍应先决定舍入、单位和规范化,再把规范值放入组件。

反射与注解目标

Class.isRecord() 判断类是否为记录类,Class.getRecordComponents() 按头部顺序返回 RecordComponent 数组。每个元素可以提供组件名、泛型类型、对应访问器和直接适用于组件位置的运行时注解。普通类会让 getRecordComponents() 返回 null,调用方不应把它与零组件记录混淆。

注解传播由目标位置分别决定。允许 FIELD 才会传播到隐式字段,允许 METHOD 才会传播到隐式访问器,允许 PARAMETER 才会传播到隐式规范构造器参数;允许 RECORD_COMPONENT 则让注解保留在组件声明自身。一个注解不必同时允许全部位置。

显式访问器与显式普通形式的规范构造器改变了传播细节。尤其是组件注解不会传播到显式访问器,因此处理器和框架应说明它读取组件、字段、方法还是参数。迁移普通类时,反射集成测试比“注解看起来还在”更可靠。

序列化与 API 演进

Java 原生序列化会以不同于普通可序列化对象的方式处理记录值,并在反序列化时调用规范构造器。这意味着构造器不变量仍是边界的一部分,但不代表记录类适合长期持久化。安全与兼容策略仍需围绕输入验证、允许类型和格式演进来设计。

组件名称、类型和顺序构成公开形状。新增组件会改变规范构造器签名,删除或重排组件也会破坏调用者、反射代码与记录模式。发布库时,应把记录头部当成 API 签名审查,而不是可以自由调整的私有字段列表。

默认 toString() 适合诊断,不是稳定交换格式。它会包含每个组件的字符串表示,既可能随头部变化,也可能泄露令牌、邮箱或内部标识。敏感值不应仅靠“日志不会调用它”的约定保护,应重新设计边界类型或提供经过筛选的日志表示。

外部 JSON、数据库与消息框架各自定义记录支持和命名规则。语言规范不会保证第三方框架接受某个构造器、注解位置或缺失字段。升级或迁移时,应使用真实版本做往返测试,并把线上格式与 Java 源码形状分开管理。

记录模式的运行边界

记录模式不是读取字段的特殊语法,它会调用记录组件访问器。嵌套模式按外层到内层继续匹配,某个嵌套类型模式失败时,整个记录模式不匹配。访问器若抛出运行时异常,匹配也不会把它静默变成“不匹配”。

这个机制再次要求访问器保持简单、稳定并忠实于组件。把 I/O、随机值或时间相关计算放进访问器,会让模式匹配产生隐藏副作用或不稳定结果。派生行为应该使用普通方法,模式只负责检查并解构状态。

泛型记录模式可以从选择器的静态类型推断类型参数,但不会让不可具体化的泛型检查突然变得可用。编译器仍应用强制转换兼容性与模式支配规则。看到生成的原始类型模式时,应检查是否丢失了本可保留的静态类型信息。

穷尽检查是源码编译时保证,不是任意部署组合的万能保护。密封层次与消费者分开编译、再只替换提供方二进制时,旧消费者没有经过新分支的重新检查。库与服务跨版本部署时仍需兼容性测试和明确的升级顺序。

延伸阅读

检查点

4个问题 · 1 道输出预测题 · 1 道找错题

复制为 Markdown 面试题库 在 GitHub 上编辑 报告错误 讲清楚了吗?