跳到主要内容

JEP 384:记录(第二次预览)

QWen Max 中英对照 JEP 384: Records (Second Preview)

总结

通过 records 增强 Java 编程语言,这些是充当不可变数据透明载体的类。Records 可以被认为是名义元组。这是 JDK 15 中的预览语言功能

历史

记录(Records)是在 2019 年年中由 JEP 359 提出的,并在 2020 年年初作为 预览功能JDK 14 提供。本 JEP 建议在 JDK 15 中重新预览此功能,一方面是为了根据反馈进行改进,另一方面是为了支持 Java 语言中更多形式的本地类和接口。

目标

  • 设计一个面向对象的结构,该结构表达值的简单聚合。
  • 帮助程序员专注于建模不可变数据,而非可扩展的行为。
  • 自动实现数据驱动的方法,例如 equals 和访问器。
  • 保留长期的 Java 原则,例如名义类型和迁移兼容性。

非目标

  • 它的目标并不是要发起一场“消除样板代码的战争”。特别是,它并不旨在解决那些使用 JavaBeans 命名约定的可变类所存在的问题。

  • 它的目标并不是添加诸如属性或注解驱动的代码生成等功能,这些功能常被提议用来简化“Plain Old Java Objects”类的声明。

动机

一个常见的抱怨是“Java 太过冗长”或者“仪式感太重”。一些最严重的违规者是那些仅仅是少量值的不可变数据载体的类。正确编写一个数据载体类需要涉及大量低价值、重复且容易出错的代码:构造函数、访问器、equalshashCodetoString 等等。例如,一个用于承载 x 和 y 坐标的类不可避免地会变成这样:

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 等方法,从而导致令人惊讶的行为或较差的可调试性,或者因为某个类具有“正确的形状”而使用它来充数,仅仅因为他们不想再声明另一个类。

IDE 可以帮助在数据载体类中编写大部分代码,但无法帮助读者从数十行样板代码中提炼出“我是 xyz 的数据载体”这样的设计意图。编写用于建模少量值的 Java 代码应当更易于编写、阅读以及验证其正确性。

虽然表面上看,将记录(records)主要视为减少样板代码的工具是诱人的,但我们选择了一个更具语义化的目标:将数据建模为数据。(如果语义正确,样板代码的问题自然会解决。)声明数据载体类应该简单且简洁,默认情况下使它们的数据不可变,并提供惯用的方法实现来生成和使用这些数据。

描述

Records 是 Java 语言中一种新型的类。记录的目的是声明一小组变量应被视为一种新的实体。记录声明其 状态 —— 即这组变量 —— 并承诺提供与该状态匹配的 API。这意味着记录放弃了类通常享有的一个自由 —— 将类的 API 与其内部表示解耦的能力 —— 但作为回报,记录变得更加简洁。

记录的声明指定了一个名称、一个头部和一个主体。头部列出了记录的 组成部分,这些组成部分是构成其状态的变量。(组成部分的列表有时被称为 状态描述。)例如:

record Point(int x, int y) { }

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

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

  • 一个规范构造函数(canonical constructor),其签名与头部相同,并将每个 private 字段赋值为对应于 new 表达式中实例化记录的参数;

  • equalshashCode 方法,表明如果两个记录属于同一类型且包含相等的组件值,则它们相等;以及

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

换句话说,记录的头部描述了它的状态(其组件的类型和名称),并且 API 是根据该状态描述机械而完整地生成的。API 包括构造协议、成员访问、相等性以及显示的协议。(我们预计未来的版本将支持解构模式以允许强大的模式匹配功能。)

记录规则

可以从头部自动生成的成员中,除了从记录组件派生的 private 字段外,其他都可以显式声明。任何对访问器或 equals/hashCode 的显式实现都应注意保持记录的语义不变性。

记录中的构造函数规则与普通类中的不同。没有任何构造函数声明的普通类会自动获得一个默认构造函数。相比之下,没有任何构造函数声明的记录则会自动获得一个规范构造函数,该构造函数将所有 private 字段赋值为实例化记录的 new 表达式中对应的参数。例如,前面声明的记录 -- record Point(int x, int y) { } -- 被编译时就好像它是:

record Point(int x, int y) { 
// Implicitly declared fields
private final int x;
private final int y;

// Other implicit declarations elided ...

// Implicitly declared canonical constructor
Point(int x, int y) {
this.x = x;
this.y = y;
}
}

规范构造函数可以显式声明,并附带与记录头匹配的形式参数列表,如上所示;也可以采用更紧凑的形式声明,帮助开发者专注于验证和规范化参数,而无需繁琐地将参数赋值给字段。紧凑规范构造函数省略了形式参数列表;这些参数被隐式声明,并且对应于记录组件的 private 字段不能在构造函数主体中赋值,而是在构造函数结束时自动赋值给相应的形式参数(this.x = x;)。例如,以下是一个验证其(隐式)形式参数的紧凑规范构造函数:

record Range(int lo, int hi) {
Range {
if (lo > hi) // referring here to the implicit constructor parameters
throw new IllegalArgumentException(String.format("(%d,%d)", lo, hi));
}
}

记录的声明有许多限制:

  • 记录(record)没有 extends 子句。记录的超类始终是 java.lang.Record,这与枚举(enum)的超类始终是 java.lang.Enum 类似。尽管普通类可以显式扩展其隐式的超类 Object,但记录不能显式扩展任何类,甚至不能扩展其隐式的超类 Record

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

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

  • 与记录类的组件相对应的隐式声明字段是 final 的,并且不能通过反射修改(尝试这样做会抛出 IllegalAccessException)。这些限制体现了一种 默认不可变 的策略,该策略广泛适用于数据载体类。

  • 任何显式声明的成员如果本应是自动派生的,则必须与自动派生成员的类型完全匹配,忽略显式声明上的类型注解。

  • 记录不能声明 native 方法。如果记录可以声明 native 方法,那么根据定义,记录的行为将依赖于外部状态,而不是记录的显式状态。任何包含 native 方法的类都不适合迁移到记录。

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

  • 使用 new 关键字实例化记录。

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

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

  • 记录可以声明实例方法。具体来说,记录可以显式声明与组件对应的 public 访问器方法,也可以声明其他实例方法。

  • 记录可以实现接口。虽然记录不能指定超类(因为这意味着继承状态,超出了头部描述的状态),但记录可以自由指定超级接口并声明实例方法以帮助实现它们。正如对于类一样,接口可以有效地表征许多记录的行为;该行为可以是与领域无关的(例如,Comparable),也可以是特定领域的,在这种情况下,记录可以是捕获该领域的一部分 sealed 层次结构(见下文)。

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

  • 记录及其状态描述中的组件可以被注解。这些注解会被传播到自动生成的字段、方法和构造函数参数。记录组件类型的类型注解也会传播到自动生成的成员的类型。

记录和密封类型

记录与密封类型JEP 360)配合得很好。例如,一组记录可以实现同一个密封接口:

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 {...}

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

本地记录

一个生成和使用记录的程序可能会处理许多中间值,而这些中间值本身只是变量的简单组合。通常情况下,声明记录来建模这些中间值会非常方便。一种选择是声明 static 且嵌套的“辅助”记录,就像许多程序目前声明辅助类一样。更方便的选择是在方法内部声明记录,靠近操作这些变量的代码。因此,本 JEP 提出了局部记录,类似于传统的局部类结构。

在以下示例中,使用本地记录 MerchantSales 对商家及其月销售额的聚合进行了建模。使用此记录可以提高后续流操作的可读性:

List<Merchant> findTopMerchants(List<Merchant> merchants, int month) {
// Local record
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());
}

局部记录是嵌套记录的一种特殊情况。像所有的嵌套记录一样,局部记录是隐式静态的。这意味着它们自己的方法不能访问外围方法的任何变量;反过来,这避免了捕获直接外围的实例,否则会悄无声息地为记录添加状态。局部记录隐式静态这一事实与局部类形成对比,局部类并非隐式静态。实际上,局部类从来都不是静态的——无论是隐式还是显式——并且总是可以访问外围方法中的变量。

鉴于局部记录(local records)的实用性,拥有局部枚举(local enums)和局部接口(local interfaces)也会很有用。由于对其语义的担忧,它们在传统上是被 Java 禁止的。具体来说,嵌套枚举(nested enums)和嵌套接口(nested interfaces)是隐式静态的,因此局部枚举和局部接口也应该是隐式静态的;然而,Java 语言中的局部声明(如局部变量、局部类)从来都不是静态的。不过,JEP 359 中引入的局部记录克服了这一语义问题,允许局部声明为静态,从而为局部枚举和局部接口打开了大门。

记录上的注解

记录组件在记录声明中具有多种角色。记录组件是一个一流的概念,但每个组件还对应一个同名和同类型的字段、一个同名和同返回类型的访问器方法,以及一个同名和同类型的构造函数参数。

这就引出了一个问题,当一个组件被注解时,到底是什么被注解了?答案是,“所有适用于此特定注解的内容”。这使得在其字段、构造函数参数或访问器方法上使用注解的类能够迁移到记录(records),而无需冗余声明这些成员。例如,如下所示的一个类

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; }
...
}

可以迁移到等效且可读性更高的记录声明:

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

注解的适用性是使用 @Target 元注解声明的。请看以下内容:

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

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

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

这声明了一个注解 @I2,它适用于字段声明和方法声明。

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

  • 如果记录组件上的注解适用于字段声明,那么该注解会出现在相应的 private 字段上。

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

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

  • 如果记录组件上的注解适用于类型,传播规则与声明注解的规则相同,区别在于该注解会出现在相应的类型使用上,而不是声明上。

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

还可以声明一个注解来自于使用新的注解声明 @Target(RECORD_COMPONENT) 定义在记录组件上的注解。这些注解可以通过反射获取,详细信息见下文的 Reflection API 部分。

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:
{Annotation} {ConstructorModifier} SimpleTypeName ConstructorBody

类文件表示

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

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()
  • boolean isRecord()

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

方法 isRecord 在给定的类被声明为记录(record)时返回 true。(与 isEnum 进行比较。)

替代方案

记录可以被视为一种名义上的 元组 形式。我们可以不使用记录,而是实现结构化的元组。然而,尽管元组可能提供了一种更轻量的方式来表达某些聚合体,但结果往往会导致较低质量的聚合体:

  • Java 设计哲学的一个核心方面是名称很重要。类及其成员拥有有意义的名称,而元组和元组组件则没有。也就是说,一个包含 firstNamelastName 属性的 Person 类比一个匿名的 StringString 元组更清晰、更安全。

  • 类通过其构造函数支持状态验证;元组则不支持。一些数据聚合(例如数值范围)具有不变量,如果这些不变量由构造函数强制执行,那么之后就可以依赖它们;元组则不具备这种能力。

  • 类可以基于其状态拥有行为;将状态和行为集中在一起使得行为更容易被发现和访问。元组作为原始数据,则没有提供这样的功能。

依赖

除了上述记录(records)与密封类型(sealed types)的组合之外,记录还天然适用于模式匹配。由于记录将其 API 与其状态描述相耦合,未来我们将能够为记录派生出解构模式(deconstruction patterns),并利用密封类型信息来确定在带有类型模式或解构模式的 switch 表达式中的穷尽性(exhaustiveness)。