1. 概述

Java 程序中,在对象之间传递不可变(immutable)的数据是一件很常见且没啥技术含量的活儿。

Java 14 之前,我们需要为类的字段和方法生成大量模板代码(Boilerplate code),不小心还容易出错,且意图混乱。或者使用 Lombok 插件自动生成。

随着 Java 14 的发布,我们现在可以使用 record 来解决这些问题。

2. 目的

通常,我们需要编写一个类用来保存从数据库、Service 等查询得到获得的结果。

大多数情况下,这些数据是不可变的,因为不可变性保证了数据在多线程之间能安全地共享

为了实现不可变性,我们创建一个数据类(data class),并遵循:

  1. 每个数据字段用 private, final 修饰
  2. 每个字段添加一个 getter 方法
  3. 创建一个包含所有属性字段参数的构造函数
  4. 重写 equals 方法,当所有字段匹配时,返回 true
  5. 重写 hashCode 方法,当所有字段匹配时,应返回相同的hash值
  6. 重写 toString 方法, 包括类的名称和每个字段的名称及其对应的值

例如,我们创建一个 Person 数据类,带有 name 和 address 两个字段:

public class Person {

    private final String name;
    private final String address;

    public Person(String name, String address) {
        this.name = name;
        this.address = address;
    }

    @Override
    public int hashCode() {
        return Objects.hash(name, address);
    }

    @Override
    public boolean equals(Object obj) {
        if (this == obj) {
            return true;
        } else if (!(obj instanceof Person)) {
            return false;
        } else {
            Person other = (Person) obj;
            return Objects.equals(name, other.name)
              && Objects.equals(address, other.address);
        }
    }

    @Override
    public String toString() {
        return "Person [name=" + name + ", address=" + address + "]";
    }

    // standard getters
}

虽然这实现了我们的目标,但它存在两个问题 :

  1. 存在大量模板代码
  2. 模糊了类的目的

对于第一个问题,我们每定义一个数据类,都需要进行重复相同的繁琐过程,添加 equalshashCode, toString方法,以及创建一个接受所有字段的构造函数。

虽然现代IDE很智能,能帮我们自动生成很多代码,但如果我们新增一个字段时,它不会自动更新类。例如,我们新增一个字段时,我们需要手动同步更新 equals 方法。

第二个问题,大量额外的代码,掩盖了我们的类仅仅是一个拥有两个字段:name 和 address 的简单数据类

更好的方法是显式声明我们的类是一个数据类。

3. 基础

从JDK14开始,我们可以用 record 这一新特性,来代替冗余的数据类。record 是不可变的数据类,只需要字段的类型和名称

equalshashCodetoString 方法以及 privatefinal 字段和 构造函数由 Java 编译器自动生成。

现在我们使用 record 关键字来创建Person record:

public record Person (String name, String address) {}

3.1. 构造函数

Using records, a public constructor – with an argument for each field – is generated for us.

在我们Person record 例子中,等价的构造函数为:

public Person(String name, String address) {
    this.name = name;
    this.address = address;
}

This constructor can be used in the same way as a class to instantiate objects from the record:

Person person = new Person("John Doe", "100 Linda Ln.");

3.2. Getter 方法

We also receive public getters methods – whose names match the name of our field – for free.

In our Person record, this means a name() and address() getter:

@Test
public void givenValidNameAndAddress_whenGetNameAndAddress_thenExpectedValuesReturned() {
    String name = "John Doe";
    String address = "100 Linda Ln.";

    Person person = new Person(name, address);

    assertEquals(name, person.name());
    assertEquals(address, person.address());
}

3.3. equals 方法

Additionally, an equals method is generated for us.

This method returns true if the supplied object is of the same type and the values of all of its fields match:

@Test
public void givenSameNameAndAddress_whenEquals_thenPersonsEqual() {
    String name = "John Doe";
    String address = "100 Linda Ln.";

    Person person1 = new Person(name, address);
    Person person2 = new Person(name, address);

    assertTrue(person1.equals(person2));
}

If any of the fields differ between two Person instances, the equals method will return false.

3.4. hashCode 方法

Similar to our equals method, a corresponding hashCode method is also generated for us.

Our hashCode method returns the same value for two Person objects if all of the field values for both object match (barring collisions due to the birthday paradox):

@Test
public void givenSameNameAndAddress_whenHashCode_thenPersonsEqual() {
    String name = "John Doe";
    String address = "100 Linda Ln.";

    Person person1 = new Person(name, address);
    Person person2 = new Person(name, address);

    assertEquals(person1.hashCode(), person2.hashCode());
}

The hashCode value will differ if any of the field values differ.

3.5. toString 方法

Lastly, we also receive a toString method that results in a string containing the name of the record, followed by the name of each field and its corresponding value in square brackets.

Therefore, instantiating a Person with a name of “John Doe” and an address of *“100 Linda Ln.*” results in the following toString result:

Person[name=John Doe, address=100 Linda Ln.]

4. 构造函数

While a public constructor is generated for us, we can still customize our constructor implementation.

This customization is intended to be used for validation and should be kept as simple as possible.

For example, we can ensure that the name and address provided to our Person record are not null using the following constructor implementation:

public record Person(String name, String address) {
    public Person {
        Objects.requireNonNull(name);
        Objects.requireNonNull(address);
    }
}

We can also create new constructors with different arguments by supplying a different argument list:

public record Person(String name, String address) {
    public Person(String name) {
        this(name, "Unknown");
    }
}

As with class constructors, the fields can be referenced using the this keyword (for example, this.name and this.address) and the arguments match the name of the fields (that is, name and address).

Note that creating a constructor with the same arguments as the generated public constructor is valid, but this requires that each field be manually initialized:

public record Person(String name, String address) {
    public Person(String name, String address) {
        this.name = name;
        this.address = address;
    }
}

Additionally, declaring a no-argument constructor and one with an argument list matching the generated constructor results in a compilation error.

Therefore, the following will not compile:

public record Person(String name, String address) {
    public Person {
        Objects.requireNonNull(name);
        Objects.requireNonNull(address);
    }
    
    public Person(String name, String address) {
        this.name = name;
        this.address = address;
    }
}

5. 静态变量 & 方法

As with regular Java classes, we can also include static variables and methods in our records.

We declare static variables using the same syntax as a class:

public record Person(String name, String address) {
    public static String UNKNOWN_ADDRESS = "Unknown";
}

Likewise, we declare static methods using the same syntax as a class:

public record Person(String name, String address) {
    public static Person unnamed(String address) {
        return new Person("Unnamed", address);
    }
}

We can then reference both static variables and static methods using the name of the record:

Person.UNKNOWN_ADDRESS
Person.unnamed("100 Linda Ln.");

6. 总结

In this article, we looked at the record keyword introduced in Java 14, including their fundamental concepts and intricacies.

Using records – with their compiler-generated methods – we can reduce boilerplate code and improve the reliability of our immutable classes.

The code and examples for this tutorial can be found over on GitHub.