Java 技术
#Java#Records#Sealed Classes#Algebraic Data Types#Type Safety#Pattern Matching

记录类与密封类:构建类型安全的 Java 数据模型

本文通过一个 JSON 反序列化场景,演示如何结合 Java 的记录类与密封类构建代数数据类型,解决类型不安全和穷举性缺失的问题,同时降低对象拷贝与相等性比较的出错率。

引言

在 Java 应用中,处理 JSON 数据是常见的需求。假设我们正在开发一个订单系统,需要解析来自上游系统的 JSON 消息。消息体包含一个 type 字段,用于区分订单类型:普通订单、预售订单或虚拟商品订单。不同类型的订单携带不同的字段,例如预售订单有预计发货日期,虚拟商品订单有激活码。

一种常见的做法是定义一个通用的 Order 类,包含所有可能的字段,并使用 type 字段在运行时判断具体类型:

class Order {
    String type;
    String productId;
    int quantity;
    String estimatedShipDate; // 仅预售订单使用
    String activationCode;    // 仅虚拟商品订单使用
}

这种设计存在明显缺陷:类型安全性差,字段访问依赖隐式约定,容易误用;穷举性无法保证,添加新订单类型时编译器不会提示遗漏;相等性比较和对象拷贝需要手动实现,容易出错。

Java 14 引入的记录类(Records)和 Java 17 最终确定的密封类(Sealed Classes)为解决这类问题提供了新的工具。记录类简化了不可变数据载体的定义,密封类限制了继承层次,两者结合可以构建代数数据类型(Algebraic Data Types, ADT),使数据模型既类型安全又易于维护。

记录类:不可变数据载体的简洁定义

记录类是一种特殊的类,用于透明地承载不可变数据。它的声明由名称、类型参数(可选)和头部组成,头部列出了组成状态的组件。例如,定义一个 Point 记录类:

record Point(int x, int y) { }

编译器会自动生成以下成员:

  • 每个组件对应的 private final 字段和公共访问器方法(方法名与组件名相同)。
  • 规范构造器(canonical constructor),其签名与头部一致,用于初始化所有字段。
  • equalshashCodetoString 方法,基于所有组件实现。

记录类放弃了 API 与内部表示的解耦,换取了极大的简洁性。这种设计非常适合作为数据载体,例如 JSON 反序列化的目标对象。

记录类的构造器与约束

记录类可以显式声明规范构造器,用于验证或规范化数据。例如,确保 xy 非负:

record Point(int x, int y) {
    Point {
        if (x < 0 || y < 0) throw new IllegalArgumentException("Coordinates must be non-negative");
    }
}

这种紧凑构造器(compact constructor)省略了参数列表和字段赋值,编译器会自动将形参与组件绑定,并在构造器末尾插入字段赋值。在紧凑构造器中,不能对实例字段赋值,这强化了不可变性。

记录类还可以声明额外的构造器,但必须以规范构造器为终点。例如,提供无参构造器创建原点:

record Point(int x, int y) {
    Point() {
        this(0, 0);
    }
}

记录类在 JSON 反序列化中的应用

现代 JSON 库(如 Jackson)对记录类有良好支持。例如,使用 Jackson 将 JSON 反序列化为 Point

ObjectMapper mapper = new ObjectMapper();
Point point = mapper.readValue("{\"x\":1,\"y\":2}", Point.class);

记录类的不可变性避免了反序列化后意外修改的风险,自动生成的 equalshashCode 使对象比较和集合操作更可靠。

密封类:限制继承以支持穷举性

密封类允许作者控制哪些类或接口可以扩展或实现它。通过 sealed 修饰符和 permits 子句,明确列出允许的子类。例如,定义一个形状层次:

public abstract sealed class Shape permits Circle, Rectangle, Square {
    // ...
}

允许的子类必须满足以下条件之一:

  • 声明为 final,禁止进一步继承。
  • 声明为 sealed,进一步限制子类。
  • 声明为 non-sealed,恢复为开放继承。

密封类的主要动机之一是支持模式匹配中的穷举性分析。当 switch 表达式或语句处理密封类的所有允许子类时,编译器可以检查是否覆盖了所有情况,无需 default 分支。

密封类与代数数据类型

代数数据类型是函数式编程中的概念,通过组合已有的类型来构造新类型。在 Java 中,密封类可以模拟和类型(sum type),记录类可以模拟积类型(product type)。密封类充当和类型的根,其允许的子类就是和类型的各个变体;每个变体通常是一个记录类,携带该变体所需的数据。

构建类型安全的订单模型

回到订单系统的例子,我们可以用密封类和记录类重新建模。首先,定义一个密封接口 Order,并允许三种订单类型:

public sealed interface Order permits NormalOrder, PresaleOrder, VirtualOrder {
    String productId();
    int quantity();
}

每个具体订单类型用记录类实现:

record NormalOrder(String productId, int quantity) implements Order { }

record PresaleOrder(String productId, int quantity, LocalDate estimatedShipDate) implements Order { }

record VirtualOrder(String productId, int quantity, String activationCode) implements Order { }

这种设计带来了以下好处:

  • 类型安全:访问 estimatedShipDate 必须先将 Order 转换为 PresaleOrder,编译器强制检查。
  • 穷举性:处理 Order 时,使用模式匹配可以覆盖所有情况,编译器会提示遗漏。
  • 不可变性:所有字段都是 final,无需担心数据被意外修改。
  • 自动生成的方法equalshashCodetoString 由编译器生成,减少出错。

反序列化与类型判断

JSON 反序列化时,需要根据 type 字段决定实例化哪个子类。Jackson 可以通过自定义反序列化器实现:

public class OrderDeserializer extends StdDeserializer<Order> {
    public OrderDeserializer() {
        super(Order.class);
    }

    @Override
    public Order deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
        JsonNode node = p.getCodec().readTree(p);
        String type = node.get("type").asText();
        return switch (type) {
            case "normal" -> ctxt.readTreeAsValue(node, NormalOrder.class);
            case "presale" -> ctxt.readTreeAsValue(node, PresaleOrder.class);
            case "virtual" -> ctxt.readTreeAsValue(node, VirtualOrder.class);
            default -> throw new IllegalArgumentException("Unknown order type: " + type);
        };
    }
}

switch 表达式中,编译器无法检查 type 字符串是否覆盖所有情况,需要 default 分支。但当我们处理 Order 对象时,可以使用模式匹配获得编译期穷举检查。

模式匹配与穷举性检查

Java 17 引入了模式匹配的预览特性,后续版本逐步增强。使用 switch 表达式处理 Order

String describe(Order order) {
    return switch (order) {
        case NormalOrder o -> "Normal order for product: " + o.productId();
        case PresaleOrder o -> "Presale order, ships on: " + o.estimatedShipDate();
        case VirtualOrder o -> "Virtual order, activation code: " + o.activationCode();
    };
}

如果遗漏某个子类,编译器会报错,要求覆盖所有可能。这避免了运行时因遗漏处理而导致的错误。

密封类的层次设计

密封类的允许子类必须与密封类位于同一个模块(如果密封类在具名模块中)或同一个包(如果密封类在未命名模块中)。这种限制保证了封闭性,但有时需要在不同包中声明子类。例如:

package com.example.geometry;

public abstract sealed class Shape
    permits com.example.polar.Circle,
            com.example.quad.Rectangle,
            com.example.quad.simple.Square {
    // ...
}

当允许的子类较少且紧密相关时,可以将它们声明在同一个源文件中,此时可以省略 permits 子句,编译器会推断。

对象拷贝与相等性比较

在传统 JavaBean 中,拷贝对象通常需要手动编写 clone 或复制构造器,容易遗漏字段。记录类的不可变性使得拷贝变得简单:直接使用构造器创建新对象,传入相同的字段值。如果需要修改某个字段,可以创建新记录:

NormalOrder order = new NormalOrder("P123", 10);
NormalOrder updated = new NormalOrder(order.productId(), 20); // 修改数量

相等性比较方面,记录类的 equals 方法基于所有组件,避免了手动实现可能出现的错误。例如,两个 NormalOrder 对象只要 productIdquantity 相等,就认为相等。

与 Lombok 等工具的对比

Lombok 的 @Value 注解也能生成不可变类,但记录类是语言级特性,具有以下优势:

特性记录类Lombok @Value
语言支持原生支持,无需额外依赖需要注解处理器
不可变性强制不可变,字段为 final字段为 final,但可绕过
构造器紧凑构造器,支持验证需手动编写或使用 Builder
继承隐式为 final,不能继承其他类可继承其他类
模式匹配支持解构模式(未来)不支持
与密封类协作天然适合作为密封类的允许子类无特殊支持

记录类更适合作为纯数据载体,而 Lombok 在需要继承或更灵活控制时仍有其用途。

生产环境中的考量

序列化兼容性

记录类的序列化基于其组件,而非内部字段。即使记录类添加了额外的方法或静态字段,只要组件不变,序列化形式保持兼容。但修改组件类型或顺序会破坏兼容性。

性能影响

记录类的 equalshashCode 由编译器生成,通常比手动实现更高效,因为编译器可以生成直接访问字段的字节码,避免反射或自动装箱。但记录类的不可变性意味着修改数据需要创建新对象,在频繁修改的场景下可能带来分配压力。对于订单模型,订单一旦创建很少修改,因此影响不大。

反射与框架集成

记录类的组件名和类型在运行时可通过 Class.getRecordComponents() 获取,框架可以利用这些信息进行序列化或数据绑定。但记录类的字段是 private final,通过反射修改会抛出 IllegalAccessException,这强化了不可变性,但也可能影响某些依赖反射修改字段的框架。

常见失败模式

  • 密封类遗漏子类:当添加新订单类型时,如果忘记在 permits 子句中声明,编译会失败。但更隐蔽的是,处理 Orderswitch 表达式会提示错误,迫使开发者处理新类型,这正是穷举性的价值。
  • 记录类构造器中的副作用:紧凑构造器应只进行验证,不应执行有副作用的操作,因为构造器可能被多次调用(例如反序列化时)。
  • JSON 反序列化中的类型映射错误:如果 type 字段与类映射不一致,会导致反序列化失败或创建错误类型。应确保映射逻辑与密封类层次同步。

贯穿场景:订单处理流程

下面通过一个完整的订单处理流程,展示记录类与密封类的协作。假设收到一个 JSON 消息:

{"type":"presale","productId":"P123","quantity":5,"estimatedShipDate":"2025-12-01"}

反序列化后得到 PresaleOrder 对象。处理逻辑根据订单类型执行不同操作:

void process(Order order) {
    switch (order) {
        case NormalOrder o -> fulfillNormal(o);
        case PresaleOrder o -> reserveInventory(o);
        case VirtualOrder o -> issueActivationCode(o);
    }
}

如果未来增加新的订单类型,例如 SubscriptionOrder,只需将其添加到 Orderpermits 列表,并实现相应处理逻辑,编译器会强制所有 switch 覆盖新类型。

下面的流程图展示了从 JSON 反序列化到类型安全处理的过程:

flowchart TD
    A[接收 JSON 消息] --> B{解析 type 字段}
    B -->|normal| C[反序列化为 NormalOrder]
    B -->|presale| D[反序列化为 PresaleOrder]
    B -->|virtual| E[反序列化为 VirtualOrder]
    C --> F[处理普通订单]
    D --> G[处理预售订单]
    E --> H[处理虚拟商品订单]
    F --> I[完成]
    G --> I
    H --> I

在这个流程中,密封类确保了 type 到子类的映射是受控的,记录类保证了数据载体的简洁和不可变。

总结

记录类与密封类的组合为 Java 带来了代数数据类型的能力,使得建模固定集合的类型变体变得简单且安全。在 JSON 反序列化场景中,这种模式消除了基于字符串的类型判断,提供了编译期穷举检查,并降低了对象拷贝和相等性比较的出错率。虽然记录类的不可变性可能带来额外的对象分配,但在许多业务场景中,这种代价远小于其带来的安全性和可维护性提升。

当你的数据模型需要表达“有限种可能”时,密封类和记录类应成为首选工具。

资料来源

  1. JEP 395: Records
  2. JEP 409: Sealed Classes
  3. Records and Sealed Classes – Oracle Dev