Java 14 特性聚焦:记录

150 阅读16分钟

关键要点

  • Java SE 14(2020 年 3 月)引入了记录作为预览功能。记录旨在增强语言以较少仪式对“纯数据”聚合进行建模的能力。
  • 记录最好被认为是一个名义元组;它是一个透明的、浅不可变的载体,用于特定有序的元素序列。
  • 记录可在多种情况下用于对常见用例建模,例如多重返回、流连接、复合键、树节点、DTO 等,并提供更强的语义保证,使开发人员和框架能够更可靠地推理他们的状态。
  • 与枚举一样,记录与类相比也有一些限制,因此不会取代所有数据载体类。具体来说,它们不打算作为可变 JavaBean 类的替代品。
  • 有资格成为记录的现有类可以兼容地迁移到记录。

预览功能

鉴于 Java 平台的全球影响力和高度兼容性承诺,语言功能设计错误的成本非常高。在语言错误功能的上下文中,对兼容性的承诺不仅意味着很难删除或显着更改该功能,而且现有功能还限制了未来功能可以做什么——今天闪亮的新功能是明天的兼容性限制。

语言特性的最终试验场是实际使用;来自在真实代码库上实际试用过的开发人员的反馈对于确保该功能按预期工作至关重要。当 Java 有多年的发布周期时,有足够的时间进行实验和反馈。为了确保在较新的快速发布节奏下有足够的时间进行实验和反馈,新的语言功能将经过一轮或多轮预览,它们是平台的一部分,但必须单独选择加入,并且尚未永久 - - 因此,如果需要根据开发人员的反馈对其进行调整,则可以在不破坏关键任务代码的情况下进行调整。

在QCon New York 的Java Futures中,Java 语言架构师 Brian Goetz 带我们旋风式地参观了 Java 语言的一些近期和未来特性。在本系列的第一篇文章中,他研究了局部变量类型推断。在本文中,他深入研究了 Records。

Java SE 14(2020 年 3 月)引入了 记录 ( jep359 ) 作为预览功能。记录旨在增强语言以较少仪式对“纯数据”聚合进行建模的能力。我们可以声明一个简单的 xy 点抽象如下:

record Point(int x, int y) { }

它将声明一个名为Point的最终类,其中包含xy 的不可变组件以及适当的访问器、构造函数、equalshashCodetoString实现。

我们都熟悉替代方法——编写(或让 IDE 生成)构造函数、对象方法和访问器的样板填充实现。

这些写起来肯定很麻烦,但更重要的是,它们读起来费劲;我们必须通读所有样板代码才能得出结论,我们实际上根本不需要阅读它。

什么是记录?

记录最好被认为是名义元组;它是一个透明的、浅不可变的载体,用于特定有序的元素序列。状态元素的名称和类型在记录头中声明,称为状态描述。Nominal 意味着聚合及其组件都有名称,而不仅仅是索引;透明意味着客户端可以访问状态(尽管实现可能会调解这种访问);浅不可变意味着记录表示的值的元组一旦实例化就不会改变(但是,如果这些值是对可变对象的引用,则被引用对象的状态可能会改变)。

记录,就像枚举一样,是一种限制形式的类,针对某些常见情况进行了优化。枚举为我们提供了各种讨价还价的机会;我们放弃了对实例化的控制,作为回报,我们获得了某些句法和语义上的好处。然后,我们可以根据枚举的好处是否超过当前特定情况下的成本,自由选择枚举或常规类。

唱片为我们提供了类似的优惠;他们要求我们放弃的是将 API 与表示解耦的能力,这反过来又允许语言从状态描述中机械地派生 API 和用于构造、状态访问、相等比较和表示的实现。

将 API 绑定到表示似乎与基本的面向对象原则冲突:封装。虽然封装是管理复杂性的基本技术,而且大多数时候它是正确的选择,但有时我们的抽象非常简单——例如 xy 点——以至于封装的成本超过了收益。其中一些成本是显而易见的——例如编写一个简单的域类所需的样板文件。但是还有另一个不太明显的成本:API 元素之间的关系不是由语言捕获的,而是仅由约定捕获的。这削弱了对抽象进行机械推理的能力,从而导致更多样板编码。

从历史上看,在 Java 中使用数据对象进行编程需要信心的飞跃。我们都熟悉以下用于建模可变数据载体的技术:

class AnInt {
    private int val;

    public AnInt(int val) { this.val = val; }

    public int getVal() { return val; }

    public void setVal(int val) { this.val = val; }

    // More boilerplate for equals, hashCode, toString
}

名称val出现在公共 API 的三个位置 - 构造函数参数和两个访问器方法。除了命名约定之外,这段代码中没有任何内容来捕获或要求val 的这三种用法在谈论同一件事,或者getVal()将返回由setVal()设置的最新值——充其量,这被捕获在人类可读的规范中(但实际上,我们几乎从未这样做过。)与这样的类进行交互纯粹是一种信仰的飞跃。

另一方面,记录做出了更强的承诺——x()访问器和x构造函数参数谈论的是相同的数量。因此,编译器不仅能够推导出这些成员的合理默认实现,而且框架可以机械地推理构造和状态访问协议 - 以及它们的交互 - 以机械地推导出诸如编组到 JSON 或 XML 之类的行为。

精美的印刷品

如前所述,记录有一些限制。它们的实例字段(对应于记录头中声明的组件)是隐式最终的;它们不能有任何其他实例字段;记录类本身不能扩展其他类;和记录类是隐式最终的。除此之外,它们几乎可以拥有其他类可以拥有的一切:构造函数、方法、静态字段、类型变量、接口等。

作为这些限制的交换,记录自动获取规范构造函数的隐式实现(签名与状态描述匹配的构造函数)、每个状态组件的读取访问器(名称与组件相同)、每个状态组件的私有 final 字段,以及Object方法equals()hashCode()toString()的基于状态的实现。(以后Java语言支持解构模式的时候,记录也将自动支持解构模式。)如果证明不合适,记录的声明可以“覆盖”隐式构造函数和方法声明(尽管存在约束,在隐式超类java.lang.Record 中指定,此类实现必须遵守),并且可以声明额外的成员(受限制)。

记录可能希望优化构造函数实现的一个示例是验证构造函数中的状态。例如,在 Range 类中,我们要检查范围的低端是否不高于高端:

public record Range(int lo, int hi) {
    public Range(int lo, int hi) {
        if (lo > hi)
            throw new IllegalArgumentException(String.format("%d, %d", lo, hi));
        this.lo = lo;
        this.hi = hi;
    }
}

虽然这个实现非常好,但有点遗憾的是,为了执行简单的不变性检查,我们不得不再说出组件的名称五次。人们可以很容易地想象开发人员说服自己他们不需要检查这些不变量,因为他们不想添加太多他们刚刚通过使用记录保存的样板文件。

因为这种情况很常见,而且有效性检查非常重要,所以记录允许使用一种特殊的紧凑形式来显式声明规范构造函数。在这种形式中,参数列表可以完全省略(假设与状态描述相同),并且构造函数参数隐式提交到构造函数末尾的记录字段。(构造函数参数本身是可变的,这意味着想要规范化状态的构造函数——例如将有理数降为最低项——可以仅仅通过改变构造函数参数来做到这一点。)以下是上述记录声明的紧凑版本:

public record Range(int lo, int hi) {
    public Range {
        if (lo > hi)
            throw new IllegalArgumentException(String.format("%d, %d", lo, hi));
    }
}

这导致了一个令人满意的结果:我们唯一需要阅读的代码是不能从状态描述中机械推导出来的代码。

示例用例

虽然并非所有类——甚至不是所有以数据为中心的类——都有资格成为记录,但记录的用例比比皆是。

Java 的一个普遍要求的特性是多次返回——允许一个方法一次返回多个项目;无法做到这一点通常会导致我们暴露出次优的 API。考虑一对扫描集合并返回最小值或最大值的方法:

static<T> T min(Iterable<? extends T> elements,
                Comparator<? super T> comparator) { ... }

static<T> T max(Iterable<? extends T> elements,
                Comparator<? super T> comparator) { ... }

这些方法写起来很容易,但也有不尽如人意的地方;要获得两个边界值,我们必须扫描列表两次。这比扫描一次效率低,如果可以同时修改正在扫描的集合,则可能会导致不一致的值。

虽然这可能是作者想要公开的 API,但更有可能这是我们得到的 API,因为编写更好的 API 工作量太大。具体来说,一次返回两个边界值意味着我们需要一种同时返回两个值的方法。我们当然可以通过声明一个类来做到这一点,但大多数开发人员会立即寻找避免这样做的方法——纯粹是因为声明辅助类的语法开销。通过降低描述自定义聚合的成本,我们可以轻松地将其转换为我们最初可能想要的 API:

record MinMax<T>(T min, T max) { }

static<T> MinMax<T> minMax(Iterable<? extends T> elements,
                           Comparator<? super T> comparator) { ... }

另一个常见的例子是复合映射键。有时我们希望Map以两个不同值的连接为键,例如表示给定用户上次使用某个功能的时间。我们可以使用HashMap轻松地做到这一点,它的键结合了人物和特征。但是如果没有方便的PersonAndFeature类型,我们必须编写一个,包含构造、相等比较、散列等的所有样板细节。 同样,我们可以这样做,但我们的懒惰很可能会妨碍我们例如,试图通过将人名与特征名称连接来键入我们的地图,这会导致更难阅读、更容易出错的代码。记录让我们直接做到这一点:

record PersonAndFeature(Person p, Feature f) { }
Map<PersonAndFeature, LocalDateTime> lastUsed = new HashMap<>();

使用复合材料的愿望通常会出现在流中,就像它们可能会出现在映射键上一样——我们遇到了同样的偶然问题,这些问题使我们达到了次优解决方案。例如,假设我们要对导出的数量执行流操作,例如对得分最高的玩家进行排名。我们可以这样写:

List<Player> topN
        = players.stream()
             .sorted(Comparator.comparingInt(p -> getScore(p)))
             .limit(N)
             .collect(toList());

这很容易,但是如果找到分数需要一些计算呢?然后我们将计算分数O(n^2)次,而不是O(n)。通过记录,我们可以轻松地将一些派生数据临时附加到流的内容中,对连接的数据进行操作,然后将其投影回我们想要的内容:

record PlayerScore(Player player, Score score) {
    // convenience constructor for use by Stream::map
    PlayerScore(Player player) { this(player, getScore(player)); }
}

List<Player> topN
    = players.stream()
             .map(PlayerScore::new)
             .sorted(Comparator.comparingInt(PlayerScore::score))
             .limit(N)
             .map(PlayerScore::player)
             .collect(toList());

如果此逻辑在方法内部,则甚至可以将记录声明为该方法的本地。

当然,记录还有很多其他明显的情况:树节点、数据传输对象 (DTO)、actor 系统中的消息等。

拥抱我们的懒惰

到目前为止,示例中的一个共同主题是,无需记录也可以获得正确的结果,但由于语法开销,我们很可能会偷工减料。我们都试图不正确地重用现有的抽象,而不是编写正确的抽象,或者通过省略Object方法的实现来偷工减料(当这些对象用作映射键时,这可能会导致细微的错误,或者在以下情况下使调试更加困难)toString()值没有帮助)。

用简洁的符号来表达我们想要的东西会给我们带来两种好处。显而易见的是,已经在做正确事情的代码受益于简洁,但更微妙的是,这也意味着我们将获得更多做正确事情的代码——因为我们降低了做正确事情的激活能量,因此减少了偷工减料的诱惑。我们在局部变量类型推断中看到了类似的效果;当声明变量的开销减少时,开发人员更有可能将复杂的计算分解为更简单的计算,从而产生更易读、更不容易出错的代码。

未选择的道路

每个人都同意在 Java 中对数据聚合进行建模——我们经常这样做——有太多的仪式。不幸的是,这种共识只是语法深层次的;关于记录应该有多灵活、可以合理接受哪些限制以及哪些用例最重要,意见分歧很大(而且声音很大)。

主要的道路是尝试扩展记录以替换可变的 JavaBean 类。虽然这会带来明显的好处——具体来说,就是扩大可以成为记录的类的数量——但额外的成本也将是巨大的。复杂的、特别的特性更难推理,并且更有可能以令人惊讶的方式与其他特性交互——如果我们试图从当今常用的各种 JavaBean 模式中推导出特性设计,我们就会得到这样的结果(更不用说关于哪些用例足够普遍值得语言支持的争论)。

因此,虽然表面上认为记录主要是关于减少样板文件的,但我们更愿意将其作为语义问题来处理;我们如何才能更好地直接在语言中对数据聚合进行建模,并为开发人员可以轻松推理的此类类提供可靠的语义基础?(将其视为语义而不是句法问题的方法对于枚举非常有效。)Java 的逻辑答案是:记录是名义元组。

为什么有限制?

对记录的限制乍一看似乎是随意的,但它们都源于一个共同的目标,我们可以将其概括为“记录就是状态,整个状态,除了状态什么也没有”。具体来说,我们想要平等从状态描述中声明的整个状态派生的记录,没有别的。如果允许可变字段、额外字段或超类,这些都会引入记录的相等性或者忽略某些状态组件(在相等性计算中包含可变组件是有问题的),或者依赖于不属于状态描述(例如附加实例字段或超类状态)。这将使该功能变得非常复杂(因为开发人员肯定会要求能够单独指定哪些组件是等式计算的一部分),并且还会破坏理想的语义不变量(例如:提取状态并从结果值中构建新记录应该导致记录等于原始记录)。

为什么不是结构元组?

鉴于记录的设计中心是名义元组,有人可能会问为什么我们不选择结构元组。在这里,答案很简单:名字很重要。包含firstNamelastName组件的Person记录比StringString的元组更清晰、更安全。类通过其构造函数支持状态验证;元组没有。类可以从它们的状态派生出额外的行为;元组不能。合适的类可以兼容地迁移到记录和从记录迁移,而不会破坏它们的客户端代码;元组不能。并且结构元组不区分Point和`Range`````````````(两者都是整数对),即使两者具有完全不同的语义。(我们之前在 Java 中曾面临在名义和结构表示之间的选择;在 Java 8 中,出于多种原因,我们选择名义函数类型而不是结构类型;在选择名义元组而不是结构元组时,许多相同的原因也适用。)

期货

JEP 355 将记录指定为一个独立的特性,但记录的设计受到希望记录与当前正在开发的其他几个特性良好配合的愿望的影响:密封类型、模式匹配和内联类。

记录是乘积类型的一种形式,之所以这么称呼是因为它们的状态空间是其组件状态空间的笛卡尔积(的子集),并且构成了通常称为代数数据类型的一半。另一半称为和类型;sum 类型是可区分的联合,例如“Shape is a Circle or a Rectangle”;这不是我们目前可以在 Java 中表达的东西(除非通过诸如非公共构造函数之类的技巧)。密封型将解决这个限制,即类和接口可以直接声明它们只能由一组固定的类型扩展。乘积总和是一种非常常见且有用的技术,用于以灵活但类型安全的方式对复杂域进行建模,例如复杂文档的节点。

具有产品类型的语言通常支持使用模式匹配对产品进行解构;记录从头开始设计以支持简单的解构(记录的透明度要求部分源于此目标)。模式匹配的第一阶段仅支持类型模式,但很快就会出现记录上的解构模式。

最后,记录通常(但不总是)与内联类型匹配;满足记录和内联类型要求的聚合(其中许多会)可以将记录性和内联性合并为内联记录

概括

通过提供将数据建模为数据的直接方式,而不是用类模拟数据,记录减少了许多常见类的冗长。记录可在多种情况下用于对常见用例建模,例如多重返回、流连接、复合键、树节点、DTO 等,并提供更强的语义保证,使开发人员和框架能够更可靠地推理他们的状态。虽然记录本身很有用,但它们也会与一些即将推出的功能积极交互,包括密封类型、模式匹配和内联类。