使用Record建模不可变数据
Java语言提供了多种创建不可变类的方法。最直接的方式可能是创建一个包含final字段和构造函数的final类,用于初始化这些字段。以下是一个此类类的示例。
public class Point {
private final int x;
private final int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
}
既然你已经编写了这些元素,接下来需要为字段添加访问器方法。你还需要添加 toString() 方法,可能还需要 equals() 方法以及 hashCode() 方法。手动编写所有这些方法相当繁琐且容易出错,所幸你的集成开发环境(IDE)可以为你自动生成这些方法。
若需将该类的实例从一个应用程序传递至另一个应用程序(无论是通过网络传输还是文件系统传输),您也可考虑使该类具备序列化能力。若选择此方案,您可能需要添加关于该类实例序列化方式的相关信息。JDK提供了多种控制序列化的方法。
最终,你的 Point 类可能长达上百行,其中大部分是IDE生成的代码,仅仅是为了建模一个需要写入文件的两个整数的不可变聚合。
为改变这种状况,JDK引入了Record类型。Record类型只需一行代码就能实现这一切。你只需声明记录的状态,其余部分由编译器自动生成。
呼唤Record来解救
Record的存在正是为了帮助你简化这段代码。从Java SE 14开始,你可以编写以下代码:
public record Point(int x, int y) {}
这行代码为您创建了以下元素。
- 这是一个不可变类,包含两个字段:x 和 y,类型均为 int。
- 它具有一个标准构造函数,用于初始化这两个字段。
- 编译器已为您创建了 toString()、equals() 和 hashCode() 方法,其默认行为与IDE生成的行为一致。如有需要,您可以通过添加这些方法的自定义实现来修改其行为。
- 它可以实现 Serializable 接口,从而能够通过网络或文件系统将Point实例发送至其他应用程序。记录的序列化和反序列化过程遵循特定规则,这些规则将在本教程末尾进行说明。
Record 使创建不可变数据集合变得更加简单,无需任何IDE的辅助。它降低了出现错误的风险,因为每次修改记录的组件时,编译器都会自动为您更新 equals() 和 hashCode() 方法。
Record类型的类
记录是用 record 关键字而不是 “class 关键字声明的类。让我们声明以下记录。
public record Point(int x, int y) {}
当你创建记录时,编译器为你生成的类是final的。
该类继承自 java.lang.Record 类。因此您的记录类不能继承任何其他类。
一个记录可以实现任意数量的接口。
声明Record的组成部分
紧接Record name之后的代码块为 (int x, int y)。该代码块声明了名为 Point 的Record的组成部分。对于记录的每个组成部分,编译器都会创建一个与该组成部分同名的私有 final 字段。记录中可以声明任意数量的组成部分。
在此示例中,编译器创建了两个私有最终字段,类型为 int:x 和 y,分别对应您声明的两个组件。
除了这些字段外,编译器还会为每个组件生成一个访问器。该访问器是一个方法,其名称与组件相同,并返回其值。对于此Point记录,生成的两个方法如下:
public int x() {
return this.x;
}
public int y() {
return this.y;
}
如果此实现适用于您的应用程序,则无需添加任何内容。不过,您可以定义自己的访问器方法。当需要返回某个字段的防御性副本时,这可能很有用。
编译器为您生成的最后几个元素是对Object类中toString()、equals()和hashCode()方法的重写。如有需要,您可以自行定义这些方法的重写版本。
无法向Record添加的内容
有三件事你无法添加到记录中:
- 在记录中不能声明任何实例字段。不能添加任何不对应于组件的实例字段。
- 您无法定义任何字段初始化器。
- 您无法添加任何实例初始化器。
您可以使用初始化器和静态初始化器创建静态字段。
使用规范构造函数构建Record
编译器还会为你创建一个构造函数,称为canonical constructor。该构造函数将记录的各个组件作为参数,并将它们的值复制到Record类的字段中。
在某些情况下,您需要覆盖此默认行为。让我们来探讨两个用例:
- 您需要验证Record的状态
- 你需要对可变组件创建一个防御性副本。
使用紧凑构造函数
您可以使用两种不同的语法来重新定义记录的规范构造函数。您可以使用紧凑构造函数或规范构造函数本身。
假设你有以下记录。
public record Range(int start, int end) {}
对于名为Range的记录,人们会期待end比start大。你可以在记录中编写紧凑构造函数来添加验证规则。
public record Range(int start, int end) {
public Range {
if (end < start) {
throw new IllegalArgumentException("End cannot be lesser than start");
}
}
}
紧凑的规范构造函数无需声明其参数块。
请注意,若选择此语法,您无法直接赋值记录字段(例如使用 this.start = start),因为编译器生成的代码会自动完成此操作。但您可以为参数赋予新值,这将产生相同效果——编译器生成的代码会将这些新值赋予字段。
public Range {
// set negtive start and end to 0
// by reassigning the compact constructor's
// implicit parameters
if (start < 0) {
start = 0;
}
if (end < 0) {
end = 0;
}
}
使用规范构造函数
若您更倾向于使用非紧凑形式(例如因不愿重新赋值参数),可自行定义规范构造函数,如下例所示:
public record Range(int start, int end) {
public Range(int start, int end) {
if (end <= start) {
throw new IllegalArgumentException("End cannot be lesser than start");
}
if (start < 0) {
this.start = 0;
} else {
this.start = start;
}
if (end > 100) {
this.end = 10;
} else {
this.end = end;
}
}
}
在这种情况下,你编写的构造函数需要为记录的字段赋值。
如果记录的组件不可变,您应考虑在规范构造函数和访问器中为其创建防御性副本。
定义任意构造函数
您也可以为记录添加任何构造函数,只要该构造函数调用记录的规范构造函数即可。其语法与使用另一个构造函数调用构造函数的经典语法相同。与任何类一样,this() 的调用必须是构造函数中的第一条语句。
让我们审视以下 State record。它由三个组成部分定义:
- 国家的名称
- 该国的首都名称
- 一个城市名称的列表,可能为空
我们需要存储城市列表的防御性副本,以确保该列表不会被本记录外部修改。这可以通过重新定义规范构造函数来实现,采用紧凑形式将参数重新赋值给防御性副本。
在您的应用程序中,提供一个不接收任何城市参数的构造函数会很有用。这可以是另一个构造函数,仅接收州名和首府城市名。该第二个构造函数必须调用标准构造函数。
那么,与其传递一个城市列表,不如将城市作为可变参数传递。为此,可以创建第三个构造函数,该构造函数必须调用标准构造函数并传入正确的列表。
import java.util.List;
public record State(String name, String capitalCity, List<String> cities) {
public State {
cities = List.copyOf(cities);
}
public State(String name, String capitalCity) {
this(name, capitalCity, List.of());
}
public State(String name, String capitalCity, String... cities) {
this(name, capitalCity, List.of(cities));
}
}
请注意,List.copyOf() 方法不接受 null 值。
获取一个Record的状态
您无需为记录添加任何访问器,因为编译器会为您完成这项工作。每个记录组件都对应一个访问器方法,该方法的名称即为该组件的名称。
本教程第一部分中的Point记录包含两个访问器方法:x()和y(),它们分别返回对应分量的值。
不过,某些情况下你需要定义自己的访问器。例如,假设上一节中的State记录在构造时未创建cities list的不可修改的防御性副本——那么它应在访问器中执行此操作,以确保调用者无法修改其内部状态。你可以在State记录中添加以下代码来返回该防御性副本:
public List<String> cities() {
return List.copyOf(cities);
}
序列化Record
如果记录类实现了Serializable接口,则可以进行序列化和反序列化。但存在一些限制。
- 对于记录而言,没有任何可替代默认序列化过程的系统可用。创建 writeObject() 和 readObject() 方法无效,实现 Externalizable 接口同样无效。
- 记录可作为代理对象用于序列化其他对象。readResolve()方法可返回记录对象。在记录中添加writeReplace()方法同样可行。
- 反序列化记录时始终调用规范构造函数。因此,你在该构造函数中添加的所有验证规则都将在反序列化记录时强制执行。
这使得Record成为在应用程序中创建数据传输对象的绝佳选择。
在实际用例中使用Record
Record是一个多功能的概念,可在多种情境中使用。
第一种是将数据承载在应用程序的对象模型中。你可以将记录用于其设计初衷:作为不可变的数据载体。
由于可以声明局部记录,您也可以利用它们来提高代码的可读性。
让我们考虑以下用例。你有两个实体被建模为记录:City 和 State。
public record City(String name, State state) {}
public record State(String name) {}
假设你有一份城市列表,需要计算拥有最多城市的州。你可以使用流式处理API,首先构建各州城市数量的直方图。该直方图通过Map对象进行建模。
List<City> cities = List.of();
Map<State, Long> numberOfCitiesPerState =
cities.stream()
.collect(Collectors.groupingBy(
City::state, Collectors.counting()
));
获取此直方图的最大值的通用代码如下:
Map.Entry<State, Long> stateWithTheMostCities =
numberOfCitiesPerState.entrySet().stream()
.max(Map.Entry.comparingByValue())
.orElseThrow();
这段代码属于技术层面的实现,不承载任何业务含义;因为它使用Map.Entry实例来建模直方图的每个元素。
使用local record可显著改善此情况。以下代码创建了一个新的记录类,该类聚合了某个州及其所辖城市数量的信息。其构造函数接受Map.Entry实例作为参数,用于将键值对流映射为记录流。
由于需要按城市数量比较这些聚合数据,可以添加工厂方法来提供此比较器。代码如下所示:
record NumberOfCitiesPerState(State state, long numberOfCities) {
public NumberOfCitiesPerState(Map.Entry<State, Long> entry) {
this(entry.getKey(), entry.getValue());
}
public static Comparator<NumberOfCitiesPerState> comparingByNumberOfCities() {
return Comparator.comparing(NumberOfCitiesPerState::numberOfCities);
}
}
NumberOfCitiesPerState stateWithTheMostCities =
numberOfCitiesPerState.entrySet().stream()
.map(NumberOfCitiesPerState::new)
.max(NumberOfCitiesPerState.comparingByNumberOfCities())
.orElseThrow();
您的代码现已实现有意义的最大值提取。代码更具可读性,更易于理解且不易出错,从长远来看也更便于维护。