本节目标:能用
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:把常量写成类型 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。