JEP 395: Records
总结
通过 records 增强 Java 编程语言,这些类充当不可变数据的透明载体。Records 可以被认为是一种 nominal tuples。
历史
-
在第一个预览版中,规范构造函数被要求为
public
。在第二个预览版中,如果规范构造函数是隐式声明的,那么其访问修饰符与记录类相同;如果规范构造函数是显式声明的,则其访问修饰符必须提供至少与记录类相同的访问权限。 -
@Override
注解的意义扩展到了包括注解方法是记录组件的显式声明的访问器方法的情况。 -
为了强制紧凑构造函数的预期使用,在构造函数主体中给任何实例字段赋值成为了编译时错误。
-
增加了声明局部记录类、局部枚举类和局部接口的能力。
本 JEP 提议在 JDK 16 中完成该功能,并进行以下改进:
- 放宽长期以来的限制,即内部类不能声明显式或隐式的静态成员。这将变得合法,特别是,将允许内部类声明一个记录类的成员。
根据进一步的反馈,可能会纳入更多的改进。
目标
-
设计一个面向对象的结构,该结构表达值的简单聚合。
-
帮助开发者专注于建模不可变数据,而不是可扩展的行为。
-
自动实现数据驱动的方法,例如
equals
和访问器。 -
保留长期存在的 Java 原则,例如名义类型和迁移兼容性。
非目标
-
虽然记录(records)在声明数据载体类时确实提供了更好的简洁性,但我们的目标并不是“消除模板代码”。特别是,我们并不打算解决那些使用 JavaBeans 命名约定的可变类的相关问题。
-
我们的目标也不是添加诸如属性(properties)或注解驱动的代码生成等功能,这些功能通常被提议用来简化“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
的数据载体”这一设计意图。编写用于对少量值进行建模的 Java 代码应当更易于编写、阅读以及验证其正确性。
虽然表面上看,将记录(records)主要视为减少样板代码的工具是诱人的,但我们选择了一个更具语义性的目标:将数据建模为数据。(如果语义正确,样板代码自然会得到解决。)声明数据载体类应该简单且简洁,默认情况下使它们的数据不可变,并提供惯用的方法实现来生成和使用这些数据。
描述
记录类(Record classes) 是 Java 语言中一种新型的类。记录类有助于以比普通类更少的代码来对纯数据聚合进行建模。
记录类的声明主要由其 状态 的声明组成;然后,记录类承诺提供与该状态匹配的 API。这意味着记录类放弃了类通常享有的一个自由——将类的 API 与其内部表示解耦的能力——但作为回报,记录类的声明变得更加简洁。
更准确地说,记录类声明由名称、可选的类型参数、头部和主体组成。头部列出了记录类的 组成部分,这些组成部分是构成其状态的变量。(此组成部分列表有时被称为 状态描述。)例如:
record Point(int x, int y) { }
由于记录类声称是其数据的透明载体,因此记录类会自动获取许多标准成员:
-
对于头部中的每个组件,包含两个成员:一个与组件同名且返回类型相同的
public
访问器方法,以及一个与组件类型相同的private
final
字段; -
一个规范构造函数(canonical constructor),其签名与头部相同,并将每个私有字段分配给从实例化记录的
new
表达式中对应的参数; -
equals
和hashCode
方法,确保两个记录值在类型相同且包含相等的组件值时是相等的;以及 -
一个
toString
方法,返回所有记录组件及其名称的字符串表示形式。
换句话说,记录类的头部描述了其状态,即其组件的类型和名称,并且 API 是从该状态描述中机械地、完整地派生出来的。API 包括构造协议、成员访问、相等性以及显示的协议。(我们预计未来的版本将支持解构模式,以允许强大的模式匹配。)
记录类的构造函数
记录类中的构造函数规则与普通类不同。没有声明任何构造函数的普通类会自动获得一个默认构造函数。相比之下,没有声明任何构造函数的记录类会自动获得一个规范构造函数,该构造函数将所有私有字段分配给实例化记录的 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;
}
}
规范构造函数可以像上面所示那样通过与记录头匹配的形式参数列表显式声明。它也可以通过省略形式参数列表更紧凑地声明。在这种紧凑规范构造函数中,参数是隐式声明的,并且对应于记录组件的私有字段不能在主体中赋值,而是在构造函数末尾自动赋值给相应的形式参数(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 Rational(int num, int denom) {
Rational {
int gcd = gcd(num, denom);
num /= gcd;
denom /= gcd;
}
}
该声明等同于传统的构造函数形式:
record Rational(int num, int denom) {
Rational(int num, int demon) {
// Normalization
int gcd = gcd(num, denom);
num /= gcd;
denom /= gcd;
// Initialization
this.num = num;
this.denom = denom;
}
}
具有隐式声明的构造函数和方法的记录类满足重要且直观的语义属性。例如,考虑如下声明的记录类 R
:
record R(T1 c1, ..., Tn cn){ }
如果 R
的实例 r1
按以下方式复制:
R r2 = new R(r1.c1(), r1.c2(), ..., r1.cn());
那么,假设 r1
不是空引用,表达式 r1.equals(r2)
总是 会评估为 true
。显式声明的访问器和 equals
方法应当遵守这个不变量。然而,编译器通常无法检查显式声明的方法是否遵守这个不变量。
例如,以下记录类的声明应被视为不良风格,因为其访问器方法会“悄悄地”调整记录实例的状态,并且不满足上述不变式:
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
的行为保持一致。同样,显式声明的 equals
和 hashCode
方法应表现出类似的行为。
记录类的规则
与普通类相比,记录类的声明有许多限制:
-
记录类声明不包含
extends
子句。记录类的超类始终是java.lang.Record
,这与枚举类的超类始终是java.lang.Enum
类似。尽管普通类可以显式继承其隐式的超类Object
,但记录类不能显式继承任何类,甚至是其隐式的超类Record
。 -
记录类默认为
final
,并且不能是abstract
。这些限制强调了记录类的 API 仅由其状态描述定义,并且不能通过其他类在之后进行扩展。 -
从记录组件派生的字段是
final
的。这一限制体现了一种默认不可变的策略,该策略广泛适用于数据载体类。 -
记录类不能显式声明实例字段,也不能包含实例初始化器。这些限制确保只有记录头定义了记录值的状态。
-
任何本应自动派生的成员的显式声明必须与自动派生成员的类型完全匹配,忽略显式声明上的任何注解。对访问器或
equals
和hashCode
方法的任何显式实现都应小心保持记录类的语义不变性。 -
记录类不能声明
native
方法。如果记录类可以声明native
方法,则根据定义,记录类的行为将依赖于外部状态,而不是记录类的显式状态。带有本地方法的类不太可能成为迁移到记录类的良好候选者。
除了上述限制之外,记录类的行为类似于普通类:
-
使用
new
表达式创建记录类的实例。 -
记录类可以声明为顶级或嵌套,并且可以是泛型。
-
记录类可以声明
static
方法、字段和初始化器。 -
记录类可以声明实例方法。
-
记录类可以实现接口。记录类不能指定超类,因为这意味着继承的状态,超出了头部描述的状态。然而,记录类可以自由指定超级接口并声明实例方法来实现它们。正如对于类一样,接口可以有效地表征许多记录的行为。该行为可以是与领域无关的(例如,
Comparable
),也可以是特定领域的,在这种情况下,记录可以是捕获该领域的一部分 密封 层次结构(见下文)。 -
记录类可以声明嵌套类型,包括嵌套记录类。如果一个记录类本身是嵌套的,那么它是隐式静态的;这避免了立即封闭的实例,该实例会悄无声息地向记录类添加状态。
-
记录类及其头部中的组件可以用注解进行修饰。根据注解的适用目标集合,记录组件上的任何注解都会传播到自动派生的字段、方法和构造函数参数。记录组件类型的类型注解也会传播到自动派生成员中相应的类型使用。
-
记录类的实例可以被序列化和反序列化。然而,不能通过提供
writeObject
、readObject
、readObjectNoData
、writeExternal
或readExternal
方法来自定义该过程。记录类的组件控制序列化,而记录类的规范构造函数控制反序列化。
本地记录类
一个生成和使用记录类实例的程序可能会处理许多中间值,而这些中间值本身只是变量的简单组合。通常可以方便地声明记录类来为这些中间值建模。一种选择是声明“辅助”记录类,这些类是静态的且嵌套的,就像许多程序如今声明辅助类一样。更方便的选择是在方法内部声明记录类,靠近操作这些变量的代码。因此,我们定义了局部记录类,类似于现有的局部类构造。
在以下示例中,使用本地记录类 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());
}
局部记录类是嵌套记录类的一种特殊情况。与嵌套记录类一样,局部记录类是隐式静态的。这意味着它们自己的方法无法访问外围方法的任何变量;反过来,这避免了捕获直接外围实例,否则会悄无声息地为记录类添加状态。局部记录类隐式静态这一事实与局部类形成对比,局部类并非隐式静态。实际上,局部类永远不会是静态的 —— 无论是隐式还是显式 —— 并且始终可以访问外围方法中的变量。
局部枚举类和局部接口
局部记录类的引入为添加其他类型的隐式静态局部声明提供了机会。
嵌套的枚举类和嵌套的接口已经隐式为静态,因此为了保持一致性,我们定义了局部枚举类和局部接口,它们同样隐式为静态。
内部类的静态成员
当前规定指出,如果内部类声明了一个显式或隐式的静态成员,则会产生编译时错误,除非该成员是一个常量变量。这意味着,例如,内部类不能声明记录类成员,因为嵌套的记录类是隐式静态的。
我们放宽了这一限制,以允许内部类声明显式或隐式的静态成员。特别是,这允许内部类声明一个静态成员,该成员是一个记录类。
记录组件上的注解
记录组件在记录声明中具有多种角色。记录组件是一个一流的概念,但每个组件还对应一个同名和同类型的字段、一个同名和同返回类型的访问器方法,以及规范构造函数的一个同名和同类型的正式参数。
这就引出了一个问题:当一个组件被注解时,到底是什么被注解了?答案是,“这个特定注解适用的所有元素”。这使得使用字段、构造函数参数或访问器方法上的注解的类可以迁移到记录(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
元注解来控制的。传播规则是系统性的且直观,所有适用的规则都会被遵循:
-
如果记录组件上的注解适用于字段声明,那么该注解会出现在相应的私有字段上。
-
如果记录组件上的注解适用于方法声明,那么该注解会出现在相应的访问器方法上。
-
如果记录组件上的注解适用于形式参数,那么在未显式声明规范构造函数的情况下,该注解会出现在规范构造函数的相应形式参数上;否则,如果显式声明了紧凑构造函数,则该注解会出现在紧凑构造函数的相应形式参数上。
-
如果记录组件上的注解适用于类型,则该注解将传播到以下所有内容:
- 相应字段的类型
- 相应访问器方法的返回类型
- 规范构造函数的相应形式参数的类型
- 记录组件的类型(在运行时可通过反射访问)
如果显式声明了公共访问器方法或(非紧凑型)规范构造函数,那么它仅具有直接出现在其上的注解;不会从相应的记录组件传播任何内容到这些成员。
记录组件上的声明注解不会通过反射 API在运行时与该记录组件关联,除非该注解使用 @Target(RECORD_COMPONENT)
进行了元注解。
兼容性与迁移
抽象类 java.lang.Record
是所有记录类的共同超类。每个 Java 源文件都会隐式导入 java.lang.Record
类,以及 java.lang
包中的所有其他类型,无论你是否启用或禁用预览功能。但是,如果你的应用程序从另一个包中导入了另一个名为 Record
的类,则可能会遇到编译器错误。
考虑以下 com.myapp.Record
的类声明:
package com.myapp;
public class Record {
public String greeting;
public Record(String greeting) {
this.greeting = greeting;
}
}
以下示例 org.example.MyappPackageExample
使用通配符导入了 com.myapp.Record
,但无法编译:
package org.example;
import com.myapp.*;
public class MyappPackageExample {
public static void main(String[] args) {
Record r = new Record("Hello world!");
}
}
编译器生成类似于以下内容的错误消息:
./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
包中的 Record
和 java.lang
包中的 Record
都是通过通配符导入的。因此,这两个类都没有优先权,编译器在遇到使用简单名称 Record
时会生成错误消息。
为了使此示例能够编译,可以更改 import
语句,以便它导入 Record
的完全限定名称:
import com.myapp.Record;
java.lang
包中引入类的情况很少,但有时是必要的。之前的例子有 Java 5 中的 Enum
、Java 9 中的 Module
和 Java 14 中的 Record
。
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
属性来存储有关记录组件的信息:
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 设计理念的一个核心方面是名称很重要。类及其成员拥有有意义的名称,而元组和元组组件则没有。也就是说,一个包含
firstName
和lastName
组件的Person
记录类比两个字符串的匿名元组更加清晰且安全。 -
类允许通过其构造函数进行状态验证;而元组通常不支持。某些数据聚合(例如数值范围)具有不变量,如果这些不变量由构造函数强制执行,则之后可以依赖它们。元组不提供这种能力。
-
类可以基于其状态拥有行为;将状态和行为共置一处使得行为更易于发现和访问。元组作为原始数据,不提供这样的功能。
依赖
记录类与另一个目前处于预览阶段的特性配合得很好,即 密封类(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 {...}
记录类和密封类的组合有时被称为 代数数据类型。记录类允许我们表达 乘积,而密封类允许我们表达 和。
除了记录类(record classes)和密封类(sealed classes)的组合之外,记录类天然适合用于模式匹配。由于记录类将其 API 与其状态描述相绑定,未来我们将能够为记录类推导出解构模式(deconstruction patterns),并利用密封类型信息来确定在使用类型模式或解构模式的 switch
表达式中的穷尽性(exhaustiveness)。