《Java 编程入门》7.3 封装与不可变

把 Book 和 Member 收成真正的不可变对象:private final 字段、无 setter、withX 返回新实例、List.copyOf 防御性复制;再把借出状态从 Book 移到 LibraryService 的 onLoan 集合,并用 List.copyOf 与 unmodifiableList 的真实差异说明「复制」和「视图」不是一回事。

本节目标:能判断一个类是否真的不可变,能用防御性复制保护集合字段,并知道什么时候该用「返回新对象」代替 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 都不需要:想改,就创建一个新对象。这样「谁能改、什么时候改」变成了「谁持有哪个版本」,比共享一个可变对象容易推理得多。

不可变对象的四个条件

一个类要称得上不可变,至少满足四条:

  1. 类本身不能被继承(final class),否则子类可以加可变字段或重写方法。
  2. 所有字段 private final,构造完成后不再重新赋值。
  3. 不提供任何修改状态的方法,包括 setter 和 markXxx 之类的变更方法。
  4. 不泄漏可变内部状态:构造时对传入的可变对象做防御性复制,访问器不返回内部可变对象。

前三条是语法层面的,第四条最容易被忽略。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:复用不是目的 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

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