《Java 编程入门》9.1 record:数据载体的最短写法

把第 7 章手写的 Book、Member 换成 record:看编译器自动生成访问器、equals、hashCode 与 toString,用紧凑构造器保留校验,处理 record 不能被继承后 StaffMember 的迁移,并用可变组件共享引用、紧凑构造器直接赋字段、继承最终类三个真实边界说明 record 的限制。

本节目标:能用 record 替掉数据载体里的样板代码,能用紧凑构造器保留参数校验,能判断什么时候不该用 record,并完成 Book、Member、Loan 的迁移。
适用版本:JDK 25 LTS(Temurin 25.0.5);record 在 JDK 21 上已是正式特性。

一个数据载体到底要写多少行

7.3 的 Book 已经是一个标准的不可变类:四个 private final 字段、三个构造器、isbn()/title()/author()/publishedYear() 访问器,以及 withTitle、isSameEdition、describe。它没有手写 equals、hashCode、toString,因为 7.3 用 isSameEdition 表达「同一版」,而不是把所有字段都算进相等性。第 10 章要把 Book 放进 Map 的 value、把 ISBN 当 key;如果相等性写漏了字段,查找会静默失败。

Member 也一样。它有 id、name、emails 三个字段,构造时用 List.copyOf 保护邮箱列表,访问器是 id()、name()、emails()。这些方法没有业务含义,却必须写对,尤其是 equals 和 hashCode。

这类「字段就是全部状态」的类,Java 用 record 给出了更短的写法。record 不是语法糖那么简单:它把「不可变数据载体」这件事变成语言层面的承诺,编译器替你生成访问器、equals、hashCode、toString,并且禁止继承。

record 的最小形状

把 Book 写成 record:

package com.plume.library.domain;

import java.util.Objects;

public record Book(String isbn, String title, String author, int publishedYear) {
    public static final int UNKNOWN_YEAR = 0;

    public Book(String isbn, String title) {
        this(isbn, title, "unknown", UNKNOWN_YEAR);
    }

    public Book(String isbn, String title, String author) {
        this(isbn, title, author, UNKNOWN_YEAR);
    }

    public Book {
        isbn = Objects.requireNonNull(isbn, "isbn").strip();
        title = Objects.requireNonNull(title, "title").strip();
        author = Objects.requireNonNull(author, "author").strip();
        if (isbn.isEmpty()) {
            throw new IllegalArgumentException("isbn 不能为空");
        }
        if (title.isEmpty()) {
            throw new IllegalArgumentException("title 不能为空");
        }
        if (author.isEmpty()) {
            throw new IllegalArgumentException("author 不能为空");
        }
        if (publishedYear < 0) {
            throw new IllegalArgumentException("publishedYear < 0: " + publishedYear);
        }
    }

    public String describe() {
        return title + " / " + author + " (" + publishedYear + ")";
    }
}

头部括号里的四个名字叫组件(component)。编译器会生成:

  • 四个 private final 字段,名字与组件相同。
  • 四个访问器,方法名就是组件名:isbn()、title()、author()、publishedYear()。
  • 一个规范构造器(canonical constructor),参数顺序与组件一致。
  • equals、hashCode、toString,基于全部组件。

用起来是这样:

Book first = new Book("9787111213826", "  Java 编程思想  ", "Bruce Eckel", 2007);
Book second = new Book("9787111213826", "Java 编程思想", "Bruce Eckel", 2007);

System.out.println("toString: " + first);
System.out.println("equals: " + first.equals(second));
System.out.println("hashCode 相同: " + (first.hashCode() == second.hashCode()));
System.out.println("accessor: " + first.title() + " / " + first.describe());

本机 JDK 25 输出:

toString: Book[isbn=9787111213826, title=Java 编程思想, author=Bruce Eckel, publishedYear=2007]
equals: true
hashCode 相同: true
accessor: Java 编程思想 / Java 编程思想 / Bruce Eckel (2007)

注意三点:

  • 访问器由组件自动生成,名字就是组件名。 7.3 已经用 isbn()、title() 这套命名,所以迁移时 Book 和 Member 的调用点基本不用改;record 省掉的是手写方法,不是改名。
  • toString 的格式是 类名[组件=值, ...]。 它适合日志和调试,不适合当协议输出;对外 JSON 要单独序列化。
  • equals 比较全部组件。 两个 Book 只要四个字段都相等就相等;hashCode 也按同样字段计算,所以放进 HashSet 能正确去重。注意 7.3 的 isSameEdition 只按 ISBN 判断同一版;如果业务上「同一 ISBN 即相等」,要在 record 里显式覆写 equals/hashCode,不能直接依赖自动生成。

Book 里仍然可以写普通方法,比如 describe();7.3 的 withTitle、isSameEdition 也可以原样保留。record 也可以实现接口,第 10 章会让它实现 Comparable<Book>。record 限制的是「状态」和「继承」,不是「行为」。

紧凑构造器:校验仍然要做

record 自动生成的规范构造器只做赋值,不做校验。如果 ISBN 是空串、出版年是 -1,对象照样能创建。要保留 7.3 的校验,就用上面那个紧凑构造器;它没有参数列表,参数从组件自动推导。它有两个容易记错的规则:

  • 在紧凑构造器里给参数重新赋值,编译器会在末尾把参数赋给字段。 所以上面写 isbn = isbn.strip() 是有效的;不要写 this.isbn = ...,紧凑构造器里不允许直接给字段赋值。
  • 校验失败就抛异常,对象不会创建。 这比「创建完再调用 validate()」可靠,因为调用方拿不到半成品。

运行两条非法输入:

try {
    new Book(" ", "空白 ISBN", "某人", 2000);
} catch (IllegalArgumentException e) {
    System.out.println("compact constructor: " + e.getMessage());
}
try {
    new Book("9780000000000", "负年份", "某人", -1);
} catch (IllegalArgumentException e) {
    System.out.println("compact constructor: " + e.getMessage());
}

本机 JDK 25 输出:

compact constructor: isbn 不能为空
compact constructor: publishedYear < 0: -1

7.3 的两个少参数构造器(只给 ISBN 和标题,或再给作者)也用 this(...) 委托给规范构造器,见上面的代码;this(...) 必须是第一条语句。record 不允许像普通类那样先做一堆计算再委托。

从 StaffMember 到 MemberType

8.1 为了演示继承,暂时去掉了 7.3 Member 的 final。现在把 Member 改成 record,final 会以更强的形式回来:record 不能被继承。

如果还写:

public final class StaffMember extends Member {
    public StaffMember(String id, String name, String department) {
        super(id, name);
    }
}

本机 javac 25.0.5 报:

错误: 无法从最终Member进行继承
public final class StaffMember extends Member {
                                       ^

这不是编译器刁难,而是 record 的设计目标:record 表示「值」,值不应该有子类型层次。员工和普通会员的差异应该写成数据,而不是写成类型。把差异放进一个字段:

package com.plume.library.domain;

public enum MemberType {
    READER(3, 14),
    STAFF(10, 30);

    private final int borrowLimit;
    private final int loanDays;

    MemberType(int borrowLimit, int loanDays) {
        this.borrowLimit = borrowLimit;
        this.loanDays = loanDays;
    }

    public int borrowLimit() {
        return borrowLimit;
    }

    public int loanDays() {
        return loanDays;
    }
}

Member 随之变成四个组件,并保留 7.3 的 emails 和三个构造器:

package com.plume.library.domain;

import java.util.List;
import java.util.Objects;

public record Member(String id, String name, List<String> emails, MemberType type) {

    public Member {
        id = requireText(id, "id");
        name = requireText(name, "name");
        emails = List.copyOf(emails);
        type = Objects.requireNonNull(type, "type");
    }

    public Member(String id, String name, MemberType type) {
        this(id, name, List.of(), type);
    }

    public Member(String id, String name) {
        this(id, name, List.of(), MemberType.READER);
    }

    public Member(String id, String name, List<String> emails) {
        this(id, name, emails, MemberType.READER);
    }

    public int borrowLimit() {
        return type.borrowLimit();
    }

    public int loanDays() {
        return type.loanDays();
    }

    public String describe() {
        return name + "(" + id + ")";
    }

    public String contactLine() {
        return name + " <" + String.join(", ", emails) + ">";
    }

    private static String requireText(String value, String field) {
        String text = Objects.requireNonNull(value, field).strip();
        if (text.isEmpty()) {
            throw new IllegalArgumentException(field + " 不能为空");
        }
        return text;
    }
}

调用方从「传 StaffMember 对象」变成「传 Member 对象,类型字段是 STAFF」:

Member reader = new Member("M-001", "林一", MemberType.READER);
Member staff = new Member("S-001", "周岚", MemberType.STAFF);
System.out.printf("%s limit=%d days=%d%n", reader.describe(), reader.borrowLimit(), reader.loanDays());
System.out.printf("%s limit=%d days=%d%n", staff.describe(), staff.borrowLimit(), staff.loanDays());

本机 JDK 25 输出:

林一(M-001) limit=3 days=14
周岚(S-001) limit=10 days=30

借阅额度和借期没变;emails 由 record 自动生成的 emails() 访问器暴露,List.copyOf 保证它不可修改;7.3 的 withEmail 也可以按同样方式保留:复制邮箱、追加一个、再构造新 record。record 的 equals 也按 id、name、emails、type 全部组件,若业务只按 id 判等,需要显式覆写。MemberType 本身是枚举,9.2 会把枚举的字段、构造器、values() 和 EnumSet 讲全,并给 Loan 加上 LoanStatus。这里先记住迁移的代价:继承能表达「类型差异」,record 只能表达「数据差异」。 当差异本来就只有几个字段时,record + 枚举通常更简单;当差异涉及完全不同的行为时,才需要重新考虑类型层次。

record 不是万能的

record 的自动方法让代码变短,但它有三个硬限制:

  • 不能被继承。 因此也不能声明为 abstract。
  • 不能声明额外的实例字段。 只能有组件对应的字段;static 字段可以。
  • 组件本身不会自动深拷贝。 如果组件是 List、数组、可变对象,record 只保存引用。

第三条最容易出事。看一个书架:

record Shelf(List<Book> books) {
}

record SafeShelf(List<Book> books) {
    SafeShelf {
        books = List.copyOf(books);
    }
}
List<Book> source = new ArrayList<>();
source.add(new Book("9787536692930", "三体", "刘慈欣", 2008));

Shelf shelf = new Shelf(source);
SafeShelf safeShelf = new SafeShelf(source);
source.add(new Book("9787020024759", "红楼梦", "曹雪芹", 1791));

System.out.println("Shelf size = " + shelf.books().size());
System.out.println("SafeShelf size = " + safeShelf.books().size());

本机 JDK 25 输出:

Shelf size = 2
SafeShelf size = 1

Shelf 保存的是同一个 ArrayList 引用,外部改列表,书架跟着变;SafeShelf 在构造时用 List.copyOf 做了一份不可变副本,外部再改也影响不到它。record 只保证「引用不变」,不保证「引用指向的对象不变」。 要真正不可变,组件用不可变类型,或者在紧凑构造器里做防御性拷贝。

同理,数组组件要 clone(),Date 要换成 LocalDate 或 Instant,自定义可变类要么改成 record,要么在构造器里拷贝。

在 library 里落地

Book、Member 迁移完,Loan 也可以改成 record。它现阶段有四个组件:

package com.plume.library.domain;

import java.time.LocalDate;
import java.util.Objects;

public record Loan(String isbn, String memberId, LocalDate borrowedOn, LocalDate dueOn) {

    public Loan {
        isbn = Objects.requireNonNull(isbn, "isbn");
        memberId = Objects.requireNonNull(memberId, "memberId");
        borrowedOn = Objects.requireNonNull(borrowedOn, "borrowedOn");
        dueOn = Objects.requireNonNull(dueOn, "dueOn");
        if (!dueOn.isAfter(borrowedOn)) {
            throw new IllegalArgumentException("dueOn 必须晚于 borrowedOn");
        }
    }

    public boolean isOverdue(LocalDate today) {
        return today.isAfter(dueOn);
    }

    public Loan withDueOn(LocalDate newDueOn) {
        return new Loan(isbn, memberId, borrowedOn, newDueOn);
    }
}

record 不可变,续借不能改 dueOn,只能返回一个新对象:

Loan loan = service.borrow("9787536692930", reader, today);
System.out.println("loan: " + loan);
Loan renewed = loan.withDueOn(loan.dueOn().plusDays(14));
System.out.println("renewed: " + renewed);
System.out.println("overdue on 2026-11-10: " + renewed.isOverdue(LocalDate.of(2026, 11, 10)));

本机 JDK 25 输出:

loan: Loan[isbn=9787536692930, memberId=M-001, borrowedOn=2026-10-12, dueOn=2026-10-26]
renewed: Loan[isbn=9787536692930, memberId=M-001, borrowedOn=2026-10-12, dueOn=2026-11-09]
overdue on 2026-11-10: true

这种「改一个字段就返回新对象」的方法通常叫 withXxx。它比 setter 安全:原对象还在,任何持有旧引用的代码都不会被悄悄改掉。第 9.2 节会给 Loan 加上状态和归还日期,届时 renew 会变成有规则的状态迁移,而不只是改日期。

Book 和 Member 的调用点不用改:7.3 的访问器已经是 isbn()、title()、id()、name(),record 只是把它们变成自动生成。真正要改的是 8.1 的 Loan:它从普通类变成 record,getIsbn()、getMemberId()、getDueOn() 要分别改成 isbn()、memberId()、dueOn();8.2 的 FinePolicy、仓储实现以及 8.1 的 FineCalculator 里如果漏改,编译器会报「找不到符号」,并指出 getDueOn() 不存在。

常见错误与边界

在紧凑构造器里给字段赋值。 写 this.isbn = isbn 会编译失败;紧凑构造器只能改参数,编译器负责把参数赋给字段。

组件用可变类型却不拷贝。 上面的 Shelf 就是例子。record 的 equals/hashCode 也会跟着组件变:如果组件是可变 List,对象放进 HashSet 后修改列表,hashCode 变化,集合就再也找不到它。

小结

  • record 适合「字段就是全部状态」的不可变数据载体,自动生成访问器、equals、hashCode、toString。
  • 访问器名是组件名(title());7.3 已经采用这套命名,record 省掉的是手写样板,不是访问器改名。Loan 从普通类迁移时,getter 才需要改。
  • 紧凑构造器用来保留校验和规范化,给参数重新赋值即可,不能直接给字段赋值。
  • record 是 final 的,不能被继承;8.1 的 StaffMember 层次改成 MemberType 字段。
  • record 只保证引用不变,不保证引用对象不变;List、数组等组件要在构造器里做防御性拷贝。
  • Loan 改成 record 后,续借用 withDueOn 返回新对象,而不是原地修改。

Member 里的 MemberType 只是一个开始。下一节把 LoanStatus 写成完整的枚举,讲清枚举为什么不是「一组常量」,以及怎么用它把借阅状态迁移写穷尽。

阅读导航:上一节:8.3 多态与动态分派 · 下一节:9.2 enum:把常量写成类型 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发