设计模式 02:字段是 private,清单为什么还会被外部改写
教学报价清单规定:只能通过 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 | |
对应的协作和时间顺序如下;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/。这里的反例是刻意观察外部修改,不是关掉失败测试伪造通过。
- 新需求要求调用者拿到旧清单后,即使
SkuBook随后加入pen,旧结果也必须保持只有paper。给三个实现各写一条可失败断言;若原设计只提供了实时只读视图,指出需要修改哪条返回路径,而不是仅在调用者写Collections.unmodifiableList。 - 将 SKU 从
String换成有可变展示名称的对象。先写一个“拿到返回元素并修改后内部也变”的反例,再分别讨论可变对象的拷贝、不可变值元素和只返回所需字段三种边界,别把浅拷贝写成完整防御性复制。
参考与系列导航
- Parnas:On the Criteria To Be Used in Decomposing Systems into Modules:信息隐藏与变化边界(转录本)。
- Java SE 21:List.copyOf;Collections.unmodifiableList:两种返回值的契约。
- 01 对象、类与值 ← 02 封装与信息隐藏(本篇) → 03 设计契约。





