Skip to content

JEP 395: Records | 记录

摘要

通过引入 记录(records) 来增强 Java 编程语言,记录是充当不可变数据透明载体的类。记录可以被视为 名义元组

历史

记录由 JEP 359 提出,并在 JDK 14 中作为 预览功能 提供。

根据反馈,通过 JEP 384 对设计进行了改进,并在 JDK 15 中作为预览功能第二次提供。第二次预览的改进如下:

  • 在第一次预览中,规范构造函数必须是 public 的。在第二次预览中,如果规范构造函数是隐式声明的,则其访问修饰符与记录类相同;如果规范构造函数是显式声明的,则其访问修饰符必须至少提供与记录类相同的访问权限。

  • @Override 注解的含义被扩展,以包括被注解方法是记录组件的显式声明访问器方法的情况。

  • 为了强制执行紧凑构造函数的预期用途,在构造函数体中为任何实例字段赋值将成为编译时错误。

  • 引入了声明局部记录类、局部枚举类和局部接口的能力。

本 JEP 提议在 JDK 16 中完成此功能的最终确定,并进行以下改进:

  • 放宽长期以来对内部类不能声明显式或隐式静态成员的限制。这将变得合法,特别是将允许内部类声明一个记录类作为成员。

根据进一步的反馈,可能会纳入其他改进。

目标

  • 设计一种面向对象的构造,用于表达值的简单聚合。

  • 帮助开发人员专注于建模不可变数据,而不是可扩展的行为。

  • 自动实现数据驱动的方法,如 equals 方法和访问器。

  • 保持 Java 的长期原则,如名义类型和迁移兼容性。

非目标

  • 尽管记录在声明数据载体类时提供了更简洁的语法,但目标并非“打击样板代码”。特别是,它并非旨在解决使用 JavaBeans 命名约定的可变类的问题。

  • 并非旨在添加诸如属性或基于注解的代码生成等功能,这些功能通常被提议用于简化“普通旧式 Java 对象”的类声明。

动机

“Java 太冗长”或“Java 仪式太多”是常见的抱怨。其中一些最严重的“罪魁祸首”就是那些不过是几个值的不可变 数据载体 的类。正确编写这样的数据载体类涉及大量低价值、重复且容易出错的代码:构造函数、访问器、equalshashCodetoString 等。例如,一个用于携带 x 和 y 坐标的类最终不可避免地会像这样:

java
class Point {
    private final int x;
    private final int y;

    Point(int x, int y) {
        this.x = x;
        this.y = y;
    }

    int x() { return x; }
    int y() { return y; }

    public boolean equals(Object o) {
        if (!(o instanceof Point)) return false;
        Point other = (Point) o;
        return other.x == x && other.y == y;
    }

    public int hashCode() {
        return Objects.hash(x, y);
    }

    public String toString() {
        return String.format("Point[x=%d, y=%d]", x, y);
    }
}

有时,开发人员会为了省事而省略如 equals 这样的方法,这会导致令人惊讶的行为或较差的调试能力,或者因为他们不想再声明另一个类,而将一个具有“正确形状”但并非完全合适的类强行用于此目的。

集成开发环境(IDEs)帮助我们 编写 数据载体类中的大部分代码,但并未对 阅读者 从数十行样板代码中提炼出“我是 xy 的数据载体”的设计意图提供任何帮助。编写用于建模少量值的 Java 代码应当更加易于编写、阅读和验证其正确性。

尽管从表面上看,将记录视为主要是关于减少样板代码的做法很诱人,但我们选择了更具语义性的目标:将数据建模为数据。(如果语义正确,样板代码自然会得到妥善处理。)应当能够轻松且简洁地声明数据载体类,这些类 默认 使其数据不可变,并提供产生和消耗数据的方法的惯用实现。

描述

记录类 是 Java 语言中的一种新型类。记录类有助于以比普通类更少的仪式来建模简单的数据聚合。

记录类的声明主要由其 状态 的声明组成;然后,记录类会承诺一个与该状态相匹配的 API。这意味着记录类放弃了类通常享有的一个自由——将类的 API 与其内部表示解耦的能力——但作为回报,记录类的声明变得更加简洁。

更具体地说,记录类声明由名称、可选的类型参数、头部和正文组成。头部列出了记录类的 组件,即构成其状态的变量。(此组件列表有时被称为 状态描述。)例如:

java
record Point(int x, int y) { }

由于记录类声明自己是其数据的透明载体,因此记录类会自动获得许多标准成员:

  • 对于头部中的每个组件,都有两个成员:一个与组件同名且返回类型相同的 public 访问器方法,以及一个与组件类型相同的 private final 字段;

  • 一个 规范构造函数,其签名与头部相同,并且该构造函数将每个私有字段分配给实例化记录的 new 表达式中对应的参数;

  • equalshashCode 方法,确保如果两个记录值是同一类型且包含相等的组件值,则它们相等;

  • 一个 toString 方法,返回所有记录组件的字符串表示形式及其名称。

换句话说,记录类的头部描述了其状态,即其组件的类型和名称,而 API 则完全机械地从该状态描述中派生出来。API 包括构造协议、成员访问协议、等同性协议和显示协议。(我们期望未来的版本支持解构模式,以实现强大的模式匹配。)

记录类的构造函数

记录类中构造函数的规则与普通类不同。没有声明任何构造函数的普通类会自动获得一个 默认构造函数。相比之下,没有声明任何构造函数的记录类会自动获得一个 规范构造函数,该函数将所有私有字段分配给实例化记录的 new 表达式的相应参数。例如,之前声明的记录——record Point(int x, int y) { }——在编译时就像是这样:

java
record Point(int x, int y) {
    // 隐式声明的字段
    private final int x;
    private final int y;

    // 其他隐式声明被省略...

    // 隐式声明的规范构造函数
    Point(int x, int y) {
        this.x = x;
        this.y = y;
    }
}

规范构造函数可以像上面所示那样,通过匹配记录头部的形式参数列表显式声明。它也可以通过省略形式参数列表来更紧凑地声明。在这种 紧凑的规范构造函数 中,参数是隐式声明的,并且与记录组件相对应的私有字段不能在构造函数体中赋值,而是会自动在构造函数末尾将参数赋值给对应的形式参数(this.x = x;)。这种紧凑形式有助于开发人员专注于验证和规范化参数,而无需执行将参数分配给字段的繁琐工作。

例如,以下是一个验证其隐式形式参数的紧凑规范构造函数:

java
record Range(int lo, int hi) {
    Range {
        if (lo > hi)  // 这里引用的是隐式构造函数参数
            throw new IllegalArgumentException(String.format("(%d,%d)", lo, hi));
    }
}

以下是一个规范化其形式参数的紧凑规范构造函数:

java
record Rational(int num, int denom) {
    Rational {
        int gcd = gcd(num, denom);
        num /= gcd;
        denom /= gcd;
    }
}

此声明等同于传统的构造函数形式:

java
record Rational(int num, int denom) {
    Rational(int num, int demon) {
        // 规范化
        int gcd = gcd(num, denom);
        num /= gcd;
        denom /= gcd;
        // 初始化
        this.num = num;
        this.denom = denom;
    }
}

具有隐式声明的构造函数和方法的记录类满足重要且直观的语义属性。例如,考虑按以下方式声明的记录类 R

java
record R(T1 c1, ..., Tn cn){ }

如果以以下方式复制 R 的一个实例 r1

java
R r2 = new R(r1.c1(), r1.c2(), ..., r1.cn());

然后,假设 r1 不是空引用,那么表达式 r1.equals(r2)总是 会评估为 true。显式声明的访问器和 equals 方法应该遵守这一不变性。然而,编译器通常无法检查显式声明的方法是否遵守这一不变性。

作为一个例子,以下记录类的声明应被视为不良风格,因为它的访问器方法“静默地”调整了记录实例的状态,并且上述不变性没有得到满足:

java
record SmallPoint(int x, int y) {
  public int x() { return this.x < 100 ? this.x : 100; }
  public int y() { return this.y < 100 ? this.y : 100; }
}

此外,对于所有记录类,隐式声明的 equals 方法都是按照自反性实现的,并且对于包含浮点组件的记录类,其行为与 hashCode 保持一致。同样,显式声明的 equalshashCode 方法也应该有类似的行为。

记录类的规则

与普通类相比,记录类的声明存在许多限制:

  • 记录类声明不包含 extends 子句。记录类的超类始终是 java.lang.Record,这与枚举类的超类始终是 java.lang.Enum 类似。尽管普通类可以显式地扩展其隐式超类 Object,但记录类不能显式地扩展任何类,即使是其隐式超类 Record 也不行。

  • 记录类是隐式 final 的,并且不能是 abstract 的。这些限制强调了记录类的 API 仅由其状态描述定义,并且不能通过另一个类进行后续增强。

  • 从记录组件派生的字段是 final 的。此限制体现了 默认不可变 策略,该策略广泛适用于数据承载类。

  • 记录类不能显式声明实例字段,也不能包含实例初始化器。这些限制确保了仅记录头定义了记录值的状态。

  • 任何显式声明的成员(否则将自动派生)必须完全匹配自动派生成员的类型,而不考虑显式声明上的任何注释。任何对访问器或 equalshashCode 方法的显式实现都应小心谨慎,以保持记录类的语义不变性。

  • 记录类不能声明 native 方法。如果记录类可以声明 native 方法,那么根据定义,记录类的行为将取决于外部状态,而不是记录类的显式状态。具有本地方法的类不太可能成为迁移到记录的合适候选者。

除了上述限制之外,记录类的行为与普通类相似:

  • 使用 new 表达式创建记录类的实例。

  • 记录类可以声明为顶级类或嵌套类,并且可以是泛型的。

  • 记录类可以声明 static 方法、字段和初始化器。

  • 记录类可以声明实例方法。

  • 记录类可以实现接口。记录类不能指定超类,因为这将意味着除了标头中描述的状态之外,还继承了状态。但是,记录类可以自由指定超接口并声明实例方法来实现它们。与类一样,接口可以有效地描述许多记录的行为。行为可能是与域无关的(例如,Comparable)或与域特定的,在这种情况下,记录可以是捕获域的 密封 层次结构的一部分(见下文)。

  • 记录类可以声明嵌套类型,包括嵌套记录类。如果记录类本身是嵌套的,那么它是隐式静态的;这避免了会默默地向记录类添加状态的立即封闭实例。

  • 记录类及其标头中的组件可以用注解进行装饰。根据注解的适用目标集,记录组件上的任何注解都会传播到自动派生的字段、方法和构造函数参数上。记录组件类型上的类型注解也会传播到自动派生成员中相应的类型使用上。

  • 记录类的实例可以进行序列化和反序列化。但是,不能通过提供 writeObjectreadObjectreadObjectNoDatawriteExternalreadExternal 方法来自定义该过程。记录类的组件控制序列化,而记录类的规范构造函数控制反序列化。

局部记录类

一个生成和消费记录类实例的程序可能会处理许多中间值,这些中间值本身只是变量的简单组合。为了对这些中间值进行建模,通常方便的做法是声明记录类。一种选择是声明静态且嵌套的“辅助”记录类,正如许多程序当前声明辅助类的方式一样。而更便捷的选择是在方法内部声明记录类,即靠近操纵这些变量的代码。因此,我们定义了 局部记录类,这与现有的 局部类 构造类似。

在以下示例中,使用局部记录类 MerchantSales 对商家和月度销售额的聚合进行建模。使用这个记录类可以提高后续流操作的可读性:

java
List<Merchant> findTopMerchants(List<Merchant> merchants, int month) {
    // 局部记录类
    record MerchantSales(Merchant merchant, double sales) {}

    return merchants.stream()
        .map(merchant -> new MerchantSales(merchant, computeSales(merchant, month)))
        .sorted((m1, m2) -> Double.compare(m2.sales(), m1.sales()))
        .map(MerchantSales::merchant)
        .collect(toList());
}

局部记录类是嵌套记录类的一个特例。与嵌套记录类一样,局部记录类是 隐式静态的。这意味着它们自己的方法不能访问封闭方法的任何变量;反过来,这避免了捕获一个会默默地向记录类添加状态的立即封闭实例。局部记录类隐式静态的事实与局部类不同,局部类不是隐式静态的。实际上,局部类永远不会是静态的——无论是隐式还是显式,并且始终可以访问封闭方法中的变量。

局部枚举类和局部接口

引入局部记录类是一个机会,可以借此添加其他类型的隐式静态局部声明。

嵌套枚举类和嵌套接口已经是隐式静态的,因此为了保持一致性,我们定义了局部枚举类和局部接口,它们也是隐式静态的。

内部类的静态成员

当前规范 指出,如果内部类声明了一个显式或隐式静态的成员(除非该成员是常量变量),则会导致编译时错误。这意味着,例如,内部类不能声明记录类成员,因为嵌套记录类是隐式静态的。

我们放宽了这一限制,以允许内部类声明显式或隐式静态的成员。特别是,这允许内部类声明一个记录类作为静态成员。

记录组件上的注解

在记录声明中,记录组件扮演着多重角色。记录组件是一个一等公民的概念,但每个组件也对应于同名的字段和类型、同名的访问器方法和返回类型,以及同名的规范构造函数的正式参数。

这引发了一个问题:当组件被注解时,实际上是在注解什么?答案是“所有适用于该特定注解的元素”。这使得那些在其字段、构造函数参数或访问器方法上使用注解的类可以迁移到记录中,而无需冗余地声明这些成员。例如,以下类

java
public final class Card {
    private final @MyAnno Rank rank;
    private final @MyAnno Suit suit;
    @MyAnno Rank rank() { return this.rank; }
    @MyAnno Suit suit() { return this.suit; }
    ...
}

可以迁移到等效的、可读性更强的记录声明中:

java
public record Card(@MyAnno Rank rank, @MyAnno Suit suit) { ... }

注解的适用性是通过 @Target 元注解来声明的。考虑以下示例:

java
@Target(ElementType.FIELD)
public @interface I1 {...}

这声明了注解 @I1 适用于字段声明。我们可以声明一个注解适用于多个声明;例如:

java
@Target({ElementType.FIELD, ElementType.METHOD})
public @interface I2 {...}

这声明了注解 @I2 既适用于字段声明也适用于方法声明。

回到记录组件上的注解,这些注解出现在它们适用的相应程序点上。换句话说,传播是在使用 @Target 元注解的开发者的控制之下的。传播规则是系统且直观的,所有适用的规则都会被遵循:

  • 如果记录组件上的注解适用于字段声明,则该注解将出现在相应的私有字段上。

  • 如果记录组件上的注解适用于方法声明,则该注解将出现在相应的访问器方法上。

  • 如果记录组件上的注解适用于形式参数,则在没有显式声明的情况下,该注解将出现在规范构造函数的相应形式参数上;如果已显式声明,则出现在紧凑构造函数的相应形式参数上。

  • 如果记录组件上的注解适用于类型,则该注解将传播到以下所有地方:

    • 相应字段的类型
    • 相应访问器方法的返回类型
    • 规范构造函数的相应形式参数的类型
    • 记录组件的类型(在运行时可通过反射访问)

如果显式声明了公共访问器方法或(非紧凑)规范构造函数,则它们仅具有直接出现在其上的注解;不会从相应的记录组件向这些成员传播任何注解。

除非注解被元注解 @Target(RECORD_COMPONENT) 标记,否则记录组件上的声明注解不会作为与记录组件在运行时通过 反射 API 相关联的注解之一。

兼容性与迁移

抽象类 java.lang.Record 是所有记录类的公共超类。无论您是否启用或禁用预览功能,每个 Java 源文件都会隐式导入 java.lang.Record 类以及 java.lang 包中的所有其他类型。然而,如果您的应用程序从另一个包中导入了名为 Record 的另一个类,您可能会遇到编译器错误。

考虑以下 com.myapp.Record 的类声明:

java
package com.myapp;

public class Record {
    public String greeting;
    public Record(String greeting) {
        this.greeting = greeting;
    }
}

以下示例 org.example.MyappPackageExample 使用通配符导入了 com.myapp.Record,但无法编译:

java
package org.example;
import com.myapp.*;

public class MyappPackageExample {
    public static void main(String[] args) {
       Record r = new Record("Hello world!");
    }
}

编译器将生成类似于以下内容的错误消息:

java
./org/example/MyappPackageExample.java:6: error: reference to Record is ambiguous
       Record r = new Record("Hello world!");
       ^
  both class com.myapp.Record in com.myapp and class java.lang.Record in java.lang match

./org/example/MyappPackageExample.java:6: error: reference to Record is ambiguous
       Record r = new Record("Hello world!");
                      ^
  both class com.myapp.Record in com.myapp and class java.lang.Record in java.lang match

由于 com.myapp 包中的 Recordjava.lang 包中的 Record 都使用通配符导入,因此没有哪个类具有优先权。当编译器遇到使用简单名称 Record 时,它会生成错误消息。

为了让这个示例能够编译,可以将 import 语句更改为导入 Record 的完全限定名:

java
import com.myapp.Record;

java.lang 包中引入类的情况是罕见的,但有时是必要的。之前的例子包括 Java 5 中的 Enum,Java 9 中的 Module,以及 Java 14 中的 Record

Java 语法

java
RecordDeclaration:
  {ClassModifier} `record` TypeIdentifier [TypeParameters]
    RecordHeader [SuperInterfaces] RecordBody

RecordHeader:
 `(` [RecordComponentList] `)`

RecordComponentList:
 RecordComponent { `,` RecordComponent}

RecordComponent:
 {Annotation} UnannType Identifier
 VariableArityRecordComponent

VariableArityRecordComponent:
 {Annotation} UnannType {Annotation} `...` Identifier

RecordBody:
  `{` {RecordBodyDeclaration} `}`

RecordBodyDeclaration:
  ClassBodyDeclaration
  CompactConstructorDeclaration

CompactConstructorDeclaration:
  {ConstructorModifier} SimpleTypeName ConstructorBody

类文件表示

记录的 class 文件使用 Record 属性来存储关于记录组件的信息:

java
Record_attribute {
    u2 attribute_name_index;
    u4 attribute_length;
    u2 components_count;
    record_component_info components[components_count];
}

record_component_info {
    u2 name_index;
    u2 descriptor_index;
    u2 attributes_count;
    attribute_info attributes[attributes_count];
}

如果记录组件有一个与擦除描述符不同的泛型签名,则 record_component_info 结构中必须有一个 Signature 属性。

反射 API

我们在 java.lang.Class 中添加了两个公共方法:

  • RecordComponent[] getRecordComponents() — 返回 java.lang.reflect.RecordComponent 对象的数组。此数组的元素对应于记录的组件,顺序与它们在记录声明中的出现顺序相同。可以从数组的每个元素中提取附加信息,包括其名称、注解和访问器方法。

  • boolean isRecord() — 如果给定的类被声明为记录,则返回 true。(与 isEnum 进行比较。)

备选方案

记录类可以视为 元组 的一种命名形式。除了记录类之外,我们还可以实现结构化的元组。然而,虽然元组可能提供了一种轻量级的方式来表达某些聚合体,但结果往往是质量较差的聚合体:

  • Java 设计哲学的一个核心方面是 名称很重要。类及其成员具有有意义的名称,而元组和元组组件则没有。即,具有 firstNamelastName 组件的 Person 记录类比两个字符串组成的匿名元组更清晰且更安全。

  • 类允许通过其构造函数进行状态验证;元组通常不提供此功能。一些数据聚合体(如数值范围)具有在构造函数中强制执行的不变量,此后可以依赖这些不变量。元组不提供此能力。

  • 类可以具有基于其状态的行为;将状态和行为并置使得行为更容易被发现和访问。而元组作为原始数据,不提供此类功能。

依赖项

记录类与目前处于预览状态的另一个特性配合良好,即 密封类JEP 360)。例如,可以显式声明一系列记录类来实现相同的密封接口:

java
package com.example.expression;

public sealed interface Expr
    permits ConstantExpr, PlusExpr, TimesExpr, NegExpr {...}

public record ConstantExpr(int i)       implements Expr {...}
public record PlusExpr(Expr a, Expr b)  implements Expr {...}
public record TimesExpr(Expr a, Expr b) implements Expr {...}
public record NegExpr(Expr e)           implements Expr {...}

记录类和密封类的组合有时被称为 代数数据类型。记录类允许我们表达 乘积,而密封类允许我们表达

除了记录类和密封类的组合外,记录类还自然地适用于 模式匹配。因为记录类将其 API 与其状态描述相关联,所以我们最终将能够推导出记录类的解构模式,并使用密封类型信息来确定具有类型模式或解构模式的 switch 表达式的穷尽性。