带你认识新的 Java 类型:记录类型
在本文中,我们将看到 Oracle with Java 16 如何正式引入除类、接口、枚举和注释之外的第五种 Java 类型:记录类型。记录是使用非常综合的语法定义的特定类。它们旨在实现表示数据的类。
特别是,记录旨在表示不可变的数据容器。记录语法可帮助开发人员专注于设计数据,而不会迷失在实现细节中。
句法
记录的语法是最小的:
[modifiers] record identifier (header) {[members]}
术语header
是指由逗号分隔的变量声明列表,它将代表记录的实例变量。一条记录隐式定义了一个构造函数,该构造函数将标头作为参数列表,定义标头中声明的所有字段的访问器方法,并提供toString
,equals
和hashCode
方法的默认实现。
让我们马上看一个例子。因此,假设我们要编写一个拍卖画作销售应用程序。这些将被理解为不可变对象。事实上,一旦它们被出售,它们就无法改变。例如,一幅画在被定义后就不能改变它的标题。然后我们可以创建Painting
记录:
public record Painting(String title, String author, int price) { }
我们可以实例化这条记录,就好像它是一个类,它有一个用头参数列表定义的构造函数:
Painting painting = new Painting("Camaleón", "Leonardo Furino", 1000000);
由于记录也自动定义了toString 方法,以下代码片段:
System.out.println(painting);
将产生输出:
Painting[title=Camaleón, author=Leonardo Furino, price=1000000]
因此,记录的明显优势之一是极其综合的语法。
记录、枚举和类
记录类型和枚举类型之间有明显的相似之处。这两种类型都在特定情况下替换了类。枚举旨在表示相同类型的定义数量的常量实例。另一方面,记录应该代表不可变的数据容器。与枚举一样,记录也通过提供比类更少冗长的语法和简单、清晰的规则来简化开发人员的工作。
这些记录仅在 Java 14 中作为功能预览引入,并在 Java 16 中正式发布。与往常一样,Java 通过将将记录转换为类的任务委托给编译器以保持与旧程序的向后兼容性来减轻这一新功能的影响。具体来说,当枚举被编译器转换成扩展抽象java.lang.Enum类的类时,记录被编译器转换成扩展抽象java.lang.Record类的类。
对于Enum类,编译器将不允许开发人员创建直接扩展Record类的类。事实上,它也是一个特殊的类,专门为支持记录的概念而创建。
当我们编译Painting.java文件时,我们会得到Painting.class文件。在这个文件中,编译器将插入一个Painting类(记录转换的结果):
- 被声明final;
- 定义一个将标头作为参数列表的构造函数。
- 定义标头中声明的所有字段的访问器方法。
- 覆盖Object方法:toString,equals和hashCode。
实际上,JDK javap 工具允许我们Painting.class使用以下命令通过自省读取生成的类的结构:
javap Painting.class
Compiled from " Painting.java"
public final class Painting extends java.lang.Record {
public Painting(java.lang.String, java.lang.String, int);
public java.lang.String toString();
public final int hashCode();
public final boolean equals(java.lang.Object);
public java.lang.String title();
public java.lang.String author();
public int price();
}
请注意,访问器方法标识符不遵循我们迄今为止使用的通常约定。而不是被调用getTitle, getAuthor并且getPrice它们被简单地称为title,author和price,但功能保持不变。
因此,我们可以使用以下语法对记录的各个字段进行读取访问:
String title = painting.title();
String author = painting.author();
如果记录不存在
如果我们创建了一个Painting与记录等效的类,我们将不得不手动编写以下代码:
public final class Painting {
private String title;
private String author;
private int price;
public Painting(String title, String author, int price) {
this.title = title;
this.author = author;
this.price = price;
}
public String title() {
return title;
}
public String author() {
return author;
}
public int price() {
return price;
}
@Override
public int hashCode() {
final int prime = 31;
int result = 1;
result = prime * result + ((author == null) ? 0 : author.hashCode());
result = prime * result + price;
result = prime * result + ((title == null) ? 0 : title.hashCode());
return result;
}
@Override
public boolean equals(Object obj) {
if (this == obj)
return true;
if (obj == null)
return false;
if (getClass() != obj.getClass())
return false;
Painting other = (Painting) obj;
if (author == null) {
if (other.author != null)
return false;
} else if (!author.equals(other.author))
return false;
if (price != other.price)
return false;
if (title == null) {
if (other.title != null)
return false;
} else if (!title.equals(other.title))
return false;
return true;
}
@Override
public String toString() {
return "Painting [title=" + title + ", author=" + author + ", price="
+ price + "]" ;
}
}
显然,在这种情况下,定义记录而不是类无疑更方便,尽管 IDE 仍然允许我们对此类进行半自动开发。
继承与多态
记录旨在表示携带不可变数据的对象。因此,记录继承是不可实现的。特别是,记录不能扩展,因为记录是自动声明的final。此外,记录不能扩展类(显然不能扩展记录),因为它已经扩展了Record类。
这是一个看似有限的选择,但它符合使用记录的理念。记录必须是不可变的,并且继承与不变性不兼容。但是,通过隐式扩展Record类,记录继承了该类的方法。实际上,Record该类仅覆盖了从Object该类继承的 3 个方法:toString、equals和hashCode,并没有定义新方法。
在记录中,我们还可以覆盖访问器方法和Object编译器在编译时生成的三个方法。事实上,如果需要,在我们的代码中显式声明它们以自定义和优化它们可能很有用。例如,我们可以自定义记录中的toString方法Painting如下:
public record Painting(String title, String author, int price) {
@Override
public String toString() {
return "The painting " + title + " by " + author + " costs " + price;
}
}
我们也已经知道记录和枚举一样,不能扩展,也不能扩展其他类或记录。但是,记录可以实现接口。
与枚举一样,记录也是隐式的final,因此abstract不能使用修饰符。所以,当我们在一个记录中实现一个接口时,我们必须实现所有继承的方法。
自定义记录
不可能在记录中声明实例变量和实例初始值设定项。这是为了不违反记录的作用,记录应该代表不可变数据的容器。
相反,你可以声明静态方法、变量和初始值设定项。事实上,这些是静态的,由记录的所有实例共享,并且不能访问特定对象的实例成员。
但是自定义记录最有趣的部分是能够创建构造函数。
我们知道,在一个类中如果不添加构造函数,编译器会添加一个无参数的构造函数,称为默认构造函数。当我们在类中显式添加构造函数时,无论其参数数量是多少,编译器都将不再添加默认构造函数。
然而,在记录中,自动添加编译器的构造函数将记录头中定义的变量定义为参数。此构造函数称为规范构造函数。在它的特性中,它是唯一允许设置记录的实例变量的构造函数(我们很快就会看到)。也就是说,我们定义构造函数的选项如下:
- 显式地重新定义规范构造函数,最好使用其紧凑形式。
- 定义一个调用规范构造函数的非规范构造函数。
规范构造函数
我们可以显式声明一个规范的构造函数。例如,如果我们想在设置实例变量的值之前添加一致性检查,这会很有用。例如,考虑以下抽象照片概念的记录,我们向其显式添加规范构造函数:
public record Photo(String format, boolean color) {
public Photo(String format, boolean color) {
if (format.length() < 5) throw new
IllegalArgumentException("Format description too short");
this.format = format;
this.color = color;
}
}
注意初始化实例变量是必须的,否则编译器会报错。例如,如果我们不初始化格式变量,我们将收到以下错误:
error: variable format might not have been initialized
}
^
1 error
在这种情况下,我们显式地创建了一个规范构造函数,它必须定义在记录头中定义的相同参数列表。但是,我们可以通过使用其紧凑形式来更轻松地创建显式规范构造函数。
紧凑规范构造函数
确实可以创建一个紧凑的规范构造函数。它的特点是不声明参数列表。这并不意味着它将有一个空的参数列表,而是圆括号不会出现在构造函数的标识符旁边。因此,让我们重写一个与前面示例等效的构造函数:
public Photo {
if (format.length() < 5) throw new IllegalArgumentException(
"Format description too short");
}
紧凑规范构造函数的使用应被视为在记录中显式定义构造函数的标准方法。请注意,甚至不需要初始化自动初始化的实例变量。更准确地说,如果我们尝试在紧凑的规范构造函数中初始化实例变量,我们将得到一个编译时错误。
非规范构造函数
也可以定义一个参数列表不同于规范构造函数的构造函数,即非规范构造函数。在这种情况下,我们正在执行构造函数重载。事实上,与类中默认构造函数的情况不同,添加具有不同参数列表的构造函数无论如何都不会阻止编译器添加规范构造函数。此外,非规范构造函数必须调用另一个构造函数作为其第一条语句。事实上,如果我们添加如下构造函数:
public Photo(String format, boolean color, boolean msg) {
if (format.length() < 5) throw new IllegalArgumentException(msg);
this.format = format;
this.color = color;
}
我们会得到一个编译时错误:
Error: constructor is not canonical, so its first statement must invoke another constructor
public Photo(String format, boolean color, String msg) {
^
1 error
显然,如果我们添加另一个非规范构造函数来调用,迟早会调用(显式或隐式)规范构造函数。在我们的例子中,如果我们然后直接调用规范构造函数,我们还必须删除设置实例变量的指令,因为这些将在非规范构造函数的第一行中被调用后由规范构造函数设置构造函数。事实上,下面的构造函数:
public Photo(String format, boolean color, String msg) {
this(format, color);
if (format.length() < 5) throw new IllegalArgumentException(msg);
this.format = format;
this.color = color;
}
会导致以下编译错误:
error: variable format might already have been assigned
this.format = format;
^
error: variable color might already have been assigned
this.color = color;
^
2 errors
这表明这两个变量此时已经被初始化。这表明规范构造函数始终负责设置记录的实例变量。所以我们只需要删除不必要的行:
public Photo(String format, boolean color, String msg) {
this(format, color);
if (format.length() < 5) throw new IllegalArgumentException(msg);
}
在这一点上,我们将能够Photo使用规范构造函数和非规范构造函数从记录创建对象。例如:
var photo1 = new Photo("Photo 1" , true); // canonical constructor
System.out.println(photo1);
var photo2 = new Photo("Photo 2" , false, "Error!"); // non-canonical constructor
System.out.println(photo2);
var photo3 = new Photo("Photo" , true, "Error!"); // non-canonical constructor
System.out.println(photo3);
前面的代码将打印输出:
Photo[format=Photo 1, color=true]
Photo[format=Photo 2, color=false]
Exception in thread "main" java.lang.IllegalArgumentException: Error!
at Photo.<init>(Photo.java:8)
at TestRecordConstructors.main(TestRecordConstructors.java:7)
何时使用记录
何时使用记录而不是类应该已经很清楚了。如上所述,记录旨在表示不可变的数据容器。记录不能总是用来代替类,尤其是当这些类主要定义业务方法时。
然而,软件的本质是进化。因此,即使我们创建一个记录来表示一个不可变数据的容器,也不一定有一天将其转换为一个类是不合适的。应该引导我们更喜欢以类的形式重写记录的一个线索是,当我们添加了太多方法或扩展了太多接口时。在这种情况下,值得询问记录是否需要转换为类。
由于其不可变的性质,记录非常适合密封接口。此外,它通常不代表聚合大量实例变量的概念。
记录的概念似乎非常适合称为 DTO(数据传输对象的首字母缩写词)的设计模式的实现。
结论
这些记录代表了 Java 语言向前迈出的重要一步。随着时间的推移,这无疑是程序员最欣赏的新奇事物之一。事实上,他们将不再被迫添加Object通过 IDE继承的常用访问方法和方法实现。
无聊且通常心不在焉地执行的操作,这也可能导致引入错误。特别是,记录使我们能够专注于数据的设计,而无需深入了解实现细节,我们始终可以对其进行自定义。此外,记录的不可变特性将指导我们编写更简单、更高效的程序。