JEP 384: Records (Second Preview) | 记录(第二次预览版)
摘要
通过 记录 来增强 Java 编程语言,记录是充当不可变数据透明载体的类。记录可以被视为 名义元组。这是 JDK 15 中的一项 预览语言功能。
历史
记录在 2019 年中由 JEP 359 提出,并在 2020 年初的 JDK 14 中作为 预览功能 交付。本 JEP 提议在 JDK 15 中重新预览该功能,以基于反馈进行改进,并支持 Java 语言中本地类和接口的更多形式。
目标
- 设计一个面向对象的构造,以表达值的简单聚合。
- 帮助程序员专注于建模不可变数据,而不是可扩展的行为。
- 自动实现由数据驱动的方法,如
equals
和访问器。 - 保持 Java 的长期原则,如名义类型和迁移兼容性。
非目标
声明“反对样板代码之战”并不是目标。特别是,解决使用 JavaBeans 命名约定的可变类的问题并不是目标。
添加诸如属性或基于注解的代码生成等功能并不是目标,这些功能通常被提出用于简化“普通旧 Java 对象”的类声明。
动机
经常有人抱怨“Java 太冗长了”或“有太多繁琐的仪式”。一些最严重的违规者是那些仅作为少数几个值的不可变 数据载体 的类。正确编写数据载体类需要很多低价值、重复、易出错的代码:构造函数、访问器、equals
、hashCode
、toString
等。例如,一个携带 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; // 注意:这里有一个错误,应该是 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)有助于 编写 数据载体类中的大部分代码,但并不能帮助 读者 从数十行样板代码中提炼出“我是 x
、y
和 z
的数据载体”的设计意图。编写用于建模少量值的 Java 代码应该更容易编写、阅读和验证其正确性。
虽然从表面上看,将记录视为主要是关于减少样板代码很有吸引力,但我们选择了更语义化的目标:将数据建模为数据。(如果语义正确,样板代码就会自己处理好。)声明数据载体类应该简单明了,默认情况下使其数据不可变,并提供用于生成和消费数据的方法的惯用实现。
说明
记录 是 Java 语言中的一种新型类。记录的目的是声明一小组变量应被视为一种新型实体。记录声明其 状态(即变量组),并承诺提供与该状态匹配的 API。这意味着记录放弃了类通常享有的自由——将类的 API 与其内部表示形式解耦的能力——但作为回报,记录变得更加简洁。
记录的声明指定了一个名称、一个头部和一个主体。头部列出了记录的 组件,即构成其状态的变量。(组件列表有时被称为 状态描述。)例如:
record Point(int x, int y) { }
由于记录声明自己是其数据的透明载体,因此记录会自动获得许多标准成员:
对于头部中的每个组件,都有两个成员:一个与组件同名的
public
访问器方法,其返回类型与组件相同,以及一个与组件类型相同的private
final
字段;一个 规范构造函数,其签名与头部相同,并且将每个
private
字段分配给通过new
表达式实例化记录时对应的参数;equals
和hashCode
方法,用于判断两个记录是否相等,如果它们属于同一类型且包含相等的组件值,则视为相等;一个
toString
方法,返回所有记录组件的字符串表示形式及其名称。
换句话说,记录的头部描述了其状态(组件的类型和名称),并且该状态描述的 API 是机械化和完全派生的。API 包括构造、成员访问、相等性和显示协议。(我们期望未来的版本支持解构模式,以允许强大的模式匹配。)
记录规则
除从记录组件派生的 private
字段外,任何从头部自动派生的成员都可以显式声明。任何显式实现的访问器或 equals
/hashCode
都应谨慎保留记录的语义不变量。
记录在构造函数方面的规则与普通类不同。没有构造函数声明的普通类会自动获得一个 默认构造函数。相反,没有构造函数声明的记录会自动获得一个 规范构造函数,该构造函数将所有 private
字段分配给实例化记录的 new
表达式的相应参数。例如,之前声明的记录——record Point(int x, int y) { }
——在编译时会被视为:
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;
}
}
规范构造函数可以显式声明一个与记录头部匹配的形式参数列表,如上面所示,或者它可以以更紧凑的形式声明,以帮助开发人员专注于验证和规范化参数,而无需将参数分配给字段的繁琐工作。一个 紧凑的规范构造函数 省略了形式参数列表;它们被隐式声明,并且与记录组件相对应的 private
字段不能在构造函数体中赋值,而是在构造函数末尾自动赋给相应的形式参数(this.x = x;
)。例如,下面是一个紧凑的规范构造函数,它验证其(隐式)形式参数:
record Range(int lo, int hi) {
Range {
if (lo > hi) // 这里引用隐式构造函数参数
throw new IllegalArgumentException(String.format("(%d,%d)", lo, hi));
}
}
记录的声明存在许多限制:
记录没有
extends
子句。记录的父类始终是java.lang.Record
,类似于枚举的父类始终是java.lang.Enum
。即使普通类可以显式地扩展其隐式父类Object
,记录也不能显式地扩展任何类,甚至不能扩展其隐式父类Record
。记录隐式地是
final
的,并且不能是abstract
的。这些限制强调了记录的 API 仅由其状态描述定义,并且不能由另一个类或记录在以后进行增强。记录不能显式地声明实例字段,也不能包含实例初始化器。这些限制确保记录头单独定义了记录值的状态。
与记录类的记录组件相对应的隐式声明字段是
final
的,并且不能通过反射进行修改(这样做将抛出IllegalAccessException
)。这些限制体现了广泛适用于数据载体类的“默认不可变”策略。任何显式声明的成员(否则将自动派生)的类型必须与自动派生的成员的类型完全匹配,而不考虑显式声明上的类型注释。
记录不能声明
native
方法。如果记录可以声明native
方法,那么记录的行为将取决于外部状态,而不是记录的显式状态。带有native
方法的类不适合迁移到记录。
除了上述限制外,记录的行为与普通类类似:
记录使用
new
关键字实例化。记录可以声明为顶层或嵌套类型,并可以是泛型。
记录可以声明静态方法、静态字段和静态初始化器。
记录可以声明实例方法。具体来说,记录可以显式声明与组件相对应的
public
访问器方法,也可以声明其他实例方法。记录可以实现接口。虽然记录不能指定超类(因为这意味着继承状态,超出头部描述的状态),但记录可以自由指定超接口并声明实例方法以帮助实现它们。与类一样,接口可以有用地描述许多记录的行为;行为可能是与领域无关的(例如,
Comparable
)或与领域相关的,在这种情况下,记录可以成为捕获领域的 封闭 层次结构的一部分(见下文)。记录可以声明嵌套类型,包括嵌套记录。如果记录本身是嵌套的,那么它是 隐式静态的;这避免了会默默地向记录添加状态的直接封闭实例。
记录和其状态描述中的组件都可以进行注解。这些注解会自动传播到自动派生的字段、方法和构造函数参数。记录组件类型上的类型注解也会传播到自动派生的成员的类型上。
记录与封闭类型
记录与 封闭类型(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 {...}
记录和封闭类型的结合有时被称为 代数数据类型。记录允许我们表达 积类型(product types),而封闭类型允许我们表达 和类型(sum types)。
局部记录
一个产生和消费记录的程序很可能会处理许多本身就是简单变量组的中间值。通常,声明记录来模拟这些中间值会非常方便。一种选择是声明“辅助”记录,这些记录是 static
且嵌套的,就像现在很多程序声明辅助类一样。一个更便捷的选择是在方法内部声明一个记录,靠近操作变量的代码。因此,这个 JEP 提案提出了 局部记录,类似于传统的 局部类 构造。
在以下示例中,商家和月销售额的聚合通过一个局部记录 MerchantSales
进行建模。使用这个记录可以提高后续流操作的可读性:
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 中传统上是不被允许的,因为担心它们的语义。具体来说,嵌套枚举和嵌套接口是隐式静态的,所以局部枚举和局部接口也应该是隐式静态的;然而,Java 语言中的局部声明(局部变量、局部类)永远不会是静态的。但是,JEP 359 中局部记录的引入克服了这种语义上的担忧,允许局部声明为静态,并为局部枚举和局部接口打开了大门。
记录上的注解
记录组件在记录声明中具有多种角色。记录组件是一个一等概念,但每个组件也对应于具有相同名称和类型的字段、具有相同名称和返回类型的访问器方法以及具有相同名称和类型的构造函数参数。
这引出了一个问题:当组件被注解时,实际上是在注解什么?答案是:“所有适用于此特定注解的项。”这使得使用字段、构造函数参数或访问器方法上的注解的类可以迁移到记录,而无需冗余地声明这些成员。例如,以下类:
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
在给定类被声明为记录时返回 true。(与 isEnum
对比。)
备选方案
记录可以视为 元组 的命名形式。除了记录之外,我们还可以实现结构化的元组。然而,虽然元组可能提供了一种更轻量级的表达某些聚合体的方式,但结果往往是较差的聚合体:
Java 设计哲学的一个核心方面是 名称很重要。类和它们的成员具有有意义的名称,而元组和元组组件则没有。也就是说,一个带有
firstName
和lastName
属性的Person
类比一个匿名String
和String
元组更清晰且更安全。类通过它们的构造函数支持状态验证;元组则不支持。一些数据聚合体(如数值范围)具有在构造函数中强制执行的不变量,之后可以依赖这些不变量;而元组不提供这种能力。
类可以具有基于其状态的行为;将状态和行为放在一起可以使行为更容易发现和访问。作为原始数据的元组不提供这种功能。
依赖项
除了上面提到的记录和密封类型的组合外,记录还自然地适用于 模式匹配。由于记录将其 API 与其状态描述结合在一起,我们最终还能够为记录推导出解构模式,并使用密封类型信息来确定具有类型模式或解构模式的 switch
表达式中的详尽性。