教学报价清单规定:只能通过 SkuBook.add 加商品,null 商品不合法。Before 的内部 skus 字段虽然是 private,却把这个 ArrayList 直接从 items() 返回。调用者收到的不是一次查询结果的独立副本,而是能写回原对象的别名。实验先让调用者把清单清空,再从 items() 验证内部也空了;随后绕过 add(null) 的拒绝路径,直接在返回的集合上插入 null,并从书中读到这个非法元素。

问题不在点号有几个,也不在 getter 是否有 private 字段:一个本应通过业务入口维护的状态,获得了第二条未受约束的修改路径。第 01 篇讨论可变金额作 HashMap 键的别名问题,本篇则看返回集合这一条边界。

三种返回方式,三种时间语义

labs/02 把三个实现放在同一个 SkuBook 测试协议下。对于“初始为空、add("paper") 后能读到商品、add(null) 被拒且状态不变”,三者使用相同输入与断言。新需求另加两条约束:外部不能通过读取结果改写内部;已经拿到的读取结果不能随着后续的内部添加变化。

返回方式 外部修改返回结果 内部后续追加 pen 时,旧结果 新约束
Before:直接返回 skus 可以清空,也可以加入非法 null 会变 两条均违约
After:List.copyOf(skus) clear() 抛异常 仍只有 paper 两条均满足
Alternative:Collections.unmodifiableList(skus) clear() 抛异常 变为 paper, pen 只满足“不允许外部修改”

After.items() 的关键调用只有一行:

1
2
3
public List<String> items() {
return List.copyOf(skus);
}

对应的协作和时间顺序如下;t1 返回的列表不会因为 t2 的添加而变化:

sequenceDiagram
    participant Caller as 调用者
    participant Book as After
    Caller->>Book: items() (t1)
    Book-->>Caller: List.copyOf(skus) = [paper]
    Caller->>Book: add("pen") (t2)
    Book-->>Caller: 添加完成
    Note over Caller: t1 的结果仍为 [paper]

List.copyOf 返回含当前元素的不可修改清单;它和 Collections.unmodifiableList 返回的底层集合视图不是同一个时间契约。后者禁止经视图写,却会在底层 skus 改变时反映新内容。Java SE 21 List.copyOf Javadoc 和 Collections.unmodifiableList Javadoc 分别给出了复制/只读视图的 API 语义;实验再验证了本段代码实际保存旧结果的表现。

选择 After 并不表示任何地方都该复制:订阅式页面可能需要始终观察最新内部清单,此时只读的 live view 正是合理需求;如果集合很大而读取频繁,复制还有分配成本,不能仅凭本实验声称哪个方案性能更优。该教学清单的元素是不可变 String。若改为可变 Item,即使返回不可修改的列表,仍可能通过 items().get(0).set... 改写元素;那需要另外定义元素的快照和所有权,List.copyOf 不提供深复制。

被藏起来的究竟是什么

Parnas 的 On the Criteria To Be Used in Decomposing Systems into Modules 以可能变化的设计决策作为模块分解的依据;链接为论文转录文本,这里借用其信息隐藏的问题意识,不虚构页码或逐字引语。本例具体要隐藏的是清单怎样存储与维护非空约束。如果调用者持有 ArrayList 并直接修改,内部换成其他集合或增加“不得删除已确认商品”规则时,维护者无法只在 add 一处审查操作。返回快照让调用者只依赖一个已确认的读取结果;同时也让“读取后是否实时更新”成为必须说清的接口契约。

本篇只在 labs/02 验证三个选择,没有提前让累计 Order 暴露不存在的商品清单接口,更没有把只读视图称作 GoF 模式。私有字段是访问控制的一步;状态所有权和对象协作还需按行为契约验证。

实验与练习

2026-10-02 在 Ubuntu OpenJDK 21.0.12.1 和 Maven 3.9.9 下执行 ./mvnw -B -ntp verify,退出码 0;02 执行 6 次,0 失败、0 错误、0 跳过;00、01 的 5、7 次也通过。原始输出、输入与判定存于 examples/design-patterns/evidence/02/。这里的反例是刻意观察外部修改,不是关掉失败测试伪造通过。

  1. 新需求要求调用者拿到旧清单后,即使 SkuBook 随后加入 pen,旧结果也必须保持只有 paper。给三个实现各写一条可失败断言;若原设计只提供了实时只读视图,指出需要修改哪条返回路径,而不是仅在调用者写 Collections.unmodifiableList。
  2. 将 SKU 从 String 换成有可变展示名称的对象。先写一个“拿到返回元素并修改后内部也变”的反例,再分别讨论可变对象的拷贝、不可变值元素和只返回所需字段三种边界,别把浅拷贝写成完整防御性复制。

参考与系列导航