了解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 只包含了 lnglat 两个属性,要实现一个完整的值对象类的代码量是很多的。

从代码中可以看出,值对象的类遵循一定的模式,完全可以从属性推导而来。这其中包括属性的获取方法、equalshashCode 方法、以及 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。属性 lnglat 称为记录类型的组件。每个记录类型是所包含的组件的聚合。对于记录类型,编译器会自动生成类中的组成部分。

public record GeoLocation(double lng, double lat) {

}
  • 每个组件都会有与之对应的 private final 字段。
  • 对记录中的全部组件进行赋值的构造器。由于记录对象是不可变的,全部属性都需要在在构造器中初始化。构造器的参数声明与记录类型的组件声明完全一致。
  • 对每个组件,生成获取值的方法。方法的名称与组件名称一致,返回值类型与组件类型一致。
  • 自动生成的 equalshashCode 方法,根据组件进行相等性比较和计算哈希值。
  • 自动生成的 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()));
  }
}

关于记录类型的内容就介绍到这里。

版权所有 © 2024 灵动代码