使用 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 生成的,仅仅是为了建模您需要写入文件的两个整数的不可变聚合。

Record 已添加到 JDK 中,以改变这种情况。Record 使您只需一行代码即可完成所有这些操作。您只需声明 Record 的状态;其余部分将由编译器为您生成。

 

调用 Record 来救援

Record 可以帮助您使代码变得更加简单。从 Java SE 14 开始,您可以编写以下代码。

public record Point(int x, int y) {}

这行代码为您创建了以下元素。

  1. 它是一个具有两个字段的不可变类:xy,类型为 int
  2. 它有一个规范构造函数,用于初始化这两个字段。
  3. toString()equals()hashCode() 方法已由编译器为您创建,其默认行为对应于 IDE 将生成的代码。如果您需要,可以通过添加您自己的这些方法的实现来修改此行为。
  4. 它可以实现 Serializable 接口,以便您可以通过网络或文件系统将 Point 的实例发送到其他应用程序。Record 的序列化和反序列化方式遵循一些特殊规则,这些规则将在本教程的最后部分介绍。

Record 使创建不可变数据聚合变得更加简单,无需任何 IDE 的帮助。它降低了错误风险,因为每次修改 Record 的组件时,编译器都会自动为您更新 equals()hashCode() 方法。

 

Record 的类

Record 是使用 record 关键字而不是 class 关键字声明的类。让我们声明以下 Record。

public record Point(int x, int y) {}

当您创建 Record 时,编译器为您创建的类是 final 的。

此类扩展了 java.lang.Record 类。因此,您的 Record 不能扩展任何类。

Record 可以实现任意数量的接口。

 

声明 Record 的组件

紧随 Record 名称之后的块是 (int x, int y)。它声明了名为 Point 的 Record 的组件。对于 Record 的每个组件,编译器都会创建一个与该组件同名的私有 final 字段。您可以在 Record 中声明任意数量的组件。

在本例中,编译器创建了两个类型为 int 的私有 final 字段:xy,对应于您声明的两个组件。

除了这些字段之外,编译器还会为每个组件生成一个访问器。此访问器是一个与组件同名的方法,并返回其值。对于此 Point Record,生成的两个方法如下所示。

public int x() {
    return this.x;
}

public int y() {
    return this.y;
}

如果此实现适合您的应用程序,那么您无需添加任何内容。不过,您可以定义自己的访问器方法。如果您需要返回特定字段的防御性副本,这可能很有用。

编译器为您生成的最后一个元素是 toString()equals()hashCode() 方法的覆盖,这些方法来自 Object 类。如果您需要,可以定义您自己的这些方法的覆盖。

 

您不能添加到 Record 中的内容

您不能将三件事添加到 Record 中

  1. 您不能在 Record 中声明任何实例字段。您不能添加任何不对应于组件的实例字段。
  2. 您不能定义任何字段初始化器。
  3. 您不能添加任何实例初始化器。

您可以创建具有初始化器和静态初始化器的静态字段。

 

使用其规范构造函数构造 Record

编译器还会为您创建一个构造函数,称为规范构造函数。此构造函数将 Record 的组件作为参数,并将它们的值复制到 Record 类的字段中。

在某些情况下,您需要覆盖此默认行为。让我们检查两个用例

  1. 您需要验证 Record 的状态
  2. 您需要创建可变组件的防御性副本。

 

使用紧凑构造函数

您可以使用两种不同的语法来重新定义 Record 的规范构造函数。您可以使用紧凑构造函数或规范构造函数本身。

假设您有以下 Record。

public record Range(int start, int end) {}

对于具有该名称的 Record,人们可能会期望 end 大于 start。您可以通过在 Record 中编写紧凑构造函数来添加验证规则。

public record Range(int start, int end) {

    public Range {
        if (end <= start) {
            throw new IllegalArgumentException("End cannot be lesser than start");
        }
    }
}

紧凑规范构造函数不需要声明其参数块。

请注意,如果您选择此语法,则不能直接分配 Record 的字段,例如使用 this.start = start - 这是由编译器添加的代码为您完成的。但是,您可以将新值分配给参数,这会导致相同的结果,因为编译器生成的代码随后会将这些新值分配给字段。

public Range {
    // set negative 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;
        }
    }
}

在这种情况下,您编写的构造函数需要将值分配给 Record 的字段。

如果 Record 的组件不是不可变的,则应考虑在规范构造函数和访问器中创建它们的防御性副本。

 

定义任何构造函数

您还可以向 Record 添加任何构造函数,只要此构造函数调用 Record 的规范构造函数即可。语法与调用另一个构造函数的构造函数的经典语法相同。与任何类一样,对 this() 的调用必须是构造函数的第一个语句。

让我们检查以下 State Record。它定义了三个组件

  1. 此州的名称
  2. 此州首府的名称
  3. 城市名称列表,可能为空。

我们需要存储城市列表的防御性副本,以确保它不会从 Record 的外部修改。这可以通过重新定义规范构造函数来完成,使用重新分配参数到防御性副本的紧凑形式。

拥有不接受任何城市的构造函数在您的应用程序中很有用。这可以是另一个构造函数,它只接受州名称和首府名称。此第二个构造函数必须调用规范构造函数。

然后,您可以将城市作为可变参数传递,而不是传递城市列表。为此,您可以创建一个第三个构造函数,它必须使用正确的列表调用规范构造函数。

public record State(String name, String capitalCity, List<String> cities) {

    public State {
        // List.copyOf returns an unmodifiable copy,
        // so the list assigned to `cities` can't change anymore
        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() 方法不接受它作为参数获取的集合中的空值。

 

获取 Record 的状态

您无需向 Record 添加任何访问器,因为编译器会为您完成此操作。Record 每个组件都有一个访问器方法,该方法与该组件同名。

本教程第一部分的 Point Record 有两个访问器方法:x()y(),它们返回相应组件的值。

不过,在某些情况下,您需要定义自己的访问器。例如,假设上一部分的 State Record 在构造期间没有创建 cities 列表的不可修改的防御性副本 - 那么它应该在访问器中执行此操作,以确保调用者无法修改其内部状态。您可以在 State Record 中添加以下代码以返回此防御性副本。

public List<String> cities() {
    return List.copyOf(cities);
}

 

序列化 Record

如果您的记录类实现了 Serializable,则可以序列化和反序列化记录。不过,也有一些限制。

  1. 您无法使用任何系统来替换默认的序列化过程,这些系统不适用于记录。创建 writeObject()readObject() 方法无效,实现 Externalizable 也不起作用。
  2. 记录可以用作代理对象来序列化其他对象。一个 readResolve() 方法可以返回一个记录。在记录中添加 writeReplace() 也是可能的。
  3. 反序列化记录始终调用规范构造函数。因此,您可能在此构造函数中添加的所有验证规则将在反序列化记录时强制执行。

这使得记录成为在应用程序中创建数据传输对象的绝佳选择。

 

在实际用例中使用记录

记录是一个通用的概念,您可以在许多上下文中使用它。

第一个是将数据携带到应用程序的对象模型中。您可以将记录用于它们的设计目的:充当不可变的数据载体。

因为您可以声明局部记录,所以您也可以使用它们来提高代码的可读性。

让我们考虑以下用例。您有两个实体被建模为记录:CityState

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();

您的代码现在以有意义的方式提取最大值。您的代码更易读、更易理解、更不容易出错,从长远来看,更易于维护。

更多学习

上次更新: 2024 年 1 月 5 日


返回教程列表