龙空技术网

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

成富Alex 240

前言:

此时大家对“java统计功能”大体比较着重,同学们都想要了解一些“java统计功能”的相关知识。那么小编同时在网摘上收集了一些有关“java统计功能””的相关资讯,希望各位老铁们能喜欢,我们快快来了解一下吧!

记录类型(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;  }  @Override  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;@Valuepublic 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 IllegalArgumentException("经度值无效");    }    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统计功能