本文翻译自 Data-Oriented Programming for Java: Beyond Records,版权归原作者所有。
引言
每个人都喜欢 records;它们允许我们创建浅层不可变的数据持有类——我们可以将其视为"具名元组"——这些类派生自简洁的状态描述,并且可以通过模式匹配解构 records。但是 records 有严格的约束,并非所有数据持有类都符合 records 的限制。也许它们有一些可变状态,或者有不属于状态描述的派生或缓存状态,或者它们的表示和 API 不完全匹配,或者它们需要在层次结构中分解状态。在这些类中,即使它们也可能是"数据持有者",用户体验就像从悬崖上掉下来一样。即使与 record 理想有一点点偏差,也意味着必须回到白板重新开始,编写显式的构造函数声明、访问器方法声明和 Object 方法实现——并且放弃通过模式匹配进行解构。
从 records 设计过程开始,我们就牢记一个目标:让更广泛的类能够获得"record 的好处":减少声明负担、参与解构,以及即将推出的重构(reconstruction)。在设计 records 期间,我们还探索了许多较弱的语义模型,这些模型可以提供更大的灵活性。虽然当时它们都未能达到 records 的目标,但我们可以施加一组较弱的语义约束,允许更大的灵活性,同时仍然支持我们想要的特性,以及与偏离 record 理想的程度相称的某种程度的语法简洁性,而不会出现"掉下悬崖"的行为。
Records、sealed 类以及使用 record 模式进行解构构成了 Java"面向数据编程"的第一个特性弧。在考虑了众多设计思想之后,我们现在准备推进下一个"面向数据编程"特性弧:carrier 类(以及接口)。
超越 Record 模式
Record 模式允许将 record 实例解构为其组件。Record 模式可以在 instanceof 和 switch 中使用,并且当 record 模式也是穷尽的时,将可以在即将推出的 模式赋值语句 特性中使用。
在探索"类如何能够参与与 records 相同类型的解构"这个问题时,我们最初关注的是类中的一种新声明形式——“解构器(deconstructor)"——它作为构造函数的反向操作。正如构造函数接受组件值并产生聚合实例一样,解构器将接受聚合实例并恢复其组件值。
但随着这一探索的展开,更有趣的问题变成了:哪些类首先适合解构?这个问题的答案将我们引向了表达解构的不同方法。适合解构的类是那些像 records 一样,只不过是特定数据元组的载体。这不仅仅是类 拥有 的东西,比如构造函数或方法,而是类 是什么。因此,将解构描述为类的顶层属性更有意义。这反过来又带来了许多简化。
状态描述的力量
Records 是一个语义特性;它们只是偶然地简洁。但它们 确实 简洁;当我们声明一个 record 时:
record Point(int x, int y) {... }
我们自动获得合理的 API(规范构造函数、解构模式、每个组件的访问器方法)和实现(字段、构造函数、访问器方法、Object 方法)。如果我们愿意,可以显式指定其中的大部分(字段除外),但大多数情况下我们不必这样做,因为默认值正是我们想要的。
Record 是一个浅层不可变的 final 类,其 API 和表示完全由其 状态描述 定义。(records 的口号是"状态,完整的状态,除了状态别无他物。")状态描述是在 record 头部声明的 record 组件 的有序列表。组件不仅仅是字段或访问器方法;它本身就是一个 API 元素,描述了类的实例所具有的状态元素。
Record 的状态描述有几个理想的属性:
- 按指定顺序排列的组件是 record 状态的 规范 描述。
- 组件是 record 状态的 完整 描述。
- 组件是 具名的;它们的名称是 record API 的承诺部分。
Records 通过做出两个承诺来获得其好处:
- 外部 承诺:record 的数据访问 API(构造函数、解构模式和组件访问器方法)由状态描述定义。
- 内部 承诺:record 的 表示(其字段)也完全由状态描述定义。
这些语义属性使我们能够派生几乎所有关于 records 的东西。我们可以派生规范构造函数的 API,因为状态描述是规范的。我们可以派生组件访问器方法的 API,因为状态描述是具名的。我们可以从访问器方法派生解构模式,因为状态描述是完整的(以及状态相关的 Object 方法的合理实现)。
状态描述也是表示的内部承诺允许我们完全派生实现的其余部分。Records 为每个组件获得一个(私有、final)字段,但更重要的是,这些字段与其相应组件之间存在明确的映射,这使我们能够派生规范构造函数和访问器方法实现。
Records 还可以声明 紧凑构造函数,允许我们省略 record 构造函数的样板部分——参数列表和字段赋值——只指定 不能 机械派生的代码。这更简洁、更不容易出错、更易于阅读:
record Rational(int num, int denom) {
Rational {
if (denom == 0)
throw new IllegalArgumentException("denominator cannot be zero");
}
}
这是更显式的简写形式:
record Rational(int num, int denom) {
Rational(int num, int denom) {
if (denom == 0)
throw new IllegalArgumentException("denominator cannot be zero");
this.num = num;
this.denom = denom;
}
}
虽然紧凑构造函数非常简洁,但更重要的好处是,通过消除机械派生的代码,“更有趣"的代码脱颖而出。
展望未来,状态描述是一份不断给予的礼物。这些语义承诺是许多潜在的未来语言和库特性的推动者,用于管理对象生命周期,例如:
- 重构(Reconstruction) record 实例,允许对 record 状态进行受控变更的外观。
- 自动编组和解编组 record 实例。
- 通过具名而非位置方式实例化或解构 record 实例。
重构
JEP 468 提出了一种机制,通过该机制可以从现有 record 实例派生出新的 record 实例,使用让人联想到直接修改的语法,通过 with 表达式:
record Complex(double re, double im) { }
Complex c = ...
Complex cConjugate = c with { im = -im; };
with 右侧的块可以包含任何 Java 语句,不仅仅是赋值。它被增强了 record 的每个组件的可变变量(组件变量),初始化为左侧 record 实例中该组件的值,执行块,并创建一个新的 record 实例,其组件值是组件变量的结束值。
重构表达式隐式使用规范解构模式解构 record 实例,在用组件变量增强的作用域中执行块,然后使用规范构造函数创建新 record。不变量检查集中在规范构造函数中,因此如果新状态无效,重构将失败。JEP 468 已经"搁置"了一段时间,主要是因为我们在等待足够的信心,确信在将其提交给 records 之前,有一条将其扩展到合适类的路径。理想的路径是让这些类也支持规范构造函数和解构模式的概念。
细心的读者会注意到 with 表达式的转换块与紧凑构造函数的主体之间的相似之处。在这两种情况下,块都被"预加载"了一组组件变量,初始化为合适的起始值,块可以根据需要修改这些变量,并且在块正常完成后,这些变量被传递给规范构造函数以产生最终结果。主要区别在于起始值的来源;对于紧凑构造函数,它来自构造函数参数,对于重构表达式,它来自 with 左侧源 record 的规范解构模式。
打破悬崖
Records 做出了强有力的语义承诺,从状态描述中派生其 API 和表示,作为回报从语言中获得了很多帮助。我们现在可以将注意力转向平滑"悬崖”——确定类可以做出的较弱的语义承诺,这些承诺仍然允许类从语言中获得 一些 帮助。理想情况下,你放弃的帮助量将与偏离 record 理想的程度成正比。
对于 records,我们从拥有完整、规范、具名的状态描述中获得了很多好处。record 契约有时过于约束的地方是 实现 契约,即表示与状态描述完全对齐,类是 final 的,字段是 final 的,并且类除了 Record 之外不能扩展任何东西。
我们的路径在这里后退一步,前进一步:保留对状态描述的外部承诺,但放弃状态描述 就是 表示的内部承诺——然后 添加回 一个简单的机制,在可行的情况下将表示组件的字段映射回其相应的组件。(对于 records,因为我们从状态描述派生表示,所以可以安全地推断此映射。)
作为一个思想实验,想象一个对状态描述做出外部承诺的类——状态描述是其状态的完整、规范、具名的描述——但需要自己提供其表示。我们能为这样的类做什么?实际上很多。出于所有与 records 相同的原因,我们可以派生规范构造函数和组件访问器方法的 API 要求。从那里,我们可以派生规范解构模式的要求,以及解构模式的实现(因为它是根据访问器方法实现的)。由于状态描述是完整的,我们还可以根据访问器方法派生 Object 方法 equals、hashCode 和 toString 的合理默认实现。鉴于存在规范构造函数和解构模式,它还可以参与重构。作者只需提供字段、访问器方法和规范构造函数。这是一个很好的进展,但我们想做得更好。
使我们能够为 records 派生实现的其余部分(字段、构造函数、访问器方法和 Object 方法)的是表示如何映射到状态描述的知识。Records 承诺其状态描述 就是 表示,因此从那里到完整实现只是一小步。
为了使这更具体,让我们看一个典型的"几乎是 record"的类,一个状态描述为 (int x, int y, Optional<String> s) 的载体,但它做出了表示选择,内部将 s 存储为可空的 String。
class AlmostRecord {
private final int x;
private final int y;
private final String s; // *
public AlmostRecord(int x, int y, Optional<String> s) {
this.x = x;
this.y = y;
this.s = s.orElse(null); // *
}
public int x() { return x; }
public int y() { return y; }
public Optional<String> s() {
return Optional.ofNullable(s); // *
}
public boolean equals(Object other) { ... } // 从 x()、y()、s() 派生
public int hashCode() { ... } // "
public String toString() { ... } // "
}
这个类与其 record 类似物展开之间的主要区别是标有 * 的行;这些是处理状态描述和实际表示之间差异的行。如果这个类的作者 只需 编写与我们可以为 record 派生的代码不同的代码,那就太好了;这不仅会非常简洁,而且意味着 存在 的所有代码都是为了捕获其表示与其 API 之间的差异。
Carrier 类
Carrier 类 是使用状态描述声明的普通类。与 record 一样,状态描述是类状态的完整、规范、具名的描述。作为回报,语言派生与 records 相同的 API 约束:规范构造函数、规范解构模式和组件访问器方法。
class Point(int x, int y) { // class,不是 record!
// 显式声明的表示...
// 必须有一个接受 (int x, int y) 的构造函数
// 必须有 x 和 y 的访问器
// 支持产生 (int x, int y) 的解构模式
}
与 record 不同,语言不对对象的表示做任何假设;类作者必须像任何其他类一样声明它。
说状态描述是"完整的"意味着它携带类的所有"重要"状态——如果我们提取此状态并重新创建对象,应该产生"等效"的实例。与 records 一样,这可以通过将构造、访问器和相等性的行为联系在一起来捕获:
Point p = ...
Point q = new Point(p.x(), p.y());
assert p.equals(q);
我们还可以从到目前为止我们拥有的信息中派生 一些 实现;我们可以派生 Object 方法的合理实现(根据组件访问器方法实现)并且我们可以派生规范解构模式(同样根据组件访问器方法)。从那里,我们可以派生对重构(with 表达式)的支持。不幸的是,我们(还)不能派生大部分与状态相关的实现:规范构造函数和组件访问器方法。
组件字段和访问器方法
数据持有类最繁琐的方面之一是访问器方法;它们通常有很多,而且几乎总是纯样板。即使 IDE 可以通过为我们生成这些来减少编写负担,读者仍然必须费力地阅读大量低信息代码——只是为了了解他们实际上根本不需要费力地阅读该代码。我们可以为 records 派生访问器方法的实现,因为 records 做出了内部承诺,即所有组件都由单个字段支持,其名称和类型与状态描述对齐。
对于 carrier 类,我们不知道 任何 组件是否由与组件的名称或类型对齐的单个字段直接支持。但这是一个相当好的赌注,许多 carrier 类组件至少对其 一些 字段会这样做。如果我们能告诉语言这种对应关系不仅仅是巧合,语言就可以为我们做更多事情。
我们通过允许 carrier 类的合适字段被声明为 component 字段来实现这一点。(像往常一样,在这个阶段,语法是临时的,但目前不是讨论的话题。)组件字段必须与当前类的组件具有相同的名称和类型(尽管它不必像 record 字段那样是 private 或 final)。这表明此字段 是 相应组件的表示,因此我们也可以派生此组件的访问器方法。
class Point(int x, int y) {
private /* mutable */ component int x;
private /* mutable */ component int y;
// 必须有规范构造函数,但(到目前为止)必须显式
public Point(int x, int y) {
this.x = x;
this.y = y;
}
// 派生的 x 和 y 访问器实现
// 派生的 equals、hashCode、toString 实现
}
这越来越好了;类作者必须提供表示以及从表示到组件的映射(以 component 修饰符的形式)和规范构造函数。
紧凑构造函数
正如我们能够在给定字段和组件之间的显式对应关系的情况下派生访问器方法实现一样,我们可以对构造函数做同样的事情。为此,我们构建在为 records 引入的 紧凑构造函数 概念之上。
与 record 一样,carrier 类中的紧凑构造函数是规范构造函数的简写,它具有与状态描述相同的形状,但被免除了实际将组件参数的结束值提交到字段的责任。主要区别在于,对于 record,所有 组件都由组件字段支持,而对于 carrier 类,只有其中一些可能由组件字段支持。但我们可以通过免除作者初始化 组件 字段的责任来推广紧凑构造函数,同时让他们负责初始化其余字段。在所有组件都由组件字段支持且构造函数中不需要其他逻辑的极限情况下,可以省略紧凑构造函数。
对于我们的可变 Point 类,这意味着我们几乎可以省略所有内容,除了字段声明本身:
class Point(int x, int y) {
private /* mutable */ component int x;
private /* mutable */ component int y;
// 派生的紧凑构造函数
// 派生的 x、y 访问器
// 派生的 equals、hashCode、toString 实现
}
我们可以认为这个类有一个隐式的空紧凑构造函数,这反过来意味着组件字段 x 和 y 从其相应的构造函数参数初始化。还为每个组件隐式派生了访问器方法,以及基于状态描述的 Object 方法实现。
对于所有组件都由字段支持的类来说,这很好,但是我们的 AlmostRecord 类呢?这里的情况也很好;我们可以为由组件字段支持的组件派生访问器方法,并且我们可以从紧凑构造函数中省略组件字段的初始化,这意味着我们 只需 指定偏离"record 理想"的部分的代码:
class AlmostRecord(int x,
int y,
Optional<String> s) {
private final component int x;
private final component int y;
private final String s;
public AlmostRecord {
this.s = s.orElse(null);
// x 和 y 字段隐式初始化
}
public Optional<String> s() {
return Optional.ofNullable(s);
}
// 派生的 x 和 y 访问器实现
// 派生的 equals、hashCode、toString 实现
}
因为如此多的现实世界几乎 records 在很小的方面与其 record 理想不同,我们期望对大多数 carrier 类获得显著的简洁性好处,就像我们对 AlmostRecord 所做的那样。与 records 一样,如果我们想显式实现构造函数、访问器方法或 Object 方法,我们仍然可以自由地这样做。
派生状态
关于 records 最常见的抱怨之一是无法从组件派生状态并将其缓存以进行快速检索。使用 carrier 类,这很简单:为派生量声明一个非组件字段,在构造函数中初始化它,并提供访问器:
class Point(int x, int y) {
private final component int x;
private final component int y;
private final double norm;
Point {
norm = Math.hypot(x, y);
}
public double norm() { return norm; }
// 派生的 x 和 y 访问器实现
// 派生的 equals、hashCode、toString 实现
}
解构和重构
与 records 一样,carrier 类自动获得与规范构造函数匹配的解构模式,因此我们可以像 record 一样解构我们的 Point 类:
case Point(var x, var y):
因为重构(with)派生自规范构造函数和相应的解构模式,当我们支持 records 的重构时,我们也将能够对 carrier 类这样做:
point = point with { x = 3; }
Carrier 接口
状态描述在接口上也有意义。它声明状态描述是接口状态的完整、规范、具名的描述(子类允许添加额外的状态),因此,实现必须为组件提供访问器方法。这使得此类接口能够参与模式匹配:
interface Pair<T,U>(T first, U second) {
// first() 和 second() 的隐式抽象访问器
}
...
if (o instanceof Pair(var a, var b)) { ... }
随着即将推出的 foreach 循环头中模式赋值的特性,如果 Map.Entry 成为 carrier 接口(它将会),我们将能够像这样迭代 Map:
for (Map.Entry(var key, var val) : map.entrySet()) { ... }
库中的一个常见模式是导出一个密封到单个私有实现的接口。在这种模式中,接口和实现可以共享一个公共状态描述:
public sealed interface Pair<T,U>(T first, U second) { }
private record PairImpl<T, U>(T first, U second) implements Pair<T, U> { }
与旧的方式相比,我们获得了增强的语义、更好的类型检查和更高的简洁性。
扩展
Carrier 类作者的主要义务是确保基本声明——状态描述是对象状态的完整、规范、具名的描述——实际上是真实的。这并不排除将 carrier 类的表示分散在层次结构中,因此与 records 不同,carrier 类不需要是 final 或具体的,也不限制其扩展。
当 carrier 类可以参与扩展时,会出现几种情况:
- Carrier 类扩展非 carrier 类;
- 非 carrier 类扩展 carrier 类;
- Carrier 类扩展另一个 carrier 类,其中所有超类组件都被子类状态描述包含;
- Carrier 类扩展另一个 carrier 类,但有一个或多个超类组件不被子类状态描述包含。
使用 carrier 类扩展非 carrier 类通常是出于在现有层次结构周围"包装"状态描述的愿望,我们无法或不想直接修改,但我们希望获得解构和重构的好处。这样的实现必须确保类实际上符合状态描述,并且规范构造函数和组件访问器得到实现。
当一个 carrier 类扩展另一个 carrier 类时,更直接的情况是它只是向超类的状态描述添加新组件。例如,给定我们的 Point 类:
class Point(int x, int y) {
component int x;
component int y;
// 其他一切都是免费的!
}
我们可以将其用作 3D 点类的基类:
class Point3d(int x, int y, int z) extends Point {
component int z;
Point3d {
super(x, y);
}
}
在这种情况下——因为超类组件都是子类状态描述的一部分——我们实际上也可以省略构造函数,因为我们可以派生子类组件和超类组件之间的关联,从而派生所需的超构造函数调用。所以我们实际上可以写:
class Point3d(int x, int y, int z) extends Point {
component int z;
// 其他一切都是免费的!
}
有人可能会认为我们需要在 Point3d 的 x 和 y 组件上做一些标记,以表明它们映射到 Point 的相应组件,就像我们为关联组件字段与其相应组件所做的那样。但在这种情况下,我们不需要这样的标记,因为 Point 的 int x 组件和其子类的 int x 组件不可能指代不同的东西——因为它们都绑定到相同的 int x() 访问器方法。因此,我们可以安全地推断哪些子类组件由超类管理,只需匹配它们的名称和类型。
在另一种 carrier 到 carrier 的扩展情况中,其中一个或多个超类组件 不 被子类状态描述包含时,需要在子类构造函数中提供显式的 super 构造函数调用。
Carrier 类也可以声明为 abstract;其主要作用是我们不会派生 Object 方法实现,而是让子类来做。
抽象 Records
这个框架还为我们提供了放松 records 限制之一的机会:records 不能扩展除 java.lang.Record 之外的任何东西。我们还可以允许 records 被声明为 abstract,并允许 records 扩展抽象 records。
与扩展其他 carrier 类的 carrier 类一样,有两种情况:当超类的组件列表完全包含在子类中时,以及当一个或多个超类组件从子类组件派生(或是常量)但本身不是子类的组件时。与 carrier 类一样,主要区别在于子类构造函数中是否需要显式的 super 调用。
当 record 扩展抽象 record 时,子类中也是超类组件的任何组件在子类中不会隐式获得组件字段(因为它们已经在超类中),并且它们从超类继承访问器方法。
Records 也是 Carriers
有了这个框架,records 现在可以被看作"只是"隐式为 final 的 carrier 类,扩展 java.lang.Record,隐式为每个组件拥有私有 final 组件字段,并且不能有其他字段。
迁移兼容性
肯定会有一些现有类想要成为 carrier 类。只要强制成员与类的现有成员没有冲突,并且类遵守状态描述是对象状态的完整、规范和具名描述的要求,这就是一个兼容的迁移。
Records 和 Carrier 类的兼容演化
到目前为止,库一直不愿意在公共 API 中使用 records,因为难以兼容地演化它们。对于一个 record:
record R(A a, B b) { }
想要通过添加新组件来演化:
record R(A a, B b, C c, D d) { }
我们有几个兼容性挑战需要管理。只要我们只是添加而不是删除/重命名,访问器方法调用将继续工作。现有的构造函数调用可以通过显式添加回具有旧形状的构造函数来继续工作:
record R(A a, B b, C c, D d) {
// 旧形状所需的显式构造函数
public R(A a, B b) {
this(a, b, DEFAULT_C, DEFAULT_D);
}
}
但是,我们对现有的 record 模式 使用能做什么?虽然 record 模式的转换会使添加组件在二进制上兼容,但它在源代码上不兼容,并且没有办法像我们对构造函数所做的那样显式添加旧形状的解构模式。
我们可以利用 只有 规范解构模式所提供的简化,并允许使用解构模式为组件列表的任何 前缀 提供嵌套模式。因此,对于演化后的 record R:
case R(P1, P2)
将被解释为:
case R(P1, P2, _, _)
其中 _ 是匹配所有的模式。这意味着可以通过仅在末尾添加新组件来兼容地演化 record,并添加合适的构造函数以与现有构造函数调用兼容。