为什么Java的记录类型比Lombok@Data和Kotlin的数据类更好? - nipafx


Java的Recode、Lombok的@Data和Kotlin的数据类所有三个都可以解决POJO样板(无需复杂setter/getter方法),但相似之处并没有什么区别。记录Recode具有更强的语义,并具有重要的下游利益,这使它变得更好。

带有样本代码的POJO的示例:

class Range {

    private final int low;
    private final int high;

    public Range(int low, int high) {
        this.low = low;
        this.high = high;
    }

    public int getLow() {
        return low;
    }

    public int getHigh() {
        return high;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o)
            return true;
        if (o == null || getClass() != o.getClass())
            return false;
        Range range = (Range) o;
        return low == range.low &&
                high == range.high;
    }

    @Override
    public int hashCode() {
        return Objects.hash(low, high);
    }

    @Override
    public String toString() {
        return "[" + low + "; " + high + "]";
    }

}

使用Java 16的record新类型:

//          these are "components"
record Range (int low, int hight) { }

或使用lombok的@Data:

@Data
class Range {

    private final int low;
    private final int high;

}

如果您熟悉Kotlin,就会知道数据类是如何做到这一点的:

data class Range(val low: Int, val high: Int)

所以这些本质上是相同的功能吧?不,不,不是。因为record目的不是精简模板,这只是其语义的(受欢迎的)结果。
不幸的是,这很容易丢失。样板的减少是显而易见的,性感的,并且易于演示,因此它引起了很多关注。但是语义和它们的好处却没有。官方文档也采用样板角度并没有帮助,尽管JEP 395更好地解释了语义,但由于其范围,在描述下游优势时,它自然是含糊的。所以我想我会把它们写下来。
首先是语义,然后是收益。
 
record语义
record是充当不可变数据的透明载体的类。
因此,通过创建记录,您可以告诉编译器,您的同事,整个世界,这种类型与数据有关。更准确地说,(浅)不可变且透明可访问的数据。这就是核心语义-其他所有内容都从这里开始。
如果此语义不适用于您要创建的类型,则不应创建记录。如果您仍然这样做(可能是因为没有样板的承诺或因为您认为记录等同于@Data/@Value或数据类而被吸引),那么您将使设计变得混乱,并且很有可能再次引起您的注意。所以不要这样。
 
透明度和限制
记录的API对状态,整个状态进行建模,而且就仅仅是针对状态。
不能存在任何隐藏状态,并且必须可以使用它们进行构造。这就是为什么记录是不可变数据的透明载体。
为了实现这一目标,需要一些限制:

  • 具有相同名称和返回类型的每个组件的访问器,该访问器可精确返回组件的值(或者API不对状态建模)
  • 一个可访问的构造函数,其参数列表与组件匹配(称为规范构造函数;或者API不对状态建模)
  • 没有其他字段(或API无法为整个状态建模)
  • 没有类继承(或者API不能为整个状态建模,因为更多的东西可能隐藏在其他地方)

为什么呢?Lombok还允许其他字段和Kotlin的数据类,以及私有的“组件”(这是记录术语; Kotlin称它们为主要构造函数参数)。那么,为什么Java对此如此严格?要回答这个问题,我们需要一些数学。
 
集合论
集合论的一个集合代表一堆元素,比如C代表所有颜色的集合,这些颜色有蓝、绿、黄,这里的集合实际就是Java中类型,代表颜色这个类别,我们可以抽象颜色为Class Color,这个Color类代表所有颜色的集合,这就是类与集合论的对应关系。
另外一个有名的例子就是罗素的理发师悖论,在没有类型理论或集合论之前,人们没有区别一个集合和另外一个集合,理发师说:他会给村里所有不会给自己理发的人理发,那么他给自己理发吗?如果不给自己理发,属于不会给自己理发的人集合,那根据他的言语他应该给自己理发;如果他给自己理发,那他也违背了他的言语,他给一个会自己理发的人理发了。
解决这个问题,引入集合论,不会给自己理发的人组成一个集合,一种类型,给自己理发的人组成一个集合,一种类型,然后在这两个边界上下文内在进行逻辑推理,不要跨两种集合类型。

假设有一个类Pair试图代表一个整数集合:
class Pair {

    private final int first;
    private final int second;

}
集合论称这个类表达为乘积,并将其写为int×int(乘积中的每个类型称为操作数)。集合论有一种Product Type产品类型,其定义是:如何将在单个操作数上操作的函数组合为在所有操作数上操作的函数,以及该函数的哪些属性(内射双射等)保持不变。

// given: bijective function from int to int
IntUnaryOperator increment =
    i -> i == Integer.MAX_VALUE ? Integer.MIN_VALUE : ++i;
// then: combining two `increment`s yields a bijective function
//       (this requires no additional proof or consideration)
UnaryOperator<Pair> incrementPair =
    pair -> new Pair(
        increment.applyAsInt(pair.first()),
        increment.applyAsInt(pair.second()));

注意

pair.first()和pair.second()两个方法
在上面的Pair类中不存在,因此我需要添加它们。否则,我无法将函数应用于单个组件/操作数,因此不能真正用作Pair对集合,同时,我需要一个构造函数,该构造函数将两个int整数作为两个参数,以便可以重新构造一个Pair实例。
更笼统地说,要将集论应用到我上面提到的一种类型中,它的所有操作数都必须可访问,并且必须有一种将操作数元组转换为实例的方法。如果两者都成立,则类型理论将这种类型称为Product Type产品类型(及其实例元组)。
实际上,记录甚至比元组更好。JEP 395说:
记录可以被视为名义nominal 元组。
nominal 意思是:记录由它们的名称标识,而不是它们的结构。这样,您就无法将两个都有为int×int的不同记录类型混合在一起,比如 Pair(int first, int second) 与Range(int low, int high)不会混合了。
我想指出一点:记录要成为产品类型(因为很酷的东西),并且要使其正常工作,它们的所有组件都必须是可访问的,即,不能存在任何隐藏状态,并且必须可以使用它们进行构造。这就是为什么记录是不可变数据的透明载体。
对于record类,编译器会生成各种访问方法,我们无法更改其名称或返回类型,我们应该非常小心地覆盖它们;同时编译器会生成规范的构造函数。因此,record不可能使用继承。
 
为什么记录record会更好?
我们从代数结构中获得的大多数好处都围绕着这样一个事实,即访问器与规范的构造函数一起允许以结构化的方式分解并重新创建记录实例,而不会丢失信息。
  • 解构模式

JEP 405提出了记录和数组模式,这将增强Java的模式匹配功能。它们将使我们能够分开记录和数组,并对它们的组件进行进一步的检查:
if (range instanceof Range(int low, int high) && high < low)
    return new Range(high, low);

由于完全透明,我们可以确保不会错过隐藏状态。这意味着range和返回的实例之间的区别就是您所看到的:low 与high 只是颠倒过来。

  • with 

Java的未来版本可能会引入一些with块,这些块将使创建(通常是不可变的)实例的副本变得很容易,并且某些值已更改。它可能看起来像这样:
Range range = new Range(5, 10);
// SYNTAX IS MADE UP!
Range newRange = range with { low = 0; }
// range: [5; 10]
// newRange: [0; 10]

这样,新语言能精确地使用with派生,Range API与它的声明保持一致。与以前类似,我们可以依靠与range完全相同的newRange,除了low不同,不会存在我们无法传输的隐藏状态。

  • 序列化

要将实例转换为字节流,JSON或XML文档或任何其他外部表示,然后再返回,则需要一种将实例分解成其值,然后将这些值重新组合在一起的方法。您可以立即看到它如何与记录一起很好地工作。它们不仅公开其所有状态并提供规范的构造函数,而且还以结构化的方式这样做,从而使该反射API的使用非常简单。
有关记录如何更改序列化的更多信息,请查看Inside Java Podcast,第14集(也在许多音频平台上,例如在Spotify上)。
  
为什么记录更糟?
记录的语义限制了您可以使用哪些类构建工具。如前所述,您不能通过添加其他字段隐藏状态,不能重命名访问器,不能更改其返回类型,并且可能不应该更改其返回值。记录也不允许重新分配组件值,即它们的后备字段是final,并且没有类继承(不过,您可以实现接口)。
那如果需要的话怎么办?然后,记录不是您想要的,而是需要创建一个常规类。即使这意味着仅更改10%的功能,您最终也会获得90%的样板,而这些样板本来可以避免的。
lombok只是生成代码。没有附加的语义,因此您拥有使类适应您的需求所需的所有自由。当然,尽管Lombok将来可能会产生解构方法,但您也不会从更强大的担保中获得收益。
Kotlin的数据类:您经常创建主要目的是保存数据的类。在此类中,通常可以从数据中机械地得出一些标准功能和实用程序功能。重点在于派生功能,即生成代码。
数据类比记录(可变的“组件”,隐藏状态等)提供了更多的类构建工具,但是与Lombok不同,您不能实现所有功能(无法扩展,无法创建您的数据库),另一方面,数据类不能给予记录以有力的保证,因此Kotlin不能在其上完全构建相同的功能。
 
总结
记录并不是一定比其他两个或其他类似的设计或好或坏。但是记录确实具有强大的语义和牢固的数学基础,在限制我们的类设计空间的同时,还可以启用强大的功能,否则这些功能将无法实现或至少不那么可靠。