跳转到内容

JEP 384:记录(第2版预览)

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

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

历史

记录是由JEP 359在2019年中期提出的,并在2020年初作为JDK 14预览特性。本JEP建议再次预览JDK 15中的特性,以结合基于反馈的改进,并支持Java语言中的局部类与接口。

目标

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

非目标

  • 宣告“样板战争”不是目标;特别是,使用JavaBean命名规范来解决可变类的问题不是目标。
  • 添加如属性或注解驱动的代码生成也不是目标,通常建议使用这些功能来简化“Plain Old Java Objects”类的声明。

动机

人们普遍抱怨“Java太冗长”或有“过多形式”。一些最严重的反例是那些仅仅作为少量值的不可变数据载体的类。可能写一个数据载体类会涉及到大量低价值的、重复的、易错的代码:构造器、访问器、equalshashCodetoString等等。例如,一个携带xy坐标的类不可避免地会写成这样:

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会帮助在数据载体类中写出大部分代码,但不会做任何事情来帮助代码阅读者在几十行样板代码中提取“我是xyz的数据载体”的含义。编写简单聚合建模的Java代码,应该更容易地——写、读和验证正确性。

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

描述

记录是Java语言中一种新的类。记录的目的是声明一小组变量,将其视为一种新的实体。记录声明它的状态——即一组变量——并提交可以匹配该状态的API。这意味着记录放弃了类的自由——将类的API与其内部表示分离的能力——作为回报,记录获得了很大程度的简洁性。

记录的声明包括一个名称、一个头与一个体。头的部分列出了记录的组件,也就是表示状态的变量。(组件列表有时也被称为状态描述。)例如:

record Point(int x, int y) { }

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

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

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

记录的规则

所有根据记录头自动获取的成员,除了根据记录组件生成的private字段外,都可以被显式声明。所有显式声明的访问器或equalshashCode方法都应该注意保留记录的不变性语义。

在构造器的规则方面,记录和普通的类有所不同。没有任何构造器声明的普通类会自动给出一个默认构造器。不同的是,没有任何构造器声明的记录会自动给出一个规范化构造器,用初始化记录时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));
}
}

声明记录时有几点限制:

  • 记录不具有extends子句。记录的父类总是java.lang.Record,类似于一个枚举的父类总是java.lang.Enum。尽管一个普通类可以显式地继承隐式的父类Object,但记录不可以显式继承任何类,即使是它的隐式父类Record
  • 记录是隐式final的,不可以是abstract的。这些限制强调记录的API仅由其状态描述定义,并且以后不能由另一个类或记录进行增强。
  • 记录不可以显式定义实例字段,不可以包含构造块。这些限制可以确保仅记录头定义记录值的状态。
  • 记录中隐式声明的与组件对应的字段是final的,而且不能用反射进行修改(这样做将引发IllegalAccessException)。这些限制体现了一种默认不变策略,该策略广泛适用于数据载体类。
  • 任何不显式声明就会自动生成的成员,必须与自动生成的成员类型严格匹配,而不管显式声明上的类型注解。
  • 记录不能声明native方法。如果记录可以声明native方法,那么记录的表现将完全依赖于外部状态,而不是记录的显式状态。带有native方法的类都不适合迁移到记录。

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

  • 记录使用new关键字进行初始化。
  • 记录可以被定义为顶级或嵌套的,可以是泛型的。
  • 记录可以声明静态方法、静态字段和静态初始化块。
  • 记录可以声明实例方法。即,记录可以显式声明与组件对应的public访问器方法,也可以声明其他实例方法。
  • 记录可以实现接口。尽管记录无法指定父类(因为这意味着继承的状态,超出了记录头中描述的状态),但是记录可以自由地指定父接口并声明实例方法来实现它们。就像类一样,接口可以有效地描述许多记录的行为。该行为可以是与域无关的(例如Comparable)或是与域相关的,在这种情况下记录可以是一个捕获域的密封层(见下文)。
  • 记录可以声明嵌套类型,包括嵌套记录。如果记录自身是嵌套的,那么它是隐式静态的;这避免了立即闭包的实例,该实例将以静默方式将状态添加到记录中。
  • 记录及其状态描述中的组件可以被注解修饰。注解将传播到自动派生的字段、方法和构造器参数上。记录组件类型上的类型注解也将会传播到派生成员的类型上。

记录与密封类型

记录可以很好地与密封类型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类型。

局部记录

产生和使用记录的程序可能会处理许多本身就是简单变量组的中间值。声明记录以对那些中间值建模通常会很方便。一种选择是声明static且嵌套的“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());
}

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

考虑到局部记录的有用性,拥有局部枚举和局部接口也将很有用。因为担心它们的语义,所以传统上在Java中不允许使用它们。具体来说,嵌套枚举和嵌套接口是隐式静态的,因此局部枚举和局部接口也应该是隐式静态的。但是,Java语言中的局部声明(局部变量,局部类)永远不会是静态的。但是,在JEP 359中引入局部记录克服了这种语义上的顾虑,允许局部声明为静态,并为局部枚举和局部接口打开了大门。

记录的注解

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

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

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

也可以使用新的注解声明@Target(RECORD_COMPONENT)声明来自记录组件上定义的注解。这些注解可以通过反射操作,如下面的“反射API”部分所述。

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()
  • boolean isRecord()

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

如果给定的类是以记录声明的,那么isRecord方法返回true。(就像isEnum。)

备选方案

记录可以视为元组的名义形式。作为记录的替代,我们可以实现结构化元组。但是,虽然元组可以提供表示某些聚合的轻量级方法,但结果通常是劣等的聚合:

  • Java的核心理念是名称很重要。类及其成员具有有意义的名称,而元组和元组的组件却没有。也就是说,具有firstNamelastName属性的Person类,要比具有StringString的匿名元组更为清晰和安全。
  • 类通过构造器支持状态验证;元组则不然。一些数据聚合(如数字范围)具有不变量,如果由构造器强制执行,则以后可以依赖这些不变量;元组则不提供此功能。
  • 类可以具有基于其状态的行为。将状态和行为并列放置可以使行为更易于发现和访问。元组是原始数据,不提供此类功能。

依赖

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