06|原型:复制状态之前先划清共享边界
配套代码:GitHub 仓库 · 本篇完整源码 · 行为测试。使用 JDK 25 与 Gradle,包名为
com.hanserwei.patterns.prototype。
系列导航:Java 25 设计模式学习指南
每周技术周刊的草稿都有相同标题前缀和标签。每次从零填参数既重复又容易漏项。编辑希望基于上一期复制一份草稿,再独立追加本期标签。
危险的实现是把两个草稿的 tags 指向同一个 ArrayList:新草稿增加标签,旧稿也跟着变化。原型的难点并不在提供一个 copy 名字,而在定义“复制以后,哪些状态仍然共享”。
从问题提炼设计意图
原型以现有对象为创建起点,复制其状态得到新对象;复制的深度、对象身份和共享关系必须由业务契约说明。
对象职责与协作关系
| 示例角色 | 职责 |
|---|---|
Draft |
可复制的原型及本例唯一具体类型 |
Draft.copy |
客户端使用的复制入口 |
私有复制构造器 |
集中实现复制规则 |
Demo |
基于模板继续编辑的调用方 |
classDiagram
Demo ..> Draft : copies
Draft ..> Draft : independent copy
代码思路:变化应该落在哪个对象上
Draft 的公开构造器首先复制外部标签集合,这样调用者持有的列表不会成为草稿的后门。title 是 String,可以安全共享同一个字符串值;tags 是可变容器,必须独立。
copy 调用私有复制构造器,复制构造器再复用公开构造器的防御性复制。两条创建路径共用同一份复制策略,避免日后新增字段时只更新其中一个入口。
tags 返回 List.copyOf 快照,因此外部无法拿到内部列表并直接修改;编辑只能经过 addTag。封装不只是 private 修饰符,还包括从方法返回什么引用。本例只有一种具体原型,因此直接提供 copy;当调用方确实需要对多种可复制类型统一操作时,再抽取原型接口。
关键实现与独立运行
配套仓库中的包名是 com.hanserwei.patterns.prototype,源码目录为 src/main/java/com/hanserwei/patterns/prototype/。仓库地址统一见系列导航。以下展示关键文件的完整内容;其余角色和测试在同一仓库中,每个顶级类型各占一个文件。
Draft.java:
package com.hanserwei.patterns.prototype;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
/** 可以复制的可变草稿;字符串共享,标签容器独立. */
public final class Draft {
/** 草稿标题;字符串本身不可变. */
private final String title;
/** 当前草稿独占的可变标签列表. */
private final List<String> tags;
/** 用标题和标签初始化草稿,并复制外部集合. */
public Draft(String title, List<String> tags) {
this.title = Objects.requireNonNull(title, "title");
this.tags = new ArrayList<>(List.copyOf(tags));
}
/** 复制原型的当前状态,不与原型共享可变容器. */
private Draft(Draft source) {
this(source.title, source.tags);
}
/** 创建可独立编辑的副本. */
public Draft copy() {
return new Draft(this);
}
/** 在当前草稿上新增非空标签. */
public void addTag(String tag) {
tags.add(Objects.requireNonNull(tag, "tag"));
}
/** 返回标签的不可变快照. */
public List<String> tags() {
return List.copyOf(tags);
}
/** 返回草稿标题. */
public String title() {
return title;
}
}
Demo.java 展示调用方如何装配这些对象:
package com.hanserwei.patterns.prototype;
import java.util.List;
/** 演示本章对象的装配方式和可观察结果. */
public final class Demo {
/** 禁止实例化演示入口. */
private Demo() {}
/** 运行独立示例;args 为未使用的命令行参数. */
public static void main(String[] args) {
Draft template = new Draft("Weekly", List.of("Java"));
Draft copy = template.copy();
copy.addTag("OOP");
System.out.println(template.tags() + " -> " + copy.tags());
}
}
在配套代码仓库根目录运行;Windows 使用 gradlew.bat 替换 ./gradlew:
./gradlew runPrototype
./gradlew test --tests 'com.hanserwei.patterns.prototype.PatternTest'
示例的业务输出如下,省略 Gradle 自身的任务提示:
[Java] -> [Java, OOP]
用测试确认模式的行为
copy 返回不同对象;修改副本不影响原型;修改输入集合也不影响草稿。这些比“两个对象字段初值相等”更能揭示复制策略是否正确。
对应测试位于 src/test/java/com/hanserwei/patterns/prototype/PatternTest.java。建议先运行现有测试,再改动一个协作环节,观察哪个断言能够发现问题。
常见用法
- 以文档模板、游戏对象或表单配置为基础快速创建变体。
- 初始化需要计算,已有实例已经包含可复用的结果状态。
- 希望复制时保留用户当前配置,而不是恢复系统默认值。
适用边界与容易踩的坑
“深拷贝”不是无限递归复制所有引用。连接、线程、锁和外部资源通常不应该复制;实体的数据库标识可能需要重建,创建时间可能需要刷新。应逐字段列出复制、共享、重置或禁止复制的规则。
本例只复制列表容器,里面的 String 不可变,所以达到业务所需的隔离。如果换成可变 Tag,new ArrayList 仍共享 Tag 元素,要进一步复制元素,或者让 Tag 本身不可变。带环对象图还需要身份映射来避免无限递归并保留原有共享关系。
不要默认使用 Java 序列化做通用复制工具;它把复制问题与序列化协议耦合,也无法自然表达所有业务规则。显式复制构造器更易审查。Object.clone 的使用还涉及浅复制和 Cloneable 契约,本例不依赖它。
与相近模式比较
建造者通常明确给出新的构建参数;原型从现有实例的当前状态出发。两者可以配合:先复制,再用受控编辑接口调整差异,但必须避免让旧对象的身份随复制被误复用。
动手练习
把标签从 String 改成带颜色的可变 Tag,并先写一个修改副本标签颜色的失败测试。修复复制策略后,再考虑两篇草稿有意共享同一个只读分类时,是否还应该复制该分类。
系列导航:Java 25 设计模式学习指南