跳到主要内容

JEP 395:记录

概括

使用记录增强 Java 编程语言,记录是充当不可变数据的透明载体的类。记录可以被认为是_名义元组_。

历史

记录由JEP 359提出,并作为预览功能在JDK 14中提供。

为了响应反馈,该设计由JEP 384进行了改进,并第二次作为预览功能在JDK 15中提供。第二次预览的改进如下:

  • 在第一个预览中,规范构造函数必须是public.在第二个预览中,如果隐式声明规范构造函数,则其访问修饰符与记录类相同;如果显式声明规范构造函数,则其访问修饰符必须至少提供与记录类一样多的访问权限。

  • 注释的含义@Override被扩展为包括注释方法是记录组件的显式声明的访问器方法的情况。

  • 为了强制使用紧凑构造函数,分配给构造函数主体中的任何实例字段都会出现编译时错误。

  • 引入了声明本地记录类、本地枚举类和本地接口的功能。

此 JEP 建议在 JDK 16 中最终确定该功能,并进行以下改进:

  • 放宽长期存在的限制,即内部类不能声明显式或隐式静态的成员。这将变得合法,特别是允许内部类声明一个记录类成员。

根据进一步的反馈,可以纳入额外的改进。

目标

  • 设计一个面向对象的构造来表达简单的值聚合。

  • 帮助开发人员专注于对不可变数据进行建模,而不是对可扩展行为进行建模。

  • 自动实现数据驱动的方法,例如equals和 访问器。

  • 保留长期存在的 Java 原则,例如名义类型和迁移兼容性。

非目标

  • 虽然记录在声明数据载体类别时确实提供了改进的简洁性,但这并不是宣布“向样板文件宣战”的目标。特别是,它的目标不是解决使用 JavaBeans 命名约定的可变类的问题。

  • 添加属性或注释驱动的代码生成等功能并不是我们的目标,这些功能通常是为了简化“普通旧 Java 对象”的类声明而提出的。

动机

人们普遍抱怨“Java 太冗长”或“太多仪式”。一些最严重的罪犯是一些类,它们只不过是少数值的不可变_数据载体_。正确编写这样的数据载体类涉及大量低价值、重复、容易出错的代码:构造函数、访问器、、、、equals等。例如,一个携带 x 和 y 坐标的类不可避免地会hashCodetoString这样结束:

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 代码应该更容易编写、阅读和验证其正确性。

虽然表面上很容易将记录视为主要是为了减少样板文件,但我们选择了一个更具语义的目标:将数据建模为数据。 (如果语义正确,样板文件将自行处理。)声明数据载体类应该简单而简洁,_默认情况下_使其数据不可变,并提供生成和使用数据的方法的惯用实现。

描述

_记录类_是Java 语言中的一种新类。记录类有助于以比普通类更少的仪式来对纯数据聚合进行建模。

记录类的声明主要包括其_状态_的声明;然后记录类提交给与该状态匹配的 API。这意味着记录类放弃了类通常享有的自由——将类的 API 与其内部表示解耦的能力——但作为回报,记录类声明变得更加简洁。

更准确地说,记录类声明由名称、可选类型参数、标头和主体组成。标头列出了记录类的_组件_,它们是构成其状态的变量。 (此组件列表有时称为_状态描述_。)例如:

record Point(int x, int y) { }

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

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

  • 一个_规范的构造函数_,其签名与标头相同,并将每个私有字段分配给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){ }

如果通过以下方式复制r1的实例:R

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具有浮点组件的记录类一致。同样,显式声明equalshashCode方法的行为应该类似。

记录等级规则

与普通类相比,记录类的声明有许多限制:

  • 记录类声明没有extends子句。记录类的超类总是java.lang.Record,类似于枚举类的超类总是java.lang.Enum。尽管普通类可以显式扩展其隐式超类Object,但记录不能显式扩展任何类,甚至是其隐式超类Record

  • 记录类是隐式的final,并且不能是隐式的abstract。这些限制强调记录类的 API 仅由其状态描述定义,并且以后不能由其他类增强。

  • 从记录组件派生的字段是final。此限制体现了广泛适用于数据载体类别的_不可变的默认策略。_

  • 记录类不能显式声明实例字段,也不能包含实例初始值设定项。这些限制确保记录头单独定义记录值的状态。

  • 否则自动派生的成员的任何显式声明都必须与自动派生成员的类型完全匹配,而忽略显式声明上的任何注释。访问器或equals或方法的任何显式实现hashCode都应小心保留记录类的语义不变量。

  • 记录类不能声明native方法。如果记录类可以声明一个native方法,那么根据定义,记录类的行为将取决于外部状态而不是记录类的显式状态。没有具有本机方法的类可能是迁移到记录的良好候选者。

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

  • 记录类的实例是使用表达式创建的new

  • 记录类可以声明为顶级或嵌套,并且可以是通用的。

  • 记录类可以声明static方法、字段和初始值设定项。

  • 记录类可以声明实例方法。

  • 记录类可以实现接口。记录类不能指定超类,因为这意味着继承状态,超出标头中描述的状态。然而,记录类可以自由地指定超级接口并声明实例方法来实现它们。就像类一样,接口可以有效地描述许多记录的行为。该行为可以是与域无关的(例如, )或特定于域的,在这种情况下,记录可以是捕获域的_密封_Comparable层次结构的一部分(见下文)。

  • 记录类可以声明嵌套类型,包括嵌套记录类。如果记录类本身是嵌套的,那么它是隐式静态的;这避免了立即封闭的实例会默默地将状态添加到记录类中。

  • 记录类及其标头中的组件可以用注释来修饰。根据注释的适用目标集,记录组件上的任何注释都会传播到自动派生的字段、方法和构造函数参数。记录组件类型上的类型注释也会传播到自动派生成员中的相应类型使用。

  • 记录类的实例可以序列化和反序列化。但是,不能通过提供writeObjectreadObjectreadObjectNoDatawriteExternal、 或方法来自定义流程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());
}

本地记录类是嵌套记录类的特例。与嵌套记录类一样,本地记录类是_隐式静态的_。这意味着它们自己的方法不能访问封闭方法的任何变量;反过来,这可以避免捕获立即封闭的实例,该实例会默默地将状态添加到记录类中。本地记录类是隐式静态的这一事实与本地类相反,本地类不是隐式静态的。事实上,局部类从来都不是静态的——无论是隐式的还是显式的——并且总是可以访问封闭方法中的变量。

本地枚举类和本地接口

添加本地记录类是添加其他类型的隐式静态本地声明的机会。

嵌套枚举类和嵌套接口已经是隐式静态的,因此为了保持一致性,我们定义了本地枚举类和本地接口,它们也是隐式静态的。

内部类的静态成员

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

我们放宽此限制,以便允许内部类声明显式或隐式静态的成员。特别是,这允许内部类声明作为记录类的静态成员。

记录组件上的注释

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

这就提出了一个问题:当一个组件被注释时,实际上注释的是什么?答案是“该特定注释适用的所有元素”。这使得在其字段、构造函数参数或访问器方法上使用注释的类能够迁移到记录,而无需多余地声明这些成员。例如,如下所示的类

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

Record包内com.myapp和包Record内都是java.lang用通配符导入的。因此,两个类都不具有优先级,并且编译器在遇到简单名称的使用时会生成错误消息Record

为了使该示例能够编译,import可以更改该语句,以便导入 的完全限定名称Record

import com.myapp.Record;

在包中引入类的情况java.lang很少见,但有时是必要的。前面的示例Enum位于 Java 5、ModuleJava 9 和RecordJava 14 中。

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

如果记录组件具有与擦除描述符不同的通用签名,则结构Signature中必须有一个属性record_component_info

反射API

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

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

  • boolean isRecord()— 如果给定类被声明为记录,则返回 true。 (与之比较isEnum。)

备择方案

_记录类可以被视为元组_的名义形式。我们可以实现结构元组,而不是记录类。然而,虽然元组可能提供表达某些聚合的轻量级方法,但结果通常是较差的聚合:

  • Java 设计哲学的一个核心方面是_名称很重要_。类及其成员具有有意义的名称,而元组和元组组件则没有。也就是说,Person具有组件 和 的记录类比firstName两个lastName字符串的匿名元组更清晰、更安全。

  • 类允许通过其构造函数进行状态验证;元组通常不会。某些数据聚合(例如数值范围)具有不变量,如果由构造函数强制执行,则此后可以依赖这些不变量。元组不提供这种能力。

  • 类可以具有基于其状态的行为;将状态和行为放在一起使得行为更容易发现并且更容易访问。元组作为原始数据,不提供这样的功能。

依赖关系

记录类与当前预览中的另一个功能(即_密封类_(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 {...}

记录类和密封类的组合有时称为代数数据类型。记录类允许我们表达_乘积_,密封类允许我们表达_求和_。

除了记录类和密封类的组合之外,记录类自然地适合于模式匹配。由于记录类将其 API 与其状态描述相结合,因此我们最终也能够导出记录类的解构模式,并使用密封类型信息来确定switch具有类型模式或解构模式的表达式的详尽性。