配套代码: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 设计模式学习指南

上一篇:建造者 · 下一篇:单例