Groovy 记录

作者:Paul King
发布时间:2023-04-02 08:22PM


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

从 JDK16 开始(从 JDK14 开始预览),Java 引入了记录作为声明“数据”类的紧凑形式。这些类保存“数据”,而且几乎没有其他内容。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 记录功能。一个示例是紧凑的构造函数。如果我们希望 color 不留空,我们可以使用紧凑的构造函数形式添加检查,提供以下备选定义

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

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

  • 隐式为 final(因此不能扩展)

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

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

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

  • 有一个默认的 serialVersionUID 为 0L 的特殊序列化代码

  • 有隐式的 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)" }
}

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

我们可以使用以下方法

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

这是 Groovy 等效于 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) { }

或者,要排除仅包含空格的 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)'

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

模拟记录

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

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

将记录与其他 AST 变换一起使用

我们看到,我们可以通过使用构成 RecordType 元注释的注释的变体来自定义生成的代码。我们还可以使用 Groovy 中大多数正常的 AST 变换。以下是一些示例

我们之前看到了一个 description 方法,它以 Point 作为参数。虽然我们通常希望记录仅包含数据,但这正是放在记录中的方法类型。我们可以按如下所示操作,并使用 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