了解Java记录类型
提到记录类型,就首先要介绍值对象。熟悉领域驱动设计的人应该听过实体和值对象这两个概念。
值对象
每个实体都唯一的标识符,可以是业务ID或是由应用指派的ID。实体的相等性完全由标识符来确定。
值对象则没有标识符,通常作为数据的容器。值对象的相等性由其包含的属性的相等性来确定。
值对象有很多应用的场景:
- 描述业务中实际存在的值,比如表示名值对的
Pair
,表示二维坐标的Point
。 - 从方法中返回多个值。多个值被组织在一个值对象中返回。
- 组织方法的参数。如果方法有多个参数,可以把这些参数组织在一个值对象中,可以简化方法的使用,也有利于代码重构。
值对象通常是不可变的。创建之后不会修改其中属性的值。
使用 Java 表示值对象
传统的表示值对象的方法是使用普通的Java类,下面的代码是值对象 GeoLocation
类。
import java.util.Objects;
public class GeoLocation {
private final double lng;
private final double lat;
public GeoLocation(double lng, double lat) {
this.lat = lat;
this.lng = lng;
}
public double getLng() {
return lng;
}
public double getLat() {
return lat;
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
GeoLocation that = (GeoLocation) o;
return Double.compare(that.lng, lng) == 0
&& Double.compare(that.lat, lat) == 0;
}
@Override
public int hashCode() {
return Objects.hash(lng, lat);
}
@Override
public String toString() {
return "GeoLocation{" +
"lng=" + lng +
", lat=" + lat +
'}';
}
}
虽然 GeoLocation
只包含了 lng
和 lat
两个属性,要实现一个完整的值对象类的代码量是很多的。
从代码中可以看出,值对象的类遵循一定的模式,完全可以从属性推导而来。这其中包括属性的获取方法、equals
和 hashCode
方法、以及 toString
方法等。
常见的替代方案
为了解决这种繁琐的声明问题,有不少的替代方案。
最常见的方案是使用Lombok中的 @Value
注解,如下面的代码所示。
import lombok.Value;
@Value
public class GeoLocation {
double lng;
double lat;
}
当用 javap
查看Lombok所实际生成的字节代码时,可以看到Lombok生成了相关的方法。
Compiled from "GeoLocation.java"
public final class io.vividcode.java11to17.record.lombok.GeoLocation {
public io.vividcode.java11to17.record.lombok.GeoLocation(double, double);
public double getLng();
public double getLat();
public boolean equals(java.lang.Object);
public int hashCode();
public java.lang.String toString();
}
Kotlin中有数据类(data class),对应的代码如下所示。
data class GeoLocation(
val lng: Double,
val lat: Double
)
Scala中也有case class,对应的代码如下所示。
case class GeoLocation(lng: Double, lat:Double)
记录类型基本说明
记录类型是Java语言原生的值对象实现方式,由关键词 record
来表示。记录类型在Java 14中以预览功能的形式引入,在Java 15再次预览,在Java 16中成为正式功能。
下面的代码是作为记录类型的 GeoLocation
。属性 lng
和 lat
称为记录类型的组件。每个记录类型是所包含的组件的聚合。对于记录类型,编译器会自动生成类中的组成部分。
public record GeoLocation(double lng, double lat) {
}
- 每个组件都会有与之对应的
private final
字段。 - 对记录中的全部组件进行赋值的构造器。由于记录对象是不可变的,全部属性都需要在在构造器中初始化。构造器的参数声明与记录类型的组件声明完全一致。
- 对每个组件,生成获取值的方法。方法的名称与组件名称一致,返回值类型与组件类型一致。
- 自动生成的
equals
和hashCode
方法,根据组件进行相等性比较和计算哈希值。 - 自动生成的
toString
方法,包含每个组件的值。
同样可以使用 javap
查看记录类型生成的字节代码,如下所示。可以看到,GeoLocation
的字节代码中包含了上述方法。
Compiled from "GeoLocation.java"
public final class io.vividcode.java11to17.record.record.GeoLocation extends java.lang.Record {
public io.vividcode.java11to17.record.record.GeoLocation(double, double);
public final java.lang.String toString();
public final int hashCode();
public final boolean equals(java.lang.Object);
public double lng();
public double lat();
}
记录类型都是声明为 final
的,也就是不能被继承。
记录类型是受限类型。这一点与枚举类型是一样的。所有的记录类型都是 java.lang.Record
的子类。
记录类型的高级用法
以上就是记录类型的基本介绍。下面介绍一些高级的用法。
如果需要对记录组件的值进行验证或规范化处理,可以添加自定义的构造器。比如,GeoLocation
中的经纬度的值有范围限制。可以把验证的逻辑添加在构造器中,如下面的代码所示。
public record GeoLocation(double lng, double lat) {
public GeoLocation(double lng, double lat) {
if (lng <= 180 && lng >= -180) {
this.lng = lng;
} else {
throw new IllegalArgumentException("Invalid value of longitude");
}
if (lat <= 90 && lat >= -90) {
this.lat = lat;
} else {
throw new IllegalArgumentException("Invalid value of latitude");
}
}
}
所添加的构造器的类型与自动生成的构造器是相同的。在这种情况下,编译器不会再生成构造器。
这种形式的构造器需要对全部组件进行初始化。如果仅需要对部分组件进行处理,可以使用紧凑形式的构造器。在下面的代码中,仅对组件 isbn
进行了验证。其他未初始化的组件会被自动添加赋值操作。
public record Book(String isbn, String title, String description,
BigDecimal price) {
public Book {
if (isbn == null) {
throw new IllegalArgumentException("ISBN is invalid");
}
}
}
上述紧凑形式的构造器,实际上等同于下面的代码。
public record Book(String isbn, String title, String description,
BigDecimal price) {
public Book(String isbn, String title, String description, BigDecimal price) {
if (isbn == null) {
throw new IllegalArgumentException("ISBN is invalid");
}
this.isbn = isbn;
this.title = title;
this.description = description;
this.price = price;
}
}
记录类型是可以嵌套的,使得它们很适合于表示复杂的对象结构。比如下面代码中的 Order
类型。
public record Order(String orderId, String userId, LocalDateTime createdAt,
List<LineItem> lineItems,
Address deliveryAddress) {
public record LineItem(String productId, int quantity, BigDecimal price) {
}
public record Address(String addressLine, String cityId, String provinceId,
String zipCode) {
}
}
记录类型可以出现在方法内部,称之为局部记录(local record)。局部记录类型很适合于用在Java流计算相关的代码中。如果流计算比较复杂,可以使用局部记录来表示中间的计算结果,从而提升代码的可读性。
在下面的代码中,calculate
方法用来计算每个用户的订单金额的最大值。OrderTotal
是一个局部记录,表示每个订单的金额。第一个流计算得出每个用户的订单的金额,第二个计算再得出金额的最大值。
public class OrderCalculator {
public Map<String, OrderSummary> calculate(List<Order> orders) {
record OrderTotal(String orderId, BigDecimal total) {
}
Map<String, List<OrderTotal>> orderTotal = orders.stream()
.collect(
Collectors.groupingBy(Order::userId, Collectors.mapping(order -> {
BigDecimal total = order.lineItems().stream()
.map(item -> item.price()
.multiply(BigDecimal.valueOf(item.quantity())))
.reduce(BigDecimal.ZERO, BigDecimal::add);
return new OrderTotal(order.orderId(), total);
}, Collectors.toList())));
return orderTotal.entrySet().stream().map(entry ->
new OrderSummary(entry.getKey(),
entry.getValue().stream()
.max(Comparator.comparing(OrderTotal::total))
.map(OrderTotal::total).orElse(BigDecimal.ZERO)))
.collect(Collectors.toMap(OrderSummary::userId, Function.identity()));
}
}
关于记录类型的内容就介绍到这里。