JEP 384:记录(第二次预览)
总结
历史
目标
- 设计一个面向对象的结构,该结构表达值的简单聚合。
- 帮助程序员专注于建模不可变数据,而非可扩展的行为。
- 自动实现数据驱动的方法,例如
equals
和访问器。 - 保留长期的 Java 原则,例如名义类型和迁移兼容性。
非目标
-
它的目标并不是要发起一场“消除样板代码的战争”。特别是,它并不旨在解决那些使用 JavaBeans 命名约定的可变类所存在的问题。
-
它的目标并不是添加诸如属性或注解驱动的代码生成等功能,这些功能常被提议用来简化“Plain Old Java Objects”类的声明。
动机
一个常见的抱怨是“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;
}
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 代码应当更易于编写、阅读以及验证其正确性。
虽然表面上看,将记录(records)主要视为减少样板代码的工具是诱人的,但我们选择了一个更具语义化的目标:将数据建模为数据。(如果语义正确,样板代码的问题自然会解决。)声明数据载体类应该简单且简洁,默认情况下使它们的数据不可变,并提供惯用的方法实现来生成和使用这些数据。
描述
Records 是 Java 语言中一种新型的类。记录的目的是声明一小组变量应被视为一种新的实体。记录声明其 状态 —— 即这组变量 —— 并承诺提供与该状态匹配的 API。这意味着记录放弃了类通常享有的一个自由 —— 将类的 API 与其内部表示解耦的能力 —— 但作为回报,记录变得更加简洁。
记录的声明指定了一个名称、一个头部和一个主体。头部列出了记录的 组成部分,这些组成部分是构成其状态的变量。(组成部分的列表有时被称为 状态描述。)例如:
record Point(int x, int y) { }
由于记录对其数据的语义声明是透明的载体,因此记录会自动获取许多标准成员:
-
对于头部中的每个组件,包含两个成员:一个与组件同名且返回类型相同的
public
访问器方法,以及一个与组件类型相同的private
final
字段; -
一个规范构造函数(canonical constructor),其签名与头部相同,并将每个
private
字段赋值为对应于new
表达式中实例化记录的参数; -
equals
和hashCode
方法,表明如果两个记录属于同一类型且包含相等的组件值,则它们相等;以及 -
一个
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 设计哲学的一个核心方面是名称很重要。类及其成员拥有有意义的名称,而元组和元组组件则没有。也就是说,一个包含
firstName
和lastName
属性的Person
类比一个匿名的String
和String
元组更清晰、更安全。 -
类通过其构造函数支持状态验证;元组则不支持。一些数据聚合(例如数值范围)具有不变量,如果这些不变量由构造函数强制执行,那么之后就可以依赖它们;元组则不具备这种能力。
-
类可以基于其状态拥有行为;将状态和行为集中在一起使得行为更容易被发现和访问。元组作为原始数据,则没有提供这样的功能。
依赖
除了上述记录(records)与密封类型(sealed types)的组合之外,记录还天然适用于模式匹配。由于记录将其 API 与其状态描述相耦合,未来我们将能够为记录派生出解构模式(deconstruction patterns),并利用密封类型信息来确定在带有类型模式或解构模式的 switch
表达式中的穷尽性(exhaustiveness)。