您的位置 首页 java

详解 Java 17 中的记录类型(Record)

记录类型(Record)在 java 14 中以预览功能的形式引入,在 Java 16 中成为正式功能。Java 17 作为 Java 目前的长期支持(LTS)版本,在很长的一段时间内,会是 Java 开发时的首选 Java 版本。了解 Java 17 中可以使用的新特性,对于提升开发效率是很有必要的。本文介绍的记录类型就是一个非常重要的新特性。

为什么要有记录类型?

提到记录类型,首先就要介绍值对象(Value Object)。值对象与实体(Entity)相对应。两者的区别在于: 每个实体对象都有其标识符(ID) ,可以是业务相关的,也可以是系统生成的无意义的标识符。比如,自然人这一类实体对象的标识符可以是身份证号码,而订单这一类实体的标识符则通常是随机生成的 UUID。 值对象没有标识符 ,通常只是作为数据的容器,由多个字段组成。值对象一般是不可变的,其相等性由所包含的字段来确定。当且仅当所包含的字段相等时,两个值对象才会被认为是相等的。实体的相等性则由其标识符来确定。

值对象在开发中经常会遇到。我们通常会用到的数据传输对象(Data Transfer Object, DTO ),也是值对象的一种。在记录类型出现之前,编写值对象的 Java 类是一件比较繁琐的事情。由于值对象的这些特征,值对象的 Java 类都有明显的相似性:

  • 类中的字段都声明为 private final,全部字段的值都在 构造器 中设置。
  • 提供了访问字段值的方法。
  • 覆写了 equals 方法来提供基于所包含的字段的相等性的比较方式,同时也必须覆写 hashCode 方法。
  • 覆写了 toString 方法来生成有意义的描述信息,方便调试和排查问题。

以表示地理位置的 GeoLocation 为例来说明。GeoLocation 中有两个字段,分别是经度(lng)和维度(lat)。从中可以看到,即便是两个字段的值对象,对应的 Java 类也是很冗长的,包含了很多 boilerplate 的代码。

 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 this.lng;
  }

  public double getLat() {
    return this.lat;
  }

  @Overr IDE 
  public boolean equals(Object o) {
    if (this == o) {
      return true;
    }
    if (o == null || this.getClass() != o.getClass()) {
      return false;
    }
    GeoLocation that = (GeoLocation) o;
    return Double.compare(that.lng, this.lng) == 0
        && Double.compare(that.lat, this.lat) == 0;
  }

  @Override
  public int hashCode() {
    return Objects.hash(this.lng, this.lat);
  }

  @Override
  public  String  toString() {
    return "GeoLocation{" +
        "lng=" + this.lng +
        ", lat=" + this.lat +
        '}';
  }
}  

虽然 IDE 已经可以提供帮助来自动生成这些代码,它们出现在源代码中仍然显得很冗余。为了简化使用,很多 Java 项目都选择使用第三方库来解决,最常用的库是 Lombok。下面的代码是使用 Lombok 的 GeoLocation 类,使用了 Lombok 提供的 @Value 注解。与上面的版本相比,Lombok 的版本可以少写很多代码。值对象所需要的方法,由 Lombok 自动生成。

 import lombok.Value;

@Value
public class GeoLocation {

  double lng;
  double lat;
}  

记录类型的出现,使得 Java 有了原生的表示值对象的方式,而不再依赖第三方库。

记录类型怎么描述值对象?

对于同样的 GeoLocation 值对象,使用记录类型的描述方式如下所示。这里使用了新的关键词 record

 public record GeoLocation(double lng, double lat) {

}  

记录类型是一种受限的 Java 类。受限类这个名词可能有点陌生。但是提到 Java 中的另外一种受限类型,枚举类,你应该就明白受限类的含义了。受限类在使用时有一些限制,只能支持特定的使用场景。

记录类型使用关键词 record 来描述,类似于枚举类型的 enum 记录类型是值的聚合 。记录类型中的值称为记录的组件。在 GeoLocation 记录类型中,lng 和 lat 都是记录组件。每个记录组件都有名称和类型。对于每个记录组件,在生成的 Java 类中,都有一个 private、final 和 非 static 的字段与之对应。需要注意的是,记录类型的访问字段的方法名称,并不是 Java Bean 的 getXXX 或 isXXX 的格式,而是直接使用的记录组件的名称。比如,GeoLocation 中的对应方法名称是 lng() 和 lat()。

记录类型是不可变的。所有记录组件都在构造器中初始化。如果记录类没有显式地声明一个构造器,编译器会自动生成一个。自动生成的构造器的 形式参数 列表与记录类型的组件声明是相同的,具体的实现也很简单,就是把形式参数的值赋值给对应的字段。这一点与手写的 Java 类是相同的。

有些记录类型需要对组件的值进行校验。比如,GeoLocation 中的经度的范围是 -180 到 180, 纬度 的范围是 -90 到 90。这个时候可以添加一个自定义的构造器。下面的代码给出了 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 IllegalArgument Exception ("经度值无效");
    }
    if (lat <= 90 && lat >= -90) {
      this.lat = lat;
    } else {
      throw new IllegalArgumentException("纬度值无效");
    }
  }
}  

需要注意的是,这种形式的构造器必须对所有的记录组件都初始化。即便是不需要校验的组件,也需要添加类似 this.xyz = xyz; 这样的代码来进行初始化,否则会出现编译错误。如果记录类型的组件很多,需要额外进行验证的字段又很少,使用这种方式的构造器就比较繁琐了。这个时候可以使用紧凑形式的构造器。

下面代码中的记录类型 Book,只有组件 isbn 需要验证,其他的组件都会由编译器自动添加赋值操作。

 public record Book(String isbn, String title, String description,
                   BigDecimal price) {

  public Book {
    if (isbn == null) {
      throw new IllegalArgumentException("ISBN 无效");
    }
  }
}  

最后使用 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();
}  

记录类型可以用在什么地方?

记录类型在很多场合都有其应用。

记录类型最重要的作用是描述值对象。记录类型的语法简洁,不需要第三方库的支持。记录类型也支持嵌套,对于一个复杂的对象结构,可以很容易就创建与之对应的记录类型结构。

下面代码中的记录类型 Order 及其嵌套的记录类型 LineItem 和 Address,可以描述订单相关的对象结构。

 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) {

  }
}  

不过与 Lombok 相比,记录类型还是缺少了一些功能。比如,没有内置提供对 builder 模式的支持,创建对象必须使用 构造方法 。对于 builder 模式的问题,可以使用第三方库解决,如 GitHub 上的 record-builder (Randgalt/record-builder)。

记录类型的第二个用法是作为方法的返回值。方法只能有一个返回值。当需要返回多个值时,需要把这些值封装起来。一般的做法是创建新的 Java 类。有些人会使用通用的类,比如封装两个值的 Pair ,封装三个值的 Triple,封装更多值的 Tuple 等。有些人还会使用通用的数据结构,如 List 或 Map 等。这些做法都不够直观,影响代码的可读性。有了记录类型之后,可以简洁地用记录类型来封装多个返回值。

记录类型的第三个用法是表示实际的值。这些指的是 领域模型 中的值对象。比如表示地理位置坐标的 GeoLocation,表示二维坐标的 Position 等。使用记录类型更贴合这些对象原本的语义。

记录类型可以声明在方法实现中,称为本地记录类型。当需要在一个方法中进行复杂的计算时,可以使用本地记录类型来表示中间的计算结果。这样写出来的代码更容易阅读和理解。这一点在使用 Java Stream API 的时候尤为明显。流处理的中间结果可以用记录类型来表示,从而把一个很长的处理流切分成较小的流,代码可读性更好。

下面的代码展示了本地记录类型的用法。OrderTotal 是一个本地记录类型,表示单个订单的总金额。在使用 Java 流进行计算时,首先把输入流转换成 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()));
  }
}  

记录类型可以封装方法的多个参数。如果一个方法的参数多于4个,使用起来就会很麻烦,尤其是当这些参数的类型一样时。使用记录类型封装参数可以使得代码更简洁。当参数发生变化时,并不需要修改调用者的代码。不过这种方式的使用也存在一些争议。Java 中并没有 Kotlin 的对象解构语法,在方法中使用这些参数还需要调用额外的访问方法。另外准备这些参数对象的时候,还是一样需要使用记录类型的构造方法,会同样遇到参数过多的问题。所以,记录类型的这种用法可以酌情使用。

文章来源:智云一二三科技

文章标题:详解 Java 17 中的记录类型(Record)

文章地址:https://www.zhihuclub.com/181597.shtml

关于作者: 智云科技

热门文章

网站地图