Groovy™ 记录

作者: Paul King

发布日期:2023-04-02 08:22PM


编程中常见的一种情况是需要将一组相关属性组合在一起。您可以使用数组、某种形式的元组或映射来组合此类属性。有些语言可能支持像结构体这样的构造。在 Java 中,将此类属性组合成一个类是很自然的。不幸的是,创建这样的类,一旦您添加了所有预期的​​方法和行为,可能会涉及大量的样板代码。

从 JDK16 开始(从 JDK14 开始预览),Java 引入了 records 作为声明“数据”类的紧凑形式。此类只包含“数据”(几乎没有其他)。Java 选择了保存不可变数据的非常常见的情况。在这种情况下,并遵循一些限制,Java 编译器生成此类的大部分样板变得相对容易。

本博客将探讨 Groovy 的记录实现。Groovy 支持与 Java 相同的功能,但增加了额外的增强和定制。Groovy 的实现建立在现有技术之上,例如编译时元编程(即 AST 转换),这些技术用于减少其他场景中的样板。

简介

首先,让我们看看创建记录的样子

record Point(int x, int y, String color) { }

我们正在分组的属性称为组件。在这种情况下是两个整数 xy,以及一个字符串 color

使用它类似于我们使用传统定义的 Point 类,该类具有与我们的记录定义相同的参数的构造函数

var bluePointAtOrigin = new Point(0, 0, 'Blue')

我们可能需要检查点的一个组件的值

assert bluePointAtOrigin.color() == 'Blue'

我们还可以打印出该点(它调用其 toString() 方法)

println bluePointAtOrigin

这将有以下输出

Point[x=0, y=0, color=Blue]

支持 Java 记录的所有功能。一个例子是紧凑构造函数。如果我们要确保颜色不留空白,我们可以使用紧凑构造函数形式添加检查,给出替代定义,例如

record Point(int x, int y, String color) {
    Point { assert !color.blank }
}

更正式地说,记录是一个类,它

  • 是隐式 final 的(因此不能被继承)

  • 每个组件都有一个私有 final 字段,例如 color

  • 每个组件都有一个同名访问器方法,例如 color()

  • 有一个默认的 Point(int, int, String) 构造函数

  • 有一个默认的 serialVersionUID0L 和特殊的序列化代码

  • 有隐式的 toString()equals()hashCode() 方法

  • 隐式扩展 java.lang.Record 类(因此不能扩展另一个类,但可以实现一个或多个接口)

可选增强功能

Groovy 记录默认有一个额外的命名参数样式构造函数

var greenPointAtOrigin = new Point(x:0, y:0, color:'Green')

默认情况下,Groovy 记录还会生成 getAtsizetoListtoMap 方法。getAt 方法提供 Groovy 的常规类数组索引。size 方法返回组件的数量。toList 方法返回组件值。toMap 方法返回组件值以及组件名称。以下是示例

assert bluePointAtOrigin.size() == 3
assert bluePointAtOrigin[2] == 'Blue'
assert bluePointAtOrigin.toList() == [0, 0, 'Blue']
assert bluePointAtOrigin.toMap() == [x:0, y:0, color:'Blue']

getAt 方法还通过多重赋值语句实现解构,如本示例所示

def (x, y, c) = bluePointAtOrigin
assert "$x $y $c" == '0 0 Blue'

很快,我们将看到 copyWith,它对于从同一类型的另一个记录创建记录很有用。toMap 在从不同类型创建记录时可能很有用,如此处所示。在我们的示例中,我们推测在出版一本书的同一个月,我们可能希望发布一篇关于该书的文章用于营销目的

record Book(String name, String author, YearMonth published) {}

record Article(String name, String author, YearMonth published, String publisher) {}

def b = new Book('Groovy in Action', 'Dierk & Paul', YearMonth.of(2015, 06))
def a = new Article(*:b.toMap(), publisher: 'InfoQ')

如果不需要,可以通过在 RecordOptions 注解上将同名的各种注解属性设置为 false 来关闭这些可选增强功能。

另外两个方法 copyWithcomponents 默认未启用,但可以通过将相应命名的注解属性设置为 true 来启用,如下所示

@RecordOptions(components = true, copyWith = true)
record Point(int x, int y, String color) { }

copyWith 方法可以按如下方式使用

var redPointAtOrigin = bluePointAtOrigin.copyWith(color: 'Red')
assert redPointAtOrigin.toString() == 'Point[x=0, y=0, color=Red]'

这类似于 Kotlin 数据类的 copy 方法。

components 方法返回一个类型化的元组。当启用类型检查时,这特别有用,如下所示

@TypeChecked
String description(Point p) {
    p.components().with{ "${v3.toUpperCase()} point at ($v1,$v2)" }
}

请注意,元组中的第三个元素类型为 String,因此我们可以调用 toUpperCase 方法。

我们可以按如下方式使用此方法

assert description(redPointAtOrigin) == 'RED point at (0,0)'

这相当于 Kotlin 数据类的 componentN 方法。

内部细节

本节中的某些详细信息并非必须知道,但了解如何定制记录定义会很有用。

当我们像这样编写记录声明时

record Point(int x, int y, String color) { }

它等效于以下传统声明

@RecordType
class Point {
    int x
    int y
    String color
}

您几乎永远不会以这种形式编写记录,但是如果您有一些尚未理解记录语法的遗留工具,它可能会很有用。

RecordType 注解是一个元注解(有时也称为注解收集器)。这意味着它是由其他注解组成的注解。不深入细节,本质上,编译器将上述注解扩展为以下内容(并且 RecordBase 进一步调用 ToStringEqualsAndHashCode

@RecordBase
@RecordOptions
@TupleConstructor(namedVariant = true, force = true, defaultsMode = AUTO)
@PropertyOptions
@KnownImmutable
@POJO
@CompileStatic
class Point {
    int x
    int y
    String color
}

这意味着,如果您不喜欢通常通过记录获得的生成代码,您可以在多个地方以声明式方式更改行为。我们将在下一节介绍。

但要小心,如果您正在创建原生记录并尝试更改某些会违反 JDK 对记录的假设的内容,您很可能会收到编译器错误。

记录的声明式定制

我们之前研究了如何通过使用紧凑构造函数形式来确保不提供空的 color。我们还有其他几种替代方案可以使用。如果我们要检查 color 不是 null 或空字符串,我们可以使用

@TupleConstructor(pre={ assert color })
record Point(int x, int y, String color) { }

或者,为了排除仅包含空白字符的颜色,并禁用命名参数样式构造函数,我们可以使用

@TupleConstructor(pre={ assert color && !color.blank }, namedVariant=false)
record Point(int x, int y, String color) { }

我们还可以使用声明式样式更改 toString() 方法

@ToString(excludes = 'color', cache = true)
record Point(int x, int y, String color) { }
assert new Point(0, 0, 'Gold').toString() == 'Point(0, 0)'

这里我们将 color 组件从 toString 值中排除,并缓存后续 toString 调用的结果。

模拟记录

Groovy 还为 JDK8+ 提供了模拟记录。模拟记录是类文件中不包含记录属性、不提供特殊记录序列化、也不扩展 java.lang.Record 类,但会遵循所有其他记录约定的类。这意味着即使您仍然停留在 JDK8 或 JDK11 上,您也可以使用 record 简写。

默认情况下,JDK8-15 提供模拟记录,JDK16+ 提供原生记录。您可以使用 RecordOptionsmode 注解属性强制编译器始终以模拟或原生记录为目标。如果您指定 NATIVE 模式并且使用的是早期 JDK 或针对早期字节码版本,您将收到编译错误。

将记录与其他 AST 转换结合使用

我们看到,我们可以通过使用构成 RecordType 元注解的注解的变体来定制生成的代码。我们还可以使用 Groovy 中可用的大多数普通 AST 转换。这里只是一些例子

我们之前看到了一个以 Point 作为参数的 description 方法。虽然我们通常希望记录只包含数据,但这种方法放在记录内部是有意义的。我们可以如下操作并利用 Memoized 缓存结果

record Point(int x, int y, String color) {
    @Memoized
    String description() {
        "${color.toUpperCase()} point at ($x,$y)"
    }
}

var pinkPointAtOrigin = new Point(x:0, y:0, color:'Pink')
assert pinkPointAtOrigin.description() == 'PINK point at (0,0)'

我们还有另一种通过使用 groovy-contracts 的设计契约功能来检查空白颜色的方法

@Requires({ color && !color.blank })
record Point(int x, int y, String color) { }

我们还可以如下制作易于排序的记录

@Sortable
record Point(int x, int y, String color) { }

var points = [
    new Point(0, 100, 'red'),
    new Point(10, 10, 'blue'),
    new Point(100, 0, 'green'),
]

println points.toSorted(Point.comparatorByX())
println points.toSorted(Point.comparatorByY())
println points.toSorted(Point.comparatorByColor())

其输出如下

[Point[x=0, y=100, color=red], Point[x=10, y=10, color=blue], Point[x=100, y=0, color=green]]
[Point[x=100, y=0, color=green], Point[x=10, y=10, color=blue], Point[x=0, y=100, color=red]]
[Point[x=10, y=10, color=blue], Point[x=100, y=0, color=green], Point[x=0, y=100, color=red]]

尽管记录在减少 Java 世界中的样板代码方面迈进了一大步,但我们应该指出 Groovy 除了记录之外还有许多减少样板代码的功能。Groovy 已经有一个与记录非常相似的功能,即 @Immutable 转换。这提供了记录的大部分样板代码减少,但遵循 JavaBean 约定。

如果您不想要不可变性,您可以使用 @Canonical,或者您可以混入 @ToString@EqualsAndHashCode@TupleConstructor@MapConstructor 等适当的转换。

以下是主要转换和提供功能的摘要

record like functionality

总结

让我们用功能摘要来结束我们对记录的介绍

TodoScreenshot