本节目标:能判断一个类是否真的不可变,能用防御性复制保护集合字段,并知道什么时候该用「返回新对象」代替 setter。
适用版本:JDK 25 LTS(Temurin 25.0.5);List.copyOf在 JDK 10 引入,JDK 21 与 25 行为一致。
上一节留下的问题
7.2 把 Book 的身份字段收成了 private final,构造器也做了校验。但还有两个口子:
private boolean available = true;
public void markBorrowed() { available = false; }
public void markReturned() { available = true; }
第一,available 仍然可变,任何拿到 Book 的代码都能调用 markReturned(),把一本已借出的书标成可借,而 LibraryService.loans 里还留着借阅记录。第二,Member 目前只有一个 email,还没有表达「追加一个邮箱」的受控方法;如果直接把它改成 List<String> 字段并配一个 getter,又会把可变集合泄漏出去。
更根本的问题是:「借出」真的是书的内在属性吗? 一本书的 ISBN、书名、作者不会因为借阅而变;「当前是否被借出」取决于有没有一条借阅记录,以及是谁借的。把它塞进 Book,等于让书自己记录一段它并不拥有的关系。本节先讲封装和不可变的原则,再用这个判断重构 Book。
封装不是「private 加 getter/setter」
封装(encapsulation)的目标是让对象自己守住不变量:外部只能通过你设计的方法改变状态,每个方法都能在改动前检查规则。
一个常见的伪封装是这样的:
private int publishedYear;
public int getPublishedYear() { return publishedYear; }
public void setPublishedYear(int year) { this.publishedYear = year; }
字段是 private 了,但 setter 把修改权原样交了出去,还没有任何校验。调用方仍能写 book.setPublishedYear(-1),和公开字段没有本质区别,只是多了一层方法调用。
真正的封装要做三件事:
- 字段私有。 外部不能直接读写。
- 入口收窄。 只暴露业务需要的方法,不为了「对称」给每个字段配 getter/setter。
- 不变量集中。 所有修改都经过同一处校验,不存在绕过路径。
对不可变对象,连 setter 都不需要:想改,就创建一个新对象。这样「谁能改、什么时候改」变成了「谁持有哪个版本」,比共享一个可变对象容易推理得多。
不可变对象的四个条件
一个类要称得上不可变,至少满足四条:
- 类本身不能被继承(
final class),否则子类可以加可变字段或重写方法。 - 所有字段
private final,构造完成后不再重新赋值。 - 不提供任何修改状态的方法,包括 setter 和
markXxx之类的变更方法。 - 不泄漏可变内部状态:构造时对传入的可变对象做防御性复制,访问器不返回内部可变对象。
前三条是语法层面的,第四条最容易被忽略。final 只约束引用不变:
private final List<String> emails = new ArrayList<>();
emails 永远指向同一个 ArrayList,但 emails.add(...)、emails.clear() 都能改内容。final 引用不等于不可变对象。
用 List.copyOf 做防御性复制
public Member(String id, String name, List<String> emails) {
this.id = requireText(id, "id");
this.name = requireText(name, "name");
this.emails = List.copyOf(emails);
}
List.copyOf 返回一个不可修改的列表;如果传入的是可变集合,它会复制元素,切断和原集合的联系。调用方之后修改原列表,不会影响 Member;Member.emails() 返回的列表也不能被 add。
本机 JDK 25 的对照实验:
List<String> original = new ArrayList<>(List.of("ada@example.com"));
List<String> copy = List.copyOf(original);
original.add("grace@example.com");
System.out.println("original = " + original);
System.out.println("copy = " + copy);
copy.add("alan@example.com");
original = [ada@example.com, grace@example.com]
copy = [ada@example.com]
Exception in thread "main" java.lang.UnsupportedOperationException
at java.base/java.util.ImmutableCollections.uoe(ImmutableCollections.java:159)
at java.base/java.util.ImmutableCollections$AbstractImmutableCollection.add(ImmutableCollections.java:164)
at CopyOfImmutable.main(CopyOfImmutable.java:11)
三行输出说明三件事:原列表加了元素,副本不变(复制);对副本 add 抛 UnsupportedOperationException(不可修改)。List.copyOf 还会拒绝 null 元素,传入含 null 的列表会直接抛 NullPointerException。
unmodifiableList 是视图,不是复制
Collections.unmodifiableList(original) 只包了一层「不许改」的壳,底层仍然是 original:
List<String> view = Collections.unmodifiableList(original);
original.add("grace@example.com");
System.out.println(view);
[ada@example.com, grace@example.com]
通过 view 不能改,但通过 original 可以,改动会从 view 里透出来。要切断联系用 List.copyOf,要只读视图用 unmodifiableList。 封装字段时通常需要前者。
把借出状态从 Book 移走
按「不变量属于谁」来判断:available 的变化总是和一条借阅记录同时发生——借出时 available = false 且 loans.put(isbn, memberId),归还时两者一起撤销。它们是一组状态,应该放在同一个地方。Book 只保留「书是什么」,借阅状态放到 LibraryService:
private final Set<String> onLoan = new HashSet<>();
private final Map<String, String> loans = new HashMap<>();
public boolean borrow(String isbn, String memberId) {
Objects.requireNonNull(memberId, "memberId");
Book book = findByIsbn(isbn);
if (book == null || onLoan.contains(isbn)) {
return false;
}
onLoan.add(isbn);
loans.put(isbn, memberId);
return true;
}
public boolean giveBack(String isbn) {
if (!onLoan.remove(isbn)) {
return false;
}
loans.remove(isbn);
return true;
}
public boolean isAvailable(String isbn) {
return findByIsbn(isbn) != null && !onLoan.contains(isbn);
}
onLoan 是判断「是否借出」的唯一依据,loans 记录借阅人。两者在 borrow/giveBack 里一起改,不会出现「书标成可借、借阅记录还在」的中间状态。
Book 相应地去掉 available,所有字段 final,类声明为 final:
public final class Book {
public static final int UNKNOWN_YEAR = 0;
private final String isbn;
private final String title;
private final String author;
private final int publishedYear;
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(String isbn, String title, String author, int publishedYear) {
this.isbn = requireText(isbn, "isbn");
this.title = requireText(title, "title");
this.author = requireText(author, "author");
if (publishedYear < 0) {
throw new IllegalArgumentException("publishedYear < 0: " + publishedYear);
}
this.publishedYear = publishedYear;
}
public String isbn() { return isbn; }
public String title() { return title; }
public String author() { return author; }
public int publishedYear() { return publishedYear; }
public Book withTitle(String newTitle) {
return new Book(isbn, newTitle, author, publishedYear);
}
public boolean isSameEdition(Book other) {
return other != null && isbn.equals(other.isbn);
}
public String describe() {
return title + " / " + author + " (" + publishedYear + ")";
}
private static String requireText(String value, String name) {
Objects.requireNonNull(value, name);
if (value.isBlank()) {
throw new IllegalArgumentException(name + " must not be blank");
}
return value.strip();
}
}
withTitle 是 wither 写法:不修改原对象,返回一个改了书名的副本。原对象仍然可用,两个版本可以同时存在。调用方想「换书名」时,拿到的是新引用,不会影响别人手里的旧引用。
这里没有 setTitle。不是忘了写,而是刻意不提供:如果书名可以随便改,那么按书名建立的缓存、已经打印的借阅记录,以及将来按字段实现 equals/hashCode 后用作键的 Map/Set 都会失去一致性。 需要修订时创建新版本,旧版本保持原样,谁引用哪个版本一目了然。
Member 的可变集合
Member 的 emails 是列表,最容易泄漏。完整写法:
public final class Member {
private final String id;
private final String name;
private final List<String> emails;
public Member(String id, String name) {
this(id, name, List.of());
}
public Member(String id, String name, List<String> emails) {
this.id = requireText(id, "id");
this.name = requireText(name, "name");
this.emails = List.copyOf(emails);
}
public String id() { return id; }
public String name() { return name; }
public List<String> emails() { return emails; }
public Member withEmail(String email) {
List<String> next = new ArrayList<>(emails);
next.add(requireText(email, "email"));
return new Member(id, name, next);
}
public String contactLine() {
return name + " <" + String.join(", ", emails) + ">";
}
private static String requireText(String value, String name) {
Objects.requireNonNull(value, name);
if (value.isBlank()) {
throw new IllegalArgumentException(name + " must not be blank");
}
return value.strip();
}
}
三个关键点:
- 构造时复制。
List.copyOf(emails)切断和调用方列表的联系,调用方之后改原列表不影响Member。 - 访问器直接返回。
emails本身就是不可修改列表,返回它不需要再复制;如果底层是可变的,就必须返回副本。 withEmail返回新对象。 先复制现有邮箱,追加一个,再交给构造器;原Member不变。多次添加会创建多个版本,不会出现「共享列表被并发修改」的问题。
equals/hashCode 的契约
不可变对象经常被放进 HashSet 或作为 HashMap 的键。这时 equals 和 hashCode 必须遵守两条契约:
- 两个对象
equals为true,hashCode必须相等。 equals必须自反、对称、传递;对null返回false。
只重写 equals 不重写 hashCode,会破坏第一条。本机 JDK 25 运行一个只重写 equals 的 Key:
Set<Key> keys = new HashSet<>();
keys.add(new Key("978-7-111-1"));
keys.add(new Key("978-7-111-1"));
System.out.println("keys = " + keys.size());
keys = 2
两个对象 equals 为 true,但默认 hashCode 通常不同(这次运行就落在不同的桶里),HashSet 认为它们是两个元素。补上基于 isbn 的 hashCode 后,结果才会是 1。
Book 和 Member 本节都没有重写 equals。Book 用 isSameEdition 表达「同一版」,而不是把所有字段都算进相等性——两本同名同作者但出版年不同的书,算不算同一本,取决于业务。第 9 章改成 record 后,编译器会按所有字段生成 equals/hashCode/toString;到那时要确认「全字段相等」正是想要的语义,否则需要在 record 里显式重写。
在 library 里落地
把三块拼起来:不可变 Book、不可变 Member、用 onLoan 管理借出的 LibraryService。LibraryApp 的 list 命令根据 isAvailable 打印状态:
private void handleList() {
for (Book book : service.books()) {
String state = service.isAvailable(book.isbn()) ? "available" : "borrowed";
System.out.printf("%s [%s]%n", book.describe(), state);
}
System.out.println("books = " + service.count());
}
编译并回放:
javac -d out src/com/plume/library/*.java
java -cp out com.plume.library.LibraryApp
输入与输出(本机 JDK 25):
add 978-7-111-1|Effective Java|Joshua Bloch|2008
added: Effective Java
add 978-7-115-2|Java Concurrency in Practice|Brian Goetz|2006
added: Java Concurrency in Practice
list
Effective Java / Joshua Bloch (2008) [available]
Java Concurrency in Practice / Brian Goetz (2006) [available]
books = 2
borrow 978-7-111-1 m-001
borrowed: 978-7-111-1 -> m-001
list
Effective Java / Joshua Bloch (2008) [borrowed]
Java Concurrency in Practice / Brian Goetz (2006) [available]
books = 2
borrow 978-7-111-1 m-002
cannot borrow: 978-7-111-1
return 978-7-111-1
returned: 978-7-111-1
list
Effective Java / Joshua Bloch (2008) [available]
Java Concurrency in Practice / Brian Goetz (2006) [available]
books = 2
bye
借出后 Book 对象没有任何变化,describe() 输出的字段和借出前一样;变化只在服务的 onLoan 集合里。归还后状态恢复。books = 2 始终不变——借出不会删除书,也不会创建新书。
这种拆分还带来一个测试上的好处:Book 的构造和 withTitle 可以单独测试,不需要构造 LibraryService;借还规则可以在服务层测试,不需要解析 CLI 输入。第 16 章会把这两类测试分开写。
常见错误与边界
以为 final 字段就不可变。 private final List<String> emails = new ArrayList<>() 的引用不能换,内容可以随便改。要么在构造时 List.copyOf,要么在访问器里返回副本。
访问器返回内部可变集合。 public List<String> emails() { return emails; } 如果 emails 是 ArrayList,调用方可以 clear()。6.2 的 Shelf 已经演示过:private 挡不住 getter 返回的引用。返回 List.copyOf(emails) 或直接持有不可变列表。
用 unmodifiableList 当防御性复制。 它只是视图,原列表改动会透出来。需要切断联系时用 List.copyOf 或 new ArrayList<>(...) 再包一层。
给每个字段配 setter。 setter 把修改权交出去,还绕过了不变量校验。需要修改时,提供表达业务意图的方法(withTitle、rename),在方法里校验并返回新对象或明确地改状态。
只重写 equals 不重写 hashCode。 HashSet 会出现重复元素,HashMap 可能找不到刚放进去的键。两者要么一起重写,要么都不重写。
把可变对象放进 HashSet 后修改它。 hashCode 变了,元素还在原来的桶里,contains 却找不到。不可变对象没有这个问题,这也是它们适合做键的原因。
在构造器里保存传入的数组或集合引用。 this.emails = emails; 让外部仍能通过原引用修改内部状态。构造器里做复制,是封装的最后一道门。
小结
- 封装的目标是守住不变量,不是「private + getter/setter」;入口要收窄,校验要集中。
- 不可变对象需要:
final class、private final字段、无修改方法、对可变组件做防御性复制。 final只约束引用;List.copyOf复制并返回不可修改列表,unmodifiableList只是视图。- 借出状态不属于
Book的内在属性,移到LibraryService的onLoan/loans后,书的字段可以全部final。 withTitle、withEmail返回新对象,旧版本保持不变;修改通过创建新版本表达,而不是 setter。equals与hashCode必须成对重写;不可变字段让hashCode稳定,适合作为HashSet/HashMap的键。library的领域对象现在一创建就合法、创建后不再改变;第 8 章会在这个基础上讲继承和接口,第 9 章用record把这套写法压缩成几行。
第 7 章把「对象」讲完了:类、构造器、封装、不可变。下一章换一个复用维度——当 Book 和 Member 有共同行为时,继承和接口各自适合什么,super 到底该在什么时候用。
阅读导航:上一节:7.2 构造器、this 与初始化顺序 · 下一节:8.1 继承与 super:复用不是目的 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。