跳转到内容

JEP 395:记录

原文:https://openjdk.org/jeps/395
翻译:张欢

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

历史

记录由JEP 359提出并在JDK 14中作为一个预览特性

作为对反馈的回应,该设计经由JEP 384进行改进并在JDK 15中作为第二版预览特性。第二版预览的改进如下:

  • 在第一版预览中,规范的构造器必须是public的。在第二版预览中,如果隐式地声明了规范的构造器,那么其访问修饰符与当前记录相同;如果显式声明了规范构造器,那么其访问修饰符必须至少具有与当前记录类型相同的访问权限。
  • @Override注解的含义得到扩展,包括了被注解的方法是记录组件的隐式访问方法的情况。
  • 为了确保使用紧凑的构造器,在构造器内部赋值给任何实例字段都会成为编译错误。
  • 引入了声明局部记录类型、局部枚举类型和局部接口的能力。

本JEP的目标是在JDK 16中完成该特性,并进行以下改进:

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

可以根据进一步的反馈合并其他改进。

目标

  • 设计一个面向对象的构造,用来表示简单数据的聚合。
  • 帮助开发者专注于对不可变数据进行建模,而不是对可扩展的行为进行建模。
  • 自动实现数据驱动的方法,例如equals和访问器。
  • 保留长期的Java原则,例如名义类型与迁移兼容性。

非目标

  • 虽然记录在声明数据载体类别时确实提供了改进的简洁性,但宣告“样板战争”不是目标;特别是,使用JavaBean命名规范来解决可变类的问题不是目标。
  • 添加如属性或注解驱动的代码生成也不是目标,通常建议使用这些功能来简化“POJO”类的声明。

动机

人们普遍抱怨“Java太冗长”或有“过多形式”。一些最严重的反例是那些仅仅作为少量值的不可变数据载体的类。可能写一个数据载体类会涉及到大量低价值的、重复的、易错的代码:构造器、访问器、equalshashCodetoString,等等。例如,一个携带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会帮助在数据载体类中写出大部分代码,但不会做任何事情来帮助代码阅读者在几十行样板代码中提取“我是xy的数据载体”的含义。编写简单聚合建模的Java代码,应该更容易地——写、读和验证正确性。

表面看来,用记录来减少样板代码很诱人,但我们选择了一个更加语义化的目标:用数据为数据建模。(如果语义正确,样板代码会自解释。)声明默认情况下数据不可变的数据载体类,并提供生成和使用数据的惯用方法,这应该非常简单明了。

描述

记录是Java语言中一种新的类。记录类用比普通类更少的形式代码,来帮助对数据聚合进行建模。

记录类的定义主要由它的状态组成;记录类会生成与状态匹配的API。这意味着状态类放弃了类的自由——将类的API与其内部表示分离的能力——但作为回报,记录类的定义获得了很大程度的简洁性。

更加精确地说,记录类的定义由一个名称、可选的类型参数、一个头和一个体组成。头的部分列出了记录的组件,也就是表示状态的变量。(组件列表有时也被称为状态描述。)例如:

record Point(int x, int y) { }

因为记录类在语义上声称是其数据的简单透明持有者,所以记录会自动获取许多标准成员:

  • 为头中的每一个组件生成两个成员:一个public的访问器方法,具有与组件相同的名称和返回类型,和一个private final的字段,具有与组件相同的类型。
  • 一个签名与头相同的规范化构造器,并使用初始化记录时new表达式中的参数为每一个私有字段赋值。
  • equalshashCode方法,用来在两条记录的组件具有相同的类型和值时表示它们相等。
  • toString方法,用字符串表示记录中所有组件的名称。

换句话说,记录的头描述了它的状态,也就是组件的类型和名称,并根据状态描述机械地导出了API。这些API包括用于构造、成员访问、判断相等和字符串显示的协议。(我们期待未来的版本支持解构模式,以实现强大的模式匹配。)

记录类的构造器

在构造器的规则方面,记录和普通的类有所不同。没有任何构造器声明的普通类会自动给出一个默认构造器。不同的是,没有任何构造器声明的记录会自动给出一个规范化构造器,用初始化记录时new表达式的参数为所有private字段赋值。例如,此前声明的记录——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));
}
}

下面是一个紧凑的规范化构造器在正常化它的形式参数:

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) {
// 正常化
int gcd = gcd(num, denom);
num /= gcd;
denom /= gcd;
// 初始化
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不是null引用,则表达式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的。这条限制体现了广泛适用于数据载体类的默认不可变策略。
  • 记录类不可以显式定义实例字段,不可以包含构造块。这些限制可以确保仅记录头定义记录值的状态。
  • 任何不显式声明就会自动生成的成员,必须与自动生成的成员类型严格匹配,而不管显式声明上的类型注解。任何访问器、equalshashCode方法的显式实现,都应该谨慎地保留记录类的语义约束。
  • 记录不能声明native方法。如果记录可以声明native方法,那么记录的表现将完全依赖于外部状态,而不是记录的显式状态。带有native方法的类都不适合迁移到记录。

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

  • 记录类的实例使用new表达式进行创建。
  • 记录类可以被定义为顶级或嵌套的,可以是泛型。
  • 记录类可以声明static的方法、字段和初始化块。
  • 记录类可以声明实例方法。
  • 记录类可以实现接口。一个记录类无法指定父类,因为那意味着继承的状态,超出了记录头中描述的状态。但一个记录可以自由地指定父接口并声明实例方法来实现它们。就像类一样,接口可以有效地描述许多记录的行为。该行为可以是与域无关的(例如Comparable)或是与域相关的,在这种情况下记录可以是一个捕获域的密封层(见下文)。
  • 记录类可以声明嵌套类型,包括嵌套记录。如果记录类自身是嵌套的,那么它是隐式static的;这避免了立即闭包的实例,该实例将以静默方式将状态添加到记录类中。
  • 记录类,以及在头部声明的组件,可以被注解修饰。根据注解的使用目标集,记录组件上的任何注解都会传播到自动生成的字段、方法和构造器参数上。记录组件类型上的类型注解也将会传播到派生成员的类型上。
  • 记录类的实例可以被序列化和反序列化。然而,不可以通过提供writeObjectreadObjectreadObjectNoDatawriteExternalreadExternal方法来自定义处理。记录类的组件控制序列化,而记录类的规范化构造器控制反序列化。

局部记录类

产生和使用记录类实例的程序可能会处理许多本身就是简单变量组的中间值。声明记录以对那些中间值建模通常会很方便。一种选择是声明静态且嵌套的“helper”记录,就像今天许多程序声明helper类一样。一个更方便的选择是在方法内部声明一条记录,靠近操作变量的代码。因此,本JEP提出了局部记录类,类似于局部类的传统构造。

在下面的例子中,使用局部记录类MerchantSales对商人和每月销售额进行汇总。使用该记录可以提高以下流操作的可读性:

List findTopMerchants(List 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());
}

局部记录类是嵌套记录类的一种特例。像所有嵌套记录一样,局部记录是隐式静态的。这意味着他们自己的方法无法访问闭包方法的任何变量;反过来,这避免了捕获立即封闭的实例,该实例将以静默方式将状态添加到记录中。局部记录是隐式静态的,这与不是隐式静态的局部类相反。实际上,局部类永远不会是静态的——无论显式或隐式——而且总是可以访问闭包方法的变量。

局部枚举类和局部接口

添加局部记录类是一个机会,可以添加其他隐式静态的局部声明。

嵌套枚举类和嵌套接口已经是隐式静态的,所以为了一致,我们定义局部枚举类和局部接口也是隐式静态的。

内部类的静态成员

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

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

记录组件的注解

记录组件在记录声明中具有多个角色。记录组件是一类(first-class)的概念,但每个组件还对应于一个具有相同名称和类型的字段,一个具有相同名称和返回类型的访问器方法,以及一个具有相同名称和类型的规范化构造器的形式参数。

这就带来了一个问题:当对组件进行注解时,实际上是在注解什么?答案是:“所有适用于该注解的内容”。这就允许那些在其字段、构造器参数或访问器方法上使用注解的类迁移到记录,而不必冗余地声明这些成员。例如,下面的类:

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字段上。
  • 如果记录组件上的注解适用于方法声明,那么注解会出现在对应的访问器方法上。
  • 如果记录组件上的注解适用于形式参数,则如果未显式声明注解,则该注解将出现在规范构造器的对应形式参数上;如果显式声明,则注解将出现在紧凑构造器的对应形式参数上。
  • 如果记录组件上的注解适用于某种类型,则注解会传播到下面所有:
    • 对应字段的类型
    • 对应访问器方法的返回值类型
    • 对应规范化构造器形式参数的类型
    • 记录组件的类型(可以在运行时用反射访问)

如果显式声明了public的公共访问器方法或(非紧凑的)规范构造参数,那么它仅具有直接显式在其上的注解;没有任何内容从相应的记录组件传播到这些成员。

在记录组件上声明的注解,不会出现在那些在运行时通过反射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语法

记录声明:
{类修饰符} `record` 类型标识符 [类型参数]
记录头 [父接口] 记录体
记录头:
`(` [记录组件列表] `)`
记录组件列表:
记录组件 { `,` 记录组件}
记录组件:
{注解} UnannType 标识符
可变数量的记录组件
可变数量的记录组件:
{注解} UnannType {注解} `...` 标识符
记录体:
`{` {记录体声明} `}`
记录体声明:
类体声明
紧凑构造器声明
紧凑构造器声明:
{构造器修饰符} 简单类名称 构造器体

类文件表示

记录的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的核心理念是名称很重要。类及其成员具有有意义的名称,而元组和元组的组件却没有。也就是说,具有firstNamelastName属性的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 {...}

记录类与密封类型的组合有时被称为代数数据类型。记录类使我们能够表示product类型,而记录类型使我们能够表示sum类型。

作为对记录类与密封类型组合的补充,记录将自身借给模式匹配。因为记录将其API与其状态描述相结合,所以我们最终也将能够导出记录的解构模式,并使用密封类型的信息来确定具有类型模式或解构模式的switch表达式的详尽性。