增强 Java 编程语言,引入 records,即作为不可变数据透明载体的类。记录可以被视为具名元组(nominal tuples)。

设计 Records 目标是:

  • 设计一种面向对象的结构,用于表达简单值的聚合。
  • 帮助开发人员专注于建模不可变数据而非扩展行为。
  • 自动实现诸如 equals 和访问器等数据驱动的方法。
  • 保持Java长期以来的原则,如名义类型(nominal typing)和迁移兼容性。

下面的不是设计 Records 的目标:

  • 虽然 Records 在声明数据载体类时确实提供了更好的简洁性,但并不是为了对样板代码(boilerplate code)宣战。特别地,它并不旨在解决使用 JavaBeans 命名约定的可变类的问题。
  • 添加诸如属性或注释驱动的代码生成之类的功能,这些常被提议用来简化“普通老式 Java 对象”(Plain Old Java Objects, POJOs)的声明。

Records 最初由 JEP 359 提出,并在 JDK 14 中作为预览特性发布。根据反馈,JEP 384 对设计进行了改进,并在 JDK 15 中第二次作为预览特性发布。第二次预览的主要改进包括:

  • 在第一次预览中,规范构造函数(canonical constructors)必须是公共的。在第二次预览中,如果规范构造函数隐式声明,则其访问修饰符与记录类相同;如果显式声明,则其访问修饰符至少应与记录类具有相同的访问级别。
  • 扩展了 @Override 注解的意义,使其适用于显式声明的记录组件访问器方法。
  • 为了强制执行紧凑构造函数的预期使用,在构造函数体内给任何实例字段赋值成为编译时错误。
  • 引入了声明局部记录类、局部枚举类和局部接口的能力。

JEP 395 提议在 JDK 16 中最终确定该特性,并放宽长期存在的限制,即内部类不能声明显式或隐式的静态成员。这将变得合法,特别是允许内部类声明记录类作为成员。

一、动机

常见的抱怨是“Java 太冗长”或“Java 有太多的形式主义”。其中一些最糟糕的例子就是那些仅仅是几个值的不可变数据载体的类。正确编写这样的数据载体类涉及到很多低价值、重复且容易出错的代码:构造函数、访问器、equalshashCodetoString 等。例如,一个携带 x 和 y 坐标的类最终会变成这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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)可以帮助我们编写大多数的数据载体类的代码,但对于读者来说,从几十行样板代码中提炼出“我是 x 和 y 的数据载体”的设计意图是非常困难的。编写 Java 代码以建模少量值应该更容易写、更容易读,并且更容易验证为正确。

尽管表面上看,将记录视为主要减少样板代码的工具很有吸引力,但我们选择了一个更语义化的目标准则:将数据建模为数据。如果语义正确,样板代码自然会得到妥善处理。应当使声明默认情况下使数据不可变并提供惯用方法实现的数据载体类变得容易和简明,这些方法生产和消费数据。

二、描述

记录类(Record Classes)是 Java 语言中的一种新型类,旨在以比普通类更简洁的方式建模纯数据聚合

记录类的声明主要由其状态的声明组成;一旦定义了记录类的状态,该类即承诺实现与该状态相匹配的API。这意味着记录类放弃了普通类通常享有的自由——即解耦类的API与其内部表示的能力——但作为交换,记录类的声明变得更加简明扼要。

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

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

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

  • 对于头部中的每个组件,有两个成员:一个与组件同名且具有相同返回类型的公共 accessor 方法,以及一个与组件具有相同类型的 private final 字段
  • 一个规范构造函数(canonical constructor),其签名与头部相同,并将每个私有字段赋值为实例化记录的新表达式对应的参数
  • equalshashCode 方法,确保两个记录值如果属于同一类型并且包含相等的组件值,则它们是相等的
  • 一个 toString 方法,返回包含所有记录组件及其名称的字符串表示。

换句话说,记录类的头部描述了它的状态,即其组件的类型和名称,而 API 则完全从该状态描述机械地派生出来。API 包括构造协议、成员访问、相等性判断和显示格式。(预计未来的版本将支持解构模式,以允许强大的模式匹配(pattern matching)功能。)

1、记录类的构造方法

记录类(record class)中的构造函数规则与普通类不同。

  • 对于没有任何构造函数声明的普通类,会自动提供一个默认构造函数。
  • 没有构造函数声明的记录类会自动获得一个规范构造函数(canonical constructor),该构造函数将所有私有字段赋值为实例化记录的新表达式对应的参数。

例如,之前声明的记录 record Point(int x, int y) { } 在编译时会被处理为如下形式:

1
2
3
4
5
6
7
8
9
10
11
12
13
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;)。这种紧凑形式帮助开发人员专注于验证和规范化参数,而不需要进行繁琐的参数到字段的赋值工作。

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

1
2
3
4
5
6
record Range(int lo, int hi) {
Range {
if (lo > hi) // 引用的是隐式的构造函数参数
throw new IllegalArgumentException(String.format("(%d,%d)", lo, hi));
}
}

再比如,这是一个对正式参数进行规范化的紧凑规范构造函数:

1
2
3
4
5
6
7
record Rational(int num, int denom) {
Rational {
int gcd = gcd(num, denom);
num /= gcd;
denom /= gcd;
}
}

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

1
2
3
4
5
6
7
8
9
10
11
record Rational(int num, int denom) {
Rational(int num, int denom) {
// 规范化
int gcd = gcd(num, denom);
num /= gcd;
denom /= gcd;
// 初始化
this.num = num;
this.denom = denom;
}
}

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

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

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

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

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

例如,下面的记录类声明应被视为不良风格,因为它的访问器方法“悄悄”调整了记录实例的状态,并且上述不变性不成立:

1
2
3
4
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 方法也应表现类似。

2、记录类的规则

与普通类相比,记录类(record class)的声明有多个限制:

  • extends 子句:记录类声明不允许有 extends 子句。记录类的父类总是 java.lang.Record,类似于枚举类的父类总是 java.lang.Enum。尽管普通类可以显式地扩展其隐式的父类 Object,但记录类不能显式地扩展任何类,即使是它的隐式父类 Record
  • 隐式最终性和不可抽象性:记录类是隐式最终的(implicitly final),并且不能是抽象的。这些限制强调了记录类的 API 仅由其状态描述定义,且不能通过其他类在后期增强。
  • 字段为最终的:从记录组件派生的字段是最终的(final)。这一限制体现了适用于数据载体类的默认不可变性策略。
  • 禁止显式声明实例字段和初始化块:记录类不能显式声明实例字段,也不能包含初始化实例字段。这些限制确保只有记录头部定义了记录值的状态。
  • 自动派生成员的类型匹配:任何显式声明的、原本会自动派生的成员必须与其自动派生成员的类型完全匹配,忽略显式声明上的注解。任何访问器或 equalshashCode 方法的显式实现都应谨慎以保持记录类的语义不变性。
  • 禁止声明本地方法:记录类不能声明本地方法(native methods)。如果允许记录类声明本地方法,则记录类的行为将取决于外部状态而非其明确描述的状态。带有本地方法的类不太可能成为迁移到记录类的好候选。

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

  • 记录类的实例是使用 new 表达式创建的。
  • 记录类可以声明为顶级类或嵌套类,并且可以是泛型的。
  • 记录类可以声明静态方法、字段和初始化器。
  • 记录类可以声明实例方法。
  • 记录类可以实现接口。由于这不会引入继承状态,记录类不能指定父类,但可以自由指定超接口并声明实例方法来实现它们。接口可以有效地表征许多记录的行为,这种行为可能是领域无关的(例如 Comparable)或是领域特定的,在这种情况下,记录可以成为封闭层次结构的一部分。
  • 记录类可以声明嵌套类型,包括嵌套的记录类。如果记录类本身是嵌套的,则它是隐式静态的;这避免了立即封闭的实例,因为这会悄悄地向记录类添加状态。
  • 记录类及其头部中的组件可以使用注解修饰。记录组件上的任何注解都会传播到自动派生的字段、方法和构造函数参数,根据注解适用的目标集。记录组件类型的类型注解也会传播到自动派生成员中相应类型使用的部分。
  • 记录类的实例可以被序列化和反序列化。但是,不能通过提供 writeObjectreadObjectreadObjectNoDatawriteExternalreadExternal 方法来自定义此过程。记录类的组件控制序列化,而记录类的规范构造函数则控制反序列化。

3、局部记录类

一个生产和消费记录类实例的程序可能会处理许多本身是简单变量组的中间值。为了建模这些中间值,声明记录类通常很方便。一种选择是声明静态嵌套的“辅助”记录类,就像许多程序今天声明辅助类一样。更方便的选择是在方法内部声明记录类,靠近操作这些变量的代码。因此,我们定义了局部记录类(local record classes),类似于现有的局部类构造。

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

1
2
3
4
5
6
7
8
9
10
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(Collectors.toList());
}

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

4、局部枚举类和局部接口

引入局部记录类(local record classes)为添加其他类型的隐式静态局部声明提供了机会。

嵌套的枚举类(nested enum classes)和嵌套的接口(nested interfaces)已经是隐式静态的,因此为了保持一致性,我们也定义了局部枚举类(local enum classes)和局部接口(local interfaces),它们同样是隐式静态的。

PS:比如 Java 8 不支持局部枚举类和局部接口,会报编译错误:local interfaces are not supported at language level '8'

5、内部类的静态成员

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

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

PS:比如 Java 8 不支持内部类具有静态成员(非 final),会报编译错误:Inner classes cannot have static declarations

6、记录类中的注解

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

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

1
2
3
4
5
6
7
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; }
...
}

可以迁移到等价且更易读的记录声明:

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

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

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

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

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

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

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

  • 如果记录组件上的注解适用于字段声明,则该注解将出现在对应的私有字段上。
  • 如果记录组件上的注解适用于方法声明,则该注解将出现在对应的访问器方法上。
  • 如果记录组件上的注解适用于正式参数,则该注解将出现在规范构造函数的对应正式参数上(如果没有显式声明),或者出现在紧凑构造函数的对应正式参数上(如果有显式声明)。
  • 如果记录组件上的注解适用于类型,则该注解将传播到以下所有地方:
    • 对应字段的类型
    • 对应访问器方法的返回类型
    • 规范构造函数的对应正式参数的类型
    • 记录组件的类型(可通过反射在运行时访问)

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

记录组件上的声明注解只有在其被 @Target(RECORD_COMPONENT) 元注解修饰时,才会在运行时通过反射 API 与记录组件关联。

7、兼容性和迁移

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

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

1
2
3
4
5
6
7
8
package com.myapp;

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

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

1
2
3
4
5
6
7
8
package org.example;
import com.myapp.*;

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

编译器会生成类似的错误信息:

1
2
3
4
5
6
7
8
9
./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 包和 java.lang 包中的 Record 都通过通配符导入了。因此,没有一个类优先级更高,当编译器遇到简单名称 Record 的使用时,它会产生一个错误信息。

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

1
import com.myapp.Record;

java.lang 包中引入新类是罕见但有时必要的。之前的例子包括 Java 5 中的 Enum、Java 9 中的 Module 和 Java 14 中的 Record

8、Java 语法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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

9、类文件表示

record 的类文件使用一个 Record attribute 来存储有关记录组件的信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
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 属性。

10、反射 API(Reflection API)

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

  • RecordComponent[] getRecordComponents(),返回一个 java.lang.reflect.RecordComponent 对象数组。该数组的元素对应于记录的组件,顺序与记录声明中的顺序相同。可以从数组的每个元素中提取额外信息,包括名称、注解和访问器方法。
  • boolean isRecord(),如果给定的类被声明为记录,则返回 true。(与 isEnum 相比较。)

三、替代方案(Alternatives)

记录类可以被视为元组的一种命名形式。作为替代,我们可以实现结构化元组。然而,尽管元组可能提供了一种轻量级表达某些聚合的方式,但结果往往是次优的聚合:

  • 命名的重要性:Java 设计哲学的一个核心方面是名称的重要性。类及其成员具有有意义的名称,而元组和元组组件则没有。也就是说,一个包含 firstNamelastName 组件的 Person 记录类比一个由两个字符串组成的匿名元组更清晰且更安全。
  • 状态验证:类可以通过构造函数进行状态验证;元组通常不提供这种功能。对于一些数据聚合(如数值范围),如果通过构造函数强制执行不变性,则可以在之后依赖这些不变性。元组不提供这种能力。
  • 基于状态的行为:类可以有基于其状态的行为;将状态和行为共同定位使得行为更容易发现和访问。作为原始数据的元组不提供这样的设施。

四、依赖

记录类(record classes)与目前处于预览阶段的另一特性——密封类(sealed classes,JEP 360)配合得非常好。例如,一组记录类可以显式声明为实现同一个密封接口:

1
2
3
4
5
6
7
8
9
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 {...}

记录类和密封类的组合有时被称为代数数据类型(algebraic data types)。记录类使我们能够表达“乘积类型”(products),而密封类则使我们能够表达“和类型”(sums)。

除了记录类与密封类的组合外,记录类还天然地适用于模式匹配(pattern matching)。由于记录类将其 API 与其状态描述绑定在一起,我们最终将能够为记录类派生解构模式(deconstruction patterns),并使用密封类型信息来确定在带有类型模式或解构模式的 switch 表达式中的穷尽性(exhaustiveness)。

相关链接

JEP 395: Records (openjdk.org)

JEP 409 密封类 | z2huo

[[JEP 359 Records(预览版)]]

[[JEP 384 Records(二次预览版)]]

[[JEP 409 密封类]]

OB tags

#JDK新特性 #Java