26. 【Java】Record:告别繁琐的 POJO

摘要: 本文深入解析Java Record特性,从JDK 16正式引入的一行代码定义数据类开始,全面讲解Record的本质、使用场景、自定义构造方法、添加方法等核心用法。重点介绍JDK 21的Record Patterns解构功能,展示如何与密封类配合构建类型安全的代数数据类型。通过订单系统综合示例,演示Record在实际项目中的应用,帮助开发者掌握这一简化数据建模的现代Java特性。

关键词: Java Record, 数据类, Record Patterns, 密封类, 不可变对象


上一篇文章我们聊了异常处理。现在你的程序已经能从容地应对各种“意外情况”了,变得稳重了不少。

但如果你回过头来看我们写过的那些类,你可能会隐隐觉得——有些代码,是不是有点“啰嗦”?

比如我们写一个 Person 类,只有 nameage 两个字段,却要写构造方法、getter、toString()equals()hashCode()……少说二十行。如果项目里有几十个这样的类,光这些“样板代码”就能堆出好几千行。更别提每次新增一个字段,要改的地方有好几处,漏掉一个就可能引发 bug。

你可能会说:“我用 IDEA 的自动生成功能不就行了?”没错,工具能帮我们生成这些代码,但生成出来的代码还是要躺在文件里,占据你的屏幕空间,分散你的注意力。而且,如果两个地方对 equals() 的实现稍有不同,就可能出现难以排查的问题。

Java 的设计者也意识到了这个问题。从 JDK 14 开始预览、JDK 16 正式定型的 Record,就是为了解决这个痛点而生的。它让你用一行代码定义一个纯数据类,编译器替你生成所有那些“机械的”方法。

今天,我们就来深入认识 Record——它是什么、怎么用、有哪些高级玩法,以及它如何与 JDK 21 的其他新特性(如模式匹配、密封类)协同工作。


1. Record 是什么?—— 一行代码定义数据类

Record 是 Java 中一种特殊的类,它的核心使命是充当“透明数据载体”——只关心“装了什么数据”,不关心“怎么操作这些数据”。

record 关键字定义一个 Record,只需要在括号里列出它的组件(components)——也就是它要承载的数据字段:

public record Person(String name, int age) {}

就这一行,编译器会自动为你生成:

  • 两个 private final 字段:nameage
  • 一个全参构造方法:Person(String name, int age)
  • 两个访问器方法:name()age()(注意:不叫 getName()getAge())。
  • toString() 方法,输出格式为 Person[name=张三, age=20]
  • equals()hashCode() 方法,基于所有组件实现。

对比一下传统写法:

// 传统写法:二十多行
public class Person {
    private final String name;
    private final int age;

    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public String getName() { return name; }
    public int getAge() { return age; }

    @Override
    public String toString() {
        return "Person{name='" + name + "', age=" + age + "}";
    }

    // equals() 和 hashCode() 还要再写十几行...
}

// Record 写法:一行
public record Person(String name, int age) {}

是不是清爽了很多?


2. Record 的本质 —— 不可变的数据载体

Record 不仅仅是一种“简写语法”,它背后有一套严格的约束:

① 所有组件都是 final:Record 的字段一旦创建就不能修改。这让 Record 天然就是不可变对象,在多线程环境中非常安全。

② Record 本身是 final:你不能继承一个 Record,也不能被其他类继承。

public record Point(int x, int y) {}
// public class ColoredPoint extends Point {}  // 编译错误!

③ 不能添加额外的实例字段:Record 的“数据清单”只能在头部声明,不能在主体里添加新的非静态字段。

public record User(String login) {
    // int counter;        // 错误!不能添加非静态字段
    static int totalUsers = 0;  // 可以,静态字段没问题
}

这些约束听起来像是“限制”,但其实它们正是 Record 的设计意图:明确地告诉读者和编译器——“这个类就是用来装数据的,没有任何隐藏状态”。这种透明性让 Record 在数据传递、序列化、模式匹配等场景中表现卓越。


3. Record 与普通类的选择

维度普通类(Class)Record
可变性任意(字段可 final 也可不)不可变:所有组件都是 final
继承可以继承其他类不能继承,本身就是 final
字段任意字段只能有组件(头部声明的字段),外加静态字段
访问器手写 getter(可任意命名)自动生成,名与组件名相同(如 name()
equals/hashCode/toString手写或工具生成自动生成,基于所有组件
构造方法任意数量一个主构造方法(涵盖所有组件),可添加紧凑构造器
适用场景复杂业务逻辑、可变状态、需要继承纯数据载体:DTO、方法多返回值、值对象

什么时候用 Record?

  • 数据传输对象(DTO):从数据库查询返回的结果、API 交互的 JSON 对象。
  • 方法的多个返回值:当一个方法需要返回多个值时,用 Record 封装比用数组或 Map 更清晰。
  • 值对象:坐标点、区间、货币金额等“由数据定义身份”的类型。
  • 集合中的键:因为 Record 自动实现了正确的 equals()hashCode(),非常适合作为 HashMap 的 key 或 HashSet 的元素。

什么时候用普通类?

  • 对象有复杂的业务逻辑、可变状态、生命周期管理。
  • 需要继承其他类来复用代码。
  • 需要添加非静态的辅助字段(如缓存、计数器)。

一句话总结:如果你的类主要就是“装数据”,用 Record;如果它有“行为”和“状态变化”,用普通类。


4. 自定义构造方法 —— 让数据更“干净”

Record 自动生成的构造方法会把所有参数直接赋给字段。但很多时候,我们需要在对象创建时做一些校验规范化——比如年龄不能为负、邮箱不能为空、坐标不能超出范围。

在 Record 中,你可以定义紧凑构造方法(Compact Constructor),它的参数列表被省略了,直接写 public RecordName { ... }

public record Person(String name, int age) {
    // 紧凑构造方法:参数自动与组件一致
    public Person {
        if (name == null || name.trim().isEmpty()) {
            throw new IllegalArgumentException("姓名不能为空");
        }
        if (age < 0 || age > 150) {
            throw new IllegalArgumentException("年龄必须在 0~150 之间");
        }
        // 不需要手动写 this.name = name; this.age = age;
        // 编译器会帮你完成赋值
    }
}

紧凑构造方法的好处是:你只需要写校验逻辑,赋值的事情编译器帮你做。如果校验失败,对象根本不会被创建。

你也可以定义额外的构造方法,用 this(...) 调用主构造方法:

public record Range(int from, int to) {
    // 额外构造方法:只传一个长度,从 0 开始
    public Range(int size) {
        this(0, size);  // 调用主构造方法
    }
}

这样你就可以用 new Range(10) 创建一个 from=0, to=10 的区间对象了。


5. 给 Record 添加方法 —— “数据”也可以有“行为”

Record 虽然主要是数据载体,但它可以拥有方法——包括实例方法和静态方法。这让 Record 可以在保持数据透明的同时,附带一些与数据紧密相关的操作。

public record Point(int x, int y) {
    // 实例方法:计算到另一个点的距离
    public double distanceTo(Point other) {
        int dx = this.x - other.x;
        int dy = this.y - other.y;
        return Math.sqrt(dx * dx + dy * dy);
    }

    // 静态方法:创建原点
    public static Point origin() {
        return new Point(0, 0);
    }
}

使用起来很自然:

Point p1 = new Point(0, 0);
Point p2 = new Point(3, 4);
System.out.println(p1.distanceTo(p2));  // 5.0
System.out.println(Point.origin());     // Point[x=0, y=0]

💡 设计建议:Record 中的方法应该是与数据紧密相关的“派生计算”(比如 distanceToxy 算出),而不是复杂的业务逻辑。如果逻辑太复杂,建议放到独立的 Service 类中。


6. JDK 21 的 Record Patterns —— 解构的“魔法”

前面我们学了 Record 是“构造数据”的简洁方式。JDK 21 引入的 Record Patterns(记录模式) 则是“解构数据”的简洁方式。

在 Java 21 之前,如果你想从 Record 中取出数据,得先做类型检查、强制转换、再调用访问器——至少三四行代码:

record Point(int x, int y) {}

Object obj = new Point(5, 7);
if (obj instanceof Point) {
    Point p = (Point) obj;
    int x = p.x();
    int y = p.y();
    System.out.println("x=" + x + ", y=" + y);
}

Java 21 之后,Record Patterns 允许你在 instanceof 中直接解构 Record 的组件:

if (obj instanceof Point(int x, int y)) {
    System.out.println("x=" + x + ", y=" + y);  // 直接使用 x 和 y
}

一步到位:类型检查 + 解构 + 变量赋值,全部在一行完成。

嵌套解构

更强大的是,Record Patterns 可以嵌套。如果你的 Record 里还包含了其他 Record,你可以一次性把它们全部“拆开”:

record Size(int width, int height) {}
record Point(int x, int y) {}
record WindowFrame(Point origin, Size size) {}

Object obj = new WindowFrame(new Point(10, 20), new Size(800, 600));

// 嵌套解构:一步拿到所有数据
if (obj instanceof WindowFrame(Point(int x, int y), Size(int width, int height))) {
    System.out.println("窗口左上角:(" + x + ", " + y + ")");
    System.out.println("窗口大小:" + width + " x " + height);
}

如果没有 Record Patterns,你需要先转成 WindowFrame,再分别取 originsize,再分别从它们里面取字段——代码会变得非常臃肿。

switch 中使用 Record Patterns

Record Patterns 在 switch 中同样大放异彩:

sealed interface Shape permits Point, Circle {}

record Point(int x, int y) implements Shape {}
record Circle(Point center, int radius) implements Shape {}

Shape shape = new Point(3, 4);

String description = switch (shape) {
    case Point(int x, int y) -> "点在 (" + x + ", " + y + ")";
    case Circle(Point(int cx, int cy), int r) -> 
        "圆心在 (" + cx + ", " + cy + "),半径 " + r;
};
System.out.println(description);  // 点在 (3, 4)

注意这里 Circle 的匹配模式还嵌套了解构 Point——一切都是那么流畅和自然。

带守卫条件(Guard)

你还可以在 Record Pattern 后面加上 when 子句(或 && 条件),对匹配进一步筛选:

if (obj instanceof Point(int x, int y) && x == 0) {
    System.out.println("点在 Y 轴上");
}

Record Patterns 让 Java 在处理结构化数据时,从“繁琐的类型检查和取值”变成了“优雅的模式匹配和解构”。这是 Java 迈向**面向数据编程(Data-Oriented Programming)**的重要一步。


7. Record 与密封类(Sealed Classes)的配合

JDK 17 引入了密封类(Sealed Classes),它允许你控制哪些类可以继承一个父类或实现一个接口。

Record 虽然不能继承普通类,但它可以实现接口。这意味着你可以用“密封接口 + Record 实现类”的组合,构建出封闭的、类型安全的代数数据类型(ADT)

// 密封接口:只允许 Point、Circle、Rectangle 实现
sealed interface Shape permits Point, Circle, Rectangle {}

// 用 Record 实现密封接口
record Point(int x, int y) implements Shape {}
record Circle(Point center, int radius) implements Shape {}
record Rectangle(double width, double height) implements Shape {}

// 使用时,switch 可以穷尽所有情况
double area = switch (shape) {
    case Point(int x, int y) -> 0;  // 点没有面积
    case Circle(Point center, int r) -> Math.PI * r * r;
    case Rectangle(double w, double h) -> w * h;
};

因为 Shape 是密封的,编译器知道所有可能的子类型就是 PointCircleRectangle 这三个。如果你在 switch 中漏掉了任何一个,编译器会报错提醒你。这在处理复杂的数据结构时非常安全。

📌 Record 不能 extends 其他类,但可以 implements 接口。所以“密封类 + Record”的正确组合方式是密封接口 + Record 实现类


8. Record 的序列化

Record 天然支持序列化(只要实现 Serializable 接口),而且它的序列化机制比普通类更简单、更安全

普通类的序列化会写入整个对象图,还可能涉及 readObjectwriteObject 等自定义方法,容易出错。而 Record 的序列化只基于它的组件,反序列化时也只使用主构造方法

import java.io.*;

public record User(String name, int age) implements Serializable {}

// 序列化和反序列化
try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("user.dat"))) {
    oos.writeObject(new User("张三", 25));
}

try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream("user.dat"))) {
    User user = (User) ois.readObject();
    System.out.println(user);  // User[name=张三, age=25]
}

⚠️ 注意:Record 不支持自定义序列化方法(如 writeObjectreadObject),这是设计使然——保持简单和透明。如果需要更灵活的序列化,可以考虑 JSON 等格式。


9. Record 的最佳实践

✅ 推荐做法:

  1. 用 Record 表示值对象:坐标、金额、区间、邮箱地址等“由数据定义身份”的类型。
  2. 用 Record 作为 DTO:API 的请求/响应对象、数据库查询结果映射。
  3. 用 Record 作为方法的多个返回值:比 Object[]Map 类型安全得多。
  4. 在 Record 中做数据校验:用紧凑构造方法保证数据合法性。
  5. 给 Record 添加派生方法:比如 Point.distanceTo()Range.contains()
  6. 配合密封接口构建 ADT:用 Record 实现密封接口,配合 Record Patterns 做穷尽匹配。

❌ 避免的做法:

  1. 不要把 Record 当成“万能类”:如果有复杂业务逻辑、可变状态、需要继承,用普通类。
  2. 不要在 Record 中添加非静态字段:编译器不允许,而且违背了 Record 的设计初衷。
  3. 不要依赖 ordinal() 做业务逻辑:这是给 Enum 用的,Record 没有这个概念。
  4. 不要在 Record 中塞入过多的组件:超过 5-6 个字段时,考虑是否真的需要这么多数据,或者是否可以拆分成更小的 Record。

10. 综合示例 —— 用 Record 重构订单系统

让我们把今天学的内容整合到一个完整的例子中:一个订单系统的核心数据模型,用 Record + 密封接口 + Record Patterns。

import java.time.LocalDateTime;
import java.util.List;

// ========== 密封接口:订单状态 ==========
sealed interface OrderStatus permits Pending, Paid, Shipped, Delivered, Cancelled {}

// 每个状态用 Record 表示,携带该状态特有的数据
record Pending(LocalDateTime createdAt) implements OrderStatus {}
record Paid(LocalDateTime paidAt, String transactionId) implements OrderStatus {}
record Shipped(LocalDateTime shippedAt, String trackingNumber) implements OrderStatus {}
record Delivered(LocalDateTime deliveredAt) implements OrderStatus {}
record Cancelled(LocalDateTime cancelledAt, String reason) implements OrderStatus {}

// ========== 订单项 Record ==========
record OrderItem(String productId, String productName, int quantity, double price) {
    // 紧凑构造方法:校验数据
    public OrderItem {
        if (productId == null || productId.trim().isEmpty()) {
            throw new IllegalArgumentException("商品ID不能为空");
        }
        if (quantity <= 0) {
            throw new IllegalArgumentException("数量必须大于0");
        }
        if (price < 0) {
            throw new IllegalArgumentException("价格不能为负数");
        }
    }

    // 派生方法:计算小计
    public double subtotal() {
        return quantity * price;
    }
}

// ========== 订单 Record ==========
public record Order(String orderId, String customerId, List<OrderItem> items, 
                    OrderStatus status, LocalDateTime updatedAt) {

    // 紧凑构造方法:校验
    public Order {
        if (orderId == null || orderId.trim().isEmpty()) {
            throw new IllegalArgumentException("订单ID不能为空");
        }
        if (items == null || items.isEmpty()) {
            throw new IllegalArgumentException("订单至少包含一个商品");
        }
    }

    // 派生方法:计算订单总额
    public double total() {
        return items.stream()
            .mapToDouble(OrderItem::subtotal)
            .sum();
    }

    // 派生方法:判断订单是否可取消
    public boolean isCancellable() {
        return switch (status) {
            case Pending _ -> true;
            case Paid _ -> true;
            case Shipped _ -> false;
            case Delivered _ -> false;
            case Cancelled _ -> false;
        };
    }

    // 创建订单的工厂方法
    public static Order create(String orderId, String customerId, List<OrderItem> items) {
        return new Order(orderId, customerId, items, 
                         new Pending(LocalDateTime.now()), 
                         LocalDateTime.now());
    }

    // 状态变更方法(返回新订单,保持不可变)
    public Order markAsPaid(String transactionId) {
        return new Order(orderId, customerId, items, 
                         new Paid(LocalDateTime.now(), transactionId), 
                         LocalDateTime.now());
    }

    public Order markAsShipped(String trackingNumber) {
        return new Order(orderId, customerId, items, 
                         new Shipped(LocalDateTime.now(), trackingNumber), 
                         LocalDateTime.now());
    }
}

// ========== 主程序 ==========
public class Main {
    public static void main(String[] args) {
        // 1. 创建订单
        var items = List.of(
            new OrderItem("P001", "Java编程思想", 2, 99.0),
            new OrderItem("P002", "设计模式", 1, 79.0)
        );
        Order order = Order.create("ORD-001", "CUST-001", items);

        System.out.println("=== 新订单 ===");
        System.out.println(order);
        System.out.println("订单总额:" + order.total());
        System.out.println("是否可取消:" + order.isCancellable());

        // 2. 支付
        Order paidOrder = order.markAsPaid("TXN-12345");
        System.out.println("\n=== 支付后 ===");
        System.out.println(paidOrder);
        System.out.println("是否可取消:" + paidOrder.isCancellable());

        // 3. 使用 Record Patterns 处理状态
        System.out.println("\n=== 状态详情 ===");
        String statusDetail = switch (paidOrder.status()) {
            case Pending(var createdAt) -> "待支付,创建于 " + createdAt;
            case Paid(var paidAt, var txnId) -> "已支付,交易号 " + txnId + ",支付时间 " + paidAt;
            case Shipped(var shippedAt, var tracking) -> "已发货,运单号 " + tracking + ",发货时间 " + shippedAt;
            case Delivered(var deliveredAt) -> "已送达,送达时间 " + deliveredAt;
            case Cancelled(var cancelledAt, var reason) -> "已取消,原因:" + reason + ",取消时间 " + cancelledAt;
        };
        System.out.println(statusDetail);
    }
}

这个例子展示了:

  • Record 作为数据载体OrderOrderItem、以及各个状态类。
  • 紧凑构造方法做校验OrderItemOrder 都在创建时验证数据合法性。
  • 派生方法total()isCancellable() 由数据计算得出。
  • 不可变状态转换markAsPaid()markAsShipped() 返回新对象,不修改原对象。
  • 密封接口 + Record 实现OrderStatus 的所有可能状态都是 Record。
  • Record Patterns:在 switch 中解构状态对象,直接取出其中的数据。

11. 今天的总结

今天我们深入学习了 Record:

  • Record 是什么:用一行代码定义不可变数据类的语法糖,自动生成构造方法、访问器、toStringequalshashCode
  • Record vs 普通类:Record 不可变、不能继承、只能有组件字段,适合纯数据载体。
  • 紧凑构造方法:在 Record 内部做数据校验和规范化,编译器自动完成赋值。
  • 给 Record 添加方法:实例方法和静态方法都可以,适合派生计算。
  • Record Patterns(JDK 21):在 instanceofswitch 中直接解构 Record 的组件,支持嵌套解构。
  • Record + 密封接口:用 Record 实现密封接口,构建封闭的类型层次,配合 Record Patterns 实现穷尽匹配。
  • 序列化:Record 天然支持序列化,机制简单安全。
  • 最佳实践:用在 DTO、值对象、方法多返回值;复杂业务逻辑用普通类。

Record 是 Java 在“数据建模”领域的一次重要进化。它让“装数据”这件事变得前所未有的轻松,同时与模式匹配、密封类等新特性协同,为 Java 带来了更现代的编程范式。


动手试试

  1. 把你之前写的 PersonStudent 等类改写成 Record,感受代码量的变化。
  2. 定义一个 Range Record,包含 startend 两个 int 组件。添加紧凑构造方法,确保 start <= end。再添加一个 contains(int value) 方法,判断某个值是否在区间内。
  3. 定义一个密封接口 Result,用 Record 实现 Success(携带数据 T data)和 Failure(携带错误信息 String error)。写一个方法返回 Result,在调用方用 switch + Record Patterns 处理两种情况。
  4. (挑战)结合第 16 篇的计算器项目,用 Record 重构计算结果。定义一个 CalculationResult Record,包含 expressionresulterror 三个组件,其中 error 可以为 null。这样 calculate 方法可以直接返回 CalculationResult,比之前用自定义类更简洁。

Record 的内容就讲到这里。下一篇文章,我们进入一个新的话题——包与导入。虽然我们一直在用 import,但它的细节和背后的模块化系统,值得专门聊一聊。

我们下一篇见。😃

📌 获取本系列示例代码请访问 GitCode

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值