用不可变类建模不可变数据
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类可能有100行长,主要由您的IDE生成的代码填充,只是为了建模需要写入文件的两个整数的不可变聚合。
已经在JDK中添加了一些Record 来改变这一点。Record 只用一行代码就能提供所有这些信息。你所需要做的就是申报一份状态Record ;其余的由编译器生成。
使用Record 来帮忙
这里的Record 可以帮助您简化代码。从Java SE 14开始,您可以编写以下代码。
public record Point(int x, int y) {}
这一行代码为您创建了以下元素。
- 它是一个不可变类,有两个字段:x和y,类型为int。
- 它有一个规范的构造函数来初始化这两个字段。
- 编译器已经为您创建了toString()、equals()和hashCode()方法,其默认行为与IDE将生成的行为相对应。如果需要,您可以通过添加这些方法的自己的实现来修改此行为。
- 它实现了Serializable接口,这样您就可以通过网络或文件系统将Point的实例发送给其他应用程序。
在没有任何IDE的帮助下,记录使不可变的数据聚合的创建变得更加简单。它降低了出现bug的风险,因为每次修改记录的组件时,编译器都会自动为您更新equals()和hashCode()方法。
Record类
Record是用record关键字而不是class关键字声明的类。让我们定义以下Record。
public record Point(int x, int y) {}
当您创建Record时,编译器为您创建的类是final。
这个类扩展了java.lang.Record类。所以你的记录不能扩展任何类。
一条Record可以实现任何接口。
声明Record的组件
紧挨着record 名后面的块是(int x, int y),它声明了名为Point的记录的组件。对于记录的每个组件,编译器创建一个与该组件同名的私有final字段。您可以在一个记录中声明任意数量的组件。
在这个例子中,编译器创建了两个int类型的私有final字段:x和y,对应于您声明的两个组件。
不能添加到Record中的东西
有三件事你不能添加到记录中:
- 您不能在记录中声明任何实例字段。您不能添加任何不对应于组件的实例字段。您可以创建静态字段或静态初始化器。
- 不能定义任何字段初始值设定项。
- 您不能添加任何实例初始化器。您可以添加静态初始化器。
使用其规范构造函数构造一个Record
编译器还为您创建一个称为规范构造函数的构造函数。此构造函数将记录的组件作为参数,并将它们的值复制到Record类的字段中。
在某些情况下,您需要覆盖这个默认行为。让我们检查两个用例:
- 您需要验证记录的状态
- 您需要创建一个可变组件的防御性副本。
使用紧凑的规范构造函数
可以使用两种不同的语法来重新定义Record的规范构造函数。您可以使用紧凑构造函数或规范构造函数本身。
假设您有以下Record。
public record Range(int start, int end) {}
对于这个名字的记录,可以认为结束比开始更大。您可以通过在记录中编写紧凑的构造函数来添加验证规则。
public record Range(int start, int end) {
public Range {
if (end <= start) {
throw new IllegalArgumentException("End cannot be lesser than start");
}
}
}
紧凑的规范构造函数不需要声明它的参数块。注意,如果选择此语法,则不能分配此记录的字段。所有这些代码都是由编译器添加的。
使用规范构造函数
在某些情况下,您可能需要为记录的字段分配一个值。在这种情况下,您可以自己定义规范构造函数,如下面的示例所示。
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()的调用必须是构造函数的第一个语句。
让我们审查以下州记录。它定义在三个组件上:
- 这个州的名字
- 这个州首府的名字
- 城市名称列表,可能为空。
我们需要存储城市名单的防御拷贝,以确保它不会从这个记录之外被修改。这是通过重新定义规范构造函数来实现的。
在应用程序中,不使用任何城市的构造函数是很有用的。这可以是另一个构造函数,它只接受州名和首都城市名。第二个构造函数必须调用规范构造函数。
然后,您可以将这些城市作为变量传递,而不是传递一个城市列表。为此,您可以创建第三个构造函数,该构造函数必须使用适当的列表调用规范构造函数。
public record State(String name, String capitalCity, List<String> cities) {
public State(String name, String capitalCity, List<String> cities) {
this.name = name;
this.capitalCity = capitalCity;
this.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));
}
}
获取一个记录的状态
您不需要向记录添加任何访问器,因为编译器会为您做这件事。一条记录每个组件有一个访问器方法,该方法具有该组件的名称。
本教程第一部分中的Point记录有两个访问器方法:x()和y(),它们返回相应组件的值。
在某些情况下,您需要定义自己的访问器。例如,前一节的国家记录需要制作一份城市清单的防御性副本。您可以在状态记录中添加以下代码以返回这个防御副本。
public List<String> cities() {
return List.copyOf(cities);
}
在真实用例中使用Records
记录是一个通用的概念,可以在许多上下文中使用。
第一个是在应用程序的对象模型中携带数据。您可以将记录用于它们的设计目的:充当不可变的数据载体。
因为可以声明本地记录,所以还可以使用它们来提高代码的可读性。
让我们考虑下面的用例。您有两个建模为记录的实体:City和State。
public record City(String name, State state) {}
public record State(String name) {}
假设您有一个城市列表,您需要计算拥有最多城市数量的州。您可以使用Stream 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实例来建模直方图的每个元素。
使用本地记录可以极大地改善这种情况。下面的代码创建了一个新的记录类,它聚合了一个州和这个州中的城市数量。它有一个接受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();
现在,代码以有意义的方式提取max。您的代码更具可读性,更容易理解,更不容易出错,从长远来看更容易维护。