JDK新提议:新增Record记录的"with"表达式


甲骨文java语言架构师Brian Goetz提议JDK增加with功能,用来增强Record功能。

记录Record和内联类是 Java 中两种新形式的浅不可变类:
如果我们的Point记录想要公开一种“set”x和y组件的方法,它必须编写withX和 withY方法:

record Point(int x, int y) {
    Point withX(int newX) { return new Point(newX, y); }
    Point withY(int newY) { return new Point(x, newY); }
}

这是可行的,并且具有使用我们今天拥有的语言的优势,但有两个明显的缺点。开发人员显然很高兴能够摆脱通常与状态相关的样板,这将形成一个不会自动化的新样板类别(前进两步,后退一步)。

当记录有许多状态组件时,编写这些“withers”变得更加乏味且容易出错。至少对于记录Record而言,如果语言有足够的信息来自动完成这一工作酒更好,要求开发人员手动完成这项工作尤其令人遗憾。

事实上,"withers "会比getters和setters更糟糕,因为虽然一个类可能有O(n)个getters,但可以想象它可能有O(2^n)个withers。更糟的是,随着组件数量的增加,这些 "withers "访问器的主体变得更加容易出错。

这个问题不是记录或内联类所独有的;现有的基于值的类(例如LocalDateTime)也必须公开 wither 方法。但是,如果语言要鼓励我们编写不可变类,它也应该可以帮助我们解决这个问题。

向 C# 学习
我们在C#世界里的朋友已经对这个问题进行了两次冲击。他们的第一个解决方案是建立在他们已经允许参数有默认值的基础上,后来又增加了默认值的引用能力。这意味着你可以写一个普通的库方法,用以下方式调用。

class Point {
    int x;
    int y;

    Point with(int x = this.x, int y = this.y) {
       return new Point(x, y)。
    }
}

这是一个改进,因为它允许你写一个方法来处理2^n个可能的组合,作为一个纯粹的API考虑,客户端可以只指定他们想要改变的参数。

p = p.with(x : 3)。

但是,对于C开发者来说,这显然是不够的,因为最近(C# 9),他们还在语言中引入了一个with表达式。

p with { x = 3; }

右边的块是极其有限的;它是一组属性赋值。(C最近还引入了 "init-only "属性(实际上是命名的构造函数参数),所以上述内容会导致一个新的点被实例化,其中写在块中的属性赋值覆盖了左边操作数的属性赋值)。

Java中的with表达式
对他们来说,C的方法是明智的,因为他们可以建立在他们已经拥有的特性上(默认参数、属性),但仅仅复制这种方法会有很多包袱。在Java中,我们已经有了我们需要(几乎)以不同的方式,而且可能是更丰富的方式来做的构件:构造函数和解构函数。

重构表达式接收一个静态类型为T的操作数和一个块,其中块表达了对操作数状态的函数转换,并产生一个类型为T的新实例。

Point p;
Point pp = p with { x = 3; }

这里,pp将拥有p的任何y值,而x=3。
这里代码块可以是一个任意的Java语句序列(赋值、,循环、方法调用等),但有一些限制。
理想情况下,我们可以为任意的类定义重构,而不仅仅是记录record,但我们将从记录开始。一个记录总是有一个规范的构造函数和解构模式。这意味着我们可以把上面的与表达式解释为。

  • 声明一个新的代码块,有新的可变的局部,其名称和类型是记录的组成部分。
  • 用典型的解构器解构目标,并将结果分配给上面描述的变量。
  • 在该范围内执行与的块,如果使用了正确的名称,就可以改变这些locals。
  • 读取locals的最终值,然后用它们来调用规范构造函数。
  • with表达式的值就是结果的实例。

因此,一个表达式,如:

p with { x = 3; }

可以被解释为这样的内容:

{ // 新的范围
    Point(var x, var y) = p;
// 用规范的ctor解构LHS
    { x = 3; }                
// 在该作用域中执行RHS的
    yield new Point(x, y);
// 用新的值来重构
}

我们可以把with表达式的RHS上的块看作是对记录状态的功能转换。因此,对它施加一些限制是合理的。出于稍后会清楚的原因,我们将禁止向任何变量写入,除了对应于被提取的组件的locals和在块内声明的locals。该块可以自由地使用语言的任何特性(如循环和条件),而不仅仅是赋值--它不是一个 "DSL",它是一个Java代码块,表达了对记录状态的转换。

客户端可以与表达式一起使用,但类也可能想在其实现中使用它们。比如说:

record Complex(double real, double im) {
    Complex conjugate() { return this with { im = -im; } }
    Complex realOnly() { return this with { im = 0; } }
    Complex imOnly() { return this with { re = 0; } }
}


好处:构建器
考虑到一条记录有许多 组件的情况,所有这些组件都是可选的。

     record Config(int a,
                   int b,
                   int c,
                   ...
                   int z) {
     }

显然,没有人愿意用26个值来调用规范的构造函数。
标准的解决方法是使用一个构建器,但这是一个很大的 仪式。

 record Config(int a,
                   int b,
                   int c,
                   ...
                   int z) {

         private Config() {
             this(0, 0, 0, ... 0);
         }

         public static Config BUILDER = new Config();
     }

`with`机制给了我们一条出路:

Config c = Config.BUILDER with { c = 3; q = 45; }