首页
学习
活动
专区
工具
TVP
发布
精选内容/技术社群/优惠产品,尽在小程序
立即前往

Java 14 特性专题报道:记录

本文要点

  • Java SE 14(2020年3月)引入记录(record)(JEP359)作为预览特性。记录的目的是增强语言能力,简化“纯数据”聚合建模。
  • 可以将记录看作一个命名元组;它是一个面向特定有序元素序列的透明的浅不可变载体。
  • 记录可以在各种情况下用于常见用例的建模,比如多返回值、流连接、组合键、树节点、DTO等等,并提供更强的语义保证,使开发人员和框架可以更可靠地推断它们的状态。
  • 与枚举类似,记录与类相比也有一些限制,因此不会替代所有的数据载体类。具体来说,它们的目的不是替代可变的JavaBean类。
  • 在保证兼容性的前提下,可以将现有的符合条件的类迁移到记录。

在QCon纽约大会题为Java Future的演讲中,Java语言架构师Brian Goetz带我们快速浏览了Java语言的一些近期和未来特性。在本系列的第一篇文章中,他探讨了局部变量类型推断。在这篇文章中,他深入讨论了记录。

Java SE 14(2020年3月)引入记录(Record)(JEP359)作为预览特性。记录的目的是增强语言能力,简化“纯数据”聚合建模。我们可以像下面这样声明一个简单的x-y点抽象,如下所示:

代码语言:javascript
复制
record Point(int x, int y) { }

它声明一个final类Point,其中包含不可变组件x和y以及适当的访问器、构造函数、equals、hashCode和toString实现。 我们都熟悉另一种方法——编写(或使用IDE生成)构造函数、对象方法和访问器的样板文件来填充实现。

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

记录是什么?

可以将记录看作一个命名元组(nominal tuple)。它是一个面向特定有序元素序列的透明的浅不可变载体。状态元素的名称和类型在记录头中声明,称为状态描述。命名意味着聚合及其组件都有名称,而不仅仅是索引;透明意味着客户端可访问状态(尽管实现可能需要为这种访问提供中介);浅不可变意味着记录所表示的值的元组在实例化后不会改变(但是,如果这些值是对可变对象的引用,那么被引用对象的状态可能会改变)。

像枚举一样,记录是类的一种受限形式,针对某些常见情况进行了优化。枚举为我们提供了各种各样的便利;我们放弃了对实例化的控制,作为回报,我们获得了某些语法和语义上的好处。然后,对于特定的情况,我们可以根据枚举的收益是否大于成本来自由地选择枚举或普通的类。

记录为我们提供了一个类似的交易;它们要求我们放弃的是将API与表示解耦的能力,这反过来又允许该语言从状态描述机械地派生出用于构造、状态访问、相等比较和表示的API和实现。

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

从历史上看,在Java中使用数据对象编程需要一个大的转变。我们都熟悉下面的可变数据载体建模技术:

代码语言:javascript
复制
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; }
    // 针对equals, hashCode, toString方法的更多样板代码
}

在这个公共API中,val出现了三次——构造函数参数和两个访问器方法。除了命名约定,这段代码就没有其他东西了,要表达或要求这三个val都是指代同一个东西,或者getVal()将返回最近由setVal()设置的值——最好是在人类可读的规范中表达(但在现实中,我们几乎从来没有这样做)。与这样的类交互需要一个大的转变。

另一方面,记录做出了更大的承诺——x()访问器和x构造函数的参数指代的是同一个数量。因此,不仅编译器能够派生出这些成员的合理的默认实现,框架也可以机械地推断出构造和状态访问协议(及其交互),从而机械地派生出行为,比如编组成JSON或XML。

要点

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

作为这些限制的交换,记录会自动获得规范化构造函数的隐式实现(其签名与状态描述相匹配)、针对每个组件的读取访问器(名称和组件相同)、每个状态组件的私有final字段以及基于状态的Object方法equals()、hashCode()和toString ()的实现。(将来,当Java语言支持解构模式时,记录也会自动支持该模式。)如果隐式构造函数和方法声明不合适,记录的声明可以“重写”它们(尽管必须遵循隐式超类java.lang.Record中指定的约束),并可以声明其他成员(受约束条件限制)。

一个例子是,记录可能希望改进构造函数实现,验证构造函数中的状态。例如,在一个Range类中,我们想要检查范围的下限是否大于上限:

代码语言:javascript
复制
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;
    }
}

虽然这个实现非常好,但有些遗憾的是,为了执行一个简单的不变量检查,我们不得不把组件的名称多用了五次。人们很容易就会想,开发人员说服自己不检查这些不变量,因为他们不想添加太多记录刚帮他们节省的样板代码。

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

代码语言:javascript
复制
public record Range(int lo, int hi) {
    public Range {
        if (lo > hi)
            throw new IllegalArgumentException(String.format("%d, %d", lo, hi));
    }
}

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

应用场景举例

虽然不是所有的类——甚至不是所有以数据为中心的类——都可以变成为记录,但是记录的应用场景很多。

Java经常需要的一个特性是多返回值——允许一个方法一次返回多个项;因为无法做到这一点,我们常常只能暴露次优的API。考虑一下,有两个方法扫描一个集合并返回最小或最大值:

代码语言:javascript
复制
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:

代码语言:javascript
复制
record MinMax<T>(T min, T max) { }
static<T> MinMax<T> minMax(Iterable<? extends T> elements,
                           Comparator<? super T> comparator) { ... }

另一个常见的例子是组合映射键。有时,我们希望Map以两个不同值的组合为键,例如表示给定用户最后一次使用某个特性的时间。我们很容易通过HashMap实现这一点,它的键组合了人员和特性。但是,如果没有一个方便的PersonAndFeature类型供我们使用,我们就必须编写一个,包含所有构造、相等比较、散列等样板代码细节。同样,我们可以做到这一点,但是我们的懒惰可能会成为障碍,例如,我们可能会被诱惑,将人的名字与特性的名字连接起来设置映射的键,这将导致更难于阅读、更容易出错的代码。记录让我们可以直接这样做:

代码语言:javascript
复制
record PersonAndFeature(Person p, Feature f) { }
Map<PersonAndFeature, LocalDateTime> lastUsed = new HashMap<>();

流处理通常会希望使用组合,就像映射键一样——我们会遇到相同的意外问题,使我们实现次优的解决方案。例如,假设我们希望对派生量执行流操作,例如对得分最高的玩家进行排名。我们可以这样写:

代码语言:javascript
复制
List<Player> topN
        = players.stream()
             .sorted(Comparator.comparingInt(p -> getScore(p)))
             .limit(N)
             .collect(toList());

这够简单了,但是如果找到分数需要一些计算呢?我们需要计算O(n^2)次,而不是O(n)次。有了记录,我们很容易临时将一些派生数据附加到流的内容上,对联合数据进行操作,然后将其投射成我们想要的东西:

代码语言:javascript
复制
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系统中的消息等。

拥抱我们的懒惰

到目前为止,这些示例中的一个共同主题是,不需要记录也可以得到正确的结果,但是由于语法开销,我们很可能会走捷径。我们都想不恰当地重用现有的抽象,而不是编写正确的抽象代码,或偷工减料省略对象方法的实现(当这些对象被用作映射键时可能导致微妙的错误,或当toString()值不能提供帮助时,调试变得更加困难)。

简单来说,我们想要的东西给我们带来了两个好处。最明显的一个是,受益于简洁,代码已经做了正确的事,但更准确地说,这也意味着我们将得到更多做正确的事的代码——因为我们降低了做正确的事所需的活化能,因此减少了偷工减料的诱惑。我们在局部变量类型推断中看到了类似的效果;当声明变量的开销减少时,开发人员更有可能将复杂的计算分解为更简单的计算,从而得到可读性更好、出错更少的代码。

未选之路

每个人都同意在Java中建模数据聚合——我们经常这么做——太繁琐。不幸的是,这种共识只是语法层面的;关于记录应该有多大的灵活性、哪些限制是可以接受的、哪些用例是最重要的,意见分歧很广泛(而且很大)。

一条重要的未选之路是设法扩展记录来替换可变的JavaBean类。虽然这会带来明显的好处——具体地说,可以增加可能成为记录的类的数目——但额外的成本也会很高。复杂,难以推断的特别功能,更有可能以令人惊讶的方式与其他特性进行交互——如果我们试图从如今常用的各种JavaBean模式推导出特性设计,这就是我们会得到的(更不用提关于哪些用例足够普遍值得语言支持这样的争论了)。

因此,虽然从表面上看很容易认为记录主要是关于样板代码简化,但我们更愿意把它当作一个语义问题来处理;我们如何才能直接在语言中更好地建模聚合模型,并为开发人员能够轻松推断这样的类提供良好的语义基础?(将其视为语义问题而非语法问题的方法对枚举非常有效。)对于Java而言,符合逻辑的答案是:记录是命名元组。

为什么有这样的限制?

对记录的限制乍一看似乎有些武断,但它们都源于一个共同的目标,我们可以将其概括为“记录是状态,整个状态,除了状态什么都不是”。具体来说,我们希望记录的相等性来自于状态描述中声明的整个状态,而不是其他。可变字段,或额外的字段,或者父类允许的字段,所有这些都会带来一些情况,使得记录相等性的判断忽略某些状态组件(在相等计算中包含可变组件会有问题),或依赖于不属于状态描述组成部分的其他状态(如额外的实例字段或超类状态)。这将使这个特性大大复杂化(因为开发人员肯定会需要具体指定哪些组件是相等计算的一部分),并破坏期望的语义不变量(如从结果值提取状态并构造一个新记录会获得一个与原来相等的记录)。

为什么不是结构化元组?

考虑到记录设计的中心是命名元组,人们可能会问为什么我们没有选择结构化元组。答案很简单:名字很重要。带有firstName和lastName组件的Person记录比String和String组成的元组更清楚、更安全。类通过其构造函数支持状态验证;元组不能。类可以从其状态派生出其他行为;元组不能。合适的类可以在不破坏客户端代码的情况下以兼容的方式迁移到记录和从记录迁移;元组不能。而且,结构化元组不能区分Point和Range(两者都是整数对),即使它们具有完全不同的语义。(我们曾经在Java语言中面临过命名和结构化表示之间的选择;在Java 8中,我们选择命名函数类型而不是结构化函数类型,那有很多原因;而选择命名元组而不是结构化元组时,有许多原因是相同的。)

未来展望

JEP 355将记录列为一个独立的特性,但是记录的设计受到以下期望的影响:记录应能够与当前正在开发的其他几个特性(密封类型、模式匹配和内联类)很好地结合。

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

具有乘积类型的语言通常通过模式匹配支持乘积解构;记录从设计伊始就支持轻松地解构(记录的透明性要求部分源于此目标)。模式匹配的第一阶段只支持类型模式,但是记录上的解构模式很快就会实现。

最后,记录通常(但不总是)与内联类型类似;满足记录和内联类型要求的聚合(很多都会)可以将记录和内联结合成内联记录。

小结

记录提供了一种将数据建模为数据的直接方法,而不是使用类来模拟数据,从而减少了许多公共类的冗余。记录可以在各种情况下用于常见用例的建模,比如多返回值、流连接、组合键、树节点、DTO等等,并提供更强的语义保证,允许开发人员和框架更可靠地推断它们的状态。虽然记录本身很有用,但是它们还可以与一些即将出现的特性进行积极的交互,包括密封类型、模式匹配和内联类。

作者简介:

Brian Goetz是Oracle的Java语言架构师,也是JSR-335(面向Java编程语言的Lambda表达式)规范的负责人。他是畅销书《Java并发编程实战》的作者,自从Jimmy Carter担任总统以来,就一直对编程着迷。

原文链接:

Java 14 Feature Spotlight: Records

  • 发表于:
  • 本文为 InfoQ 中文站特供稿件
  • 首发地址https://www.infoq.cn/article/bJrVTPcuXGG0cHQpSzae
  • 如有侵权,请联系 cloudcommunity@tencent.com 删除。

扫码

添加站长 进交流群

领取专属 10元无门槛券

私享最新 技术干货

扫码加入开发者社群
领券
http://www.vxiaotou.com