设计模式(六):原型模式——当“复制一个现成对象”比“重新造一个”更划算
一、模式原理
定义:原型模式用原型实例指定要创建的对象的种类,并通过复制这个原型来创建新对象,而不是通过 new 调用构造函数。
拆开看,这个定义包含三个要点:
- 以实例为模板:被复制的对象本身充当“创建说明书”,使用方不需要知道产品的具体类名;
- 绕开构造函数:复制不走
new,因此不重复执行昂贵的初始化逻辑; - 复制的是状态:新对象从原型继承创建时刻的状态,而不是从零开始。
意图:当对象的创建过程昂贵(读库、读文件、复杂计算、远程调用)或复杂(构造参数需要从现有对象推导),而系统中又已经存在一个状态可用的实例时,通过复制它来获得新对象,把“创建”从“构造”变成“克隆”。
核心思想:把“造什么、怎么造”的知识从类转移到实例上——原型本身就是模板,复制就是创建。
GoF 原版结构中,参与者只有两个:
- Prototype:声明
clone()方法(实现Cloneable接口或等价机制); - ConcretePrototype:实现复制自身的方法,返回一个与自己状态相同的新对象。
使用方持有某个原型引用,调用 clone() 得到新对象——它不需要 new,也不需要知道具体类。这带来一个独特的收益:可以在运行期动态替换原型,从而改变“接下来创建出来的对象是什么”,而不改任何代码。
代价一句话:你换来的是“跳过昂贵初始化”和“不依赖具体类名”,付出的是深拷贝/浅拷贝的正确性负担,以及复制逻辑散落在每个类里、难以统一管理。
二、先看一个真实痛点
报表服务需要为每个租户生成一份月度报表对象。报表对象的初始化很重:从数据仓库拉取聚合数据、加载该租户的模板配置、预计算图表数据、建立缓存索引——单次构造约 1.2 秒。
问题出在“生成”环节:用户点一次“生成”,实际要产出多个变体——PDF 版、Excel 版、邮件摘要版。三个变体共享 90% 的数据(租户信息、聚合结果、模板配置),只有导出格式和少量字段不同。如果每个都 new Report(tenantId, month),就是三次 1.2 秒的重复劳动,而且三次拉取的数据可能因为期间数据更新而不一致。
有人试过把构造好的对象直接传给三个导出器,结果更糟:PDF 导出器在对象上设置了一个 format 字段,Excel 导出器读到了被改过的状态——它们共享了同一个可变对象。
这就是原型模式回应的压力:创建一个“和现有对象几乎一样、只有少量差异”的新对象时,重新构造既昂贵又可能不一致,直接共享引用又会互相污染。正确做法是复制一份,但复制必须知道“哪些字段该深拷贝、哪些可以共享”。
三、本质与结构速写
本质:让对象自己负责“复制自己”,使用方通过克隆而非构造获得新实例。
原型模式真正的难点不在“怎么调 clone()”,而在复制语义。这里必须分清三组概念:
浅拷贝:复制对象本身和它的基本类型字段,但引用类型字段仍指向同一对象。复制出来的两个对象共享内部的列表、Map、子对象。
深拷贝:递归复制所有引用类型字段,新对象与原型完全独立。
混合拷贝:部分字段深拷贝、部分共享——比如不可变字段(字符串、配置快照)可以共享,可变集合必须深拷贝。这是实践中真正需要的语义,也是两种语言都没有开箱即用支持的地方。
三条不变量,审查时必须检查:
- 复制后的对象与原对象在可变状态上互不影响(除非业务明确要求共享);
- 复制不重新执行昂贵初始化(否则原型模式失去意义);
- 原型本身的状态在复制时是稳定且合法的(不能复制一个正在被修改的对象)。
四、Java 实现:Cloneable 是个陷阱,要看清它的边界
4.1 标准形态与它的三个坑
public class Report implements Cloneable {
private String tenantId;
private Month month;
private List<ChartData> charts; // 可变引用
private TemplateConfig template; // 可变引用
@Override
public Report clone() {
try {
Report copy = (Report) super.clone(); // 浅拷贝:charts/template 仍共享
copy.charts = new ArrayList<>(this.charts); // 手动深拷贝可变集合
copy.template = this.template.copy(); // 若 template 不可变,可省略
return copy;
} catch (CloneNotSupportedException e) {
throw new AssertionError(e);
}
}
}
使用方:
Report base = reportService.build(tenantId, month); // 昂贵,只做一次
Report pdf = base.clone(); pdf.setFormat(PDF);
Report excel = base.clone(); excel.setFormat(EXCEL);
三个必须点明的坑:
坑一:super.clone() 是浅拷贝,且不调用构造函数。 它按内存逐字段复制,charts 和 template 两个引用会被原样复制——两个 Report 指向同一个 List。不手动处理,就是前面那个“导出器互相污染”的事故。同时,因为不调用构造函数,任何在构造函数里做的初始化、注册、校验都不会执行——如果类依赖构造函数建立不变量,clone() 可能产出一个“绕过校验”的对象。
坑二:Cloneable 是一个标记接口,但它不声明 clone()。 clone() 来自 Object,且是 protected 的。一个类不实现 Cloneable 却调用 super.clone(),会抛 CloneNotSupportedException。这个设计被普遍认为是 Java 语言的历史失误——接口没有约定行为,行为在 Object 上,且是受检异常。
坑三:继承链上的复制会悄悄变浅。 如果 Report 有子类 DetailedReport 且子类新增了可变字段,父类的 clone() 不会处理它——子类必须重写。忘记重写,就会得到一个“看起来是子类、但子类字段共享”的对象。这是原型模式在 Java 里最难排查的一类 bug。
4.2 更可控的替代:拷贝构造函数
public class Report {
private final String tenantId;
private final Month month;
private final List<ChartData> charts;
private final TemplateConfig template;
// 拷贝构造函数:显式、可读、不依赖 Cloneable
public Report(Report other) {
this.tenantId = other.tenantId;
this.month = other.month;
this.charts = new ArrayList<>(other.charts); // 深拷贝可变集合
this.template = other.template; // 不可变则共享
}
}
拷贝构造函数绕开了 Cloneable 的全部坑:调用构造函数意味着不变量会被重新建立、子类必须显式调用父类拷贝构造(编译器强制)、异常语义清晰。在 Java 里,拷贝构造函数通常比 clone() 更值得推荐——除非需要在运行期通过接口统一克隆异构对象(这时才需要 Prototype 接口 + clone())。
4.3 需要运行期替换原型时
如果系统需要在运行期动态改变“接下来创建什么”,原型注册表是有价值的:
public class PrototypeRegistry {
private final Map<String, Report> prototypes = new HashMap<>();
public void register(String key, Report prototype) { prototypes.put(key, prototype); }
public Report create(String key) { return prototypes.get(key).clone(); }
}
这时 clone() 的价值才真正显现:注册表只持有 Report 引用,create 不需要知道具体子类。这是 GoF 原型模式的本意——用实例替代类作为创建模板。
五、Python 实现:copy 模块把“深浅”问题摆到了明面上
5.1 标准库的两种复制
import copy
base = report_service.build(tenant_id, month) # 昂贵,只做一次
pdf = copy.copy(base) # 浅拷贝:charts/template 共享
excel = copy.deepcopy(base) # 深拷贝:完全独立
Python 的优势在于 “浅拷贝还是深拷贝”是调用方的显式选择,而不是隐藏在类实现里。copy.copy 和 copy.deepcopy 的语义清晰、命名直白,这是 Java clone() 做不到的。
但深拷贝也有自己的边界:
deepcopy会递归复制一切,包括你不希望被复制的对象(数据库连接、线程锁、文件句柄)——它对这些对象要么报错,要么复制出无意义的副本;- 循环引用:
deepcopy用 memo 字典处理循环引用,不会无限递归,但复制出来的图结构与原图是两份独立的环; - 自定义
__deepcopy__:对于持有不可复制资源的类,必须实现__deepcopy__来定义“什么该复制、什么该共享”。
class Report:
def __init__(self, tenant_id, month, charts, template, db_conn):
self.tenant_id = tenant_id
self.month = month
self.charts = charts
self.template = template
self._db_conn = db_conn # 不应被复制的资源
def __deepcopy__(self, memo):
new = Report.__new__(Report) # 不走 __init__
new.tenant_id = self.tenant_id
new.month = self.month
new.charts = copy.deepcopy(self.charts, memo)
new.template = self.template # 不可变,共享
new._db_conn = self._db_conn # 连接共享,不复制
return new
__deepcopy__ 是 Python 版的“手动深拷贝”——和 Java 里重写 clone() 做的是同一件事,但语义更明确:你是在定义“深拷贝时这个类怎么处理”,而不是在和 Cloneable 的历史包袱搏斗。
5.2 不可变对象:原型模式的“退化形态”
如果对象本身就是不可变的(frozen dataclass、NamedTuple),那“复制 + 修改”可以用 dataclasses.replace 表达:
from dataclasses import dataclass, replace
@dataclass(frozen=True)
class Report:
tenant_id: str
month: str
charts: tuple[ChartData, ...]
format: str = "pdf"
pdf = base
excel = replace(base, format="excel") # 只改一个字段,其余共享
这里没有显式的 clone(),但 replace 做的就是“复制 + 覆盖部分字段”。由于所有字段不可变,共享是安全的——原型模式的“复制”在不可变对象上退化成“结构共享”,成本几乎为零。这是函数式语言影响下的一种现代形态:用不可变性消灭“深拷贝还是浅拷贝”的问题本身。
5.3 GoF 版在 Python 里:注册表 + 可调用
_PROTOTYPES: dict[str, Report] = {}
def register(key: str, prototype: Report) -> None:
_PROTOTYPES[key] = prototype
def create(key: str) -> Report:
return copy.deepcopy(_PROTOTYPES[key])
和 Java 注册表版结构一致。Python 里更常见的变体是注册工厂函数而不是原型实例——但那已经属于工厂模式,不是原型模式了。原型模式的特征是“注册的是实例,创建靠复制”,这个特征要保留。
六、双语言对照表
| 维度 | Java | Python |
|---|---|---|
| 典型实现 | 实现 Cloneable + 重写 clone();或拷贝构造函数 |
copy.copy / copy.deepcopy;__deepcopy__;不可变对象用 replace |
| 深浅拷贝由谁决定 | 类实现者(重写 clone() 时手动处理) |
调用方显式选择 copy 还是 deepcopy |
| 不走构造函数的风险 | super.clone() 跳过构造函数,不变量可能不成立 |
__deepcopy__ 常用 __new__ 绕过 __init__,同样要小心 |
| 继承链上的复制 | 子类必须重写 clone(),否则字段悄悄共享 |
deepcopy 自动处理整个对象图,子类字段默认被复制 |
| 不可复制资源(连接、锁) | 需在 clone() 中显式处理 |
需实现 __deepcopy__ 或使用 copy 浅拷贝 |
| 样板代码量 | 较高,且容易写错 | 低(标准库承担大部分工作) |
| 运行期替换原型 | 注册表 + Prototype 接口,较自然 |
注册表 + deepcopy,同样自然 |
| 主要风险 | Cloneable 语义混乱、浅拷贝遗漏、子类未重写 |
deepcopy 复制了不该复制的资源、性能开销被低估 |
| 现代替代 | 拷贝构造函数、Record + 紧凑构造器、MapStruct | dataclasses.replace、不可变模型、attrs.evolve |
记忆锚点:原型 = 拿现成的当模板,复制出新的;它省掉的是构造成本,付出的是“复制语义必须想清楚”的负担。
七、现代视角:AI 生成复制逻辑时,审查什么
原型模式是 AI 最容易“生成一个能跑的 clone()、但复制语义完全错误”的模式——因为深浅拷贝的正确性依赖于领域知识(哪些字段可变、哪些资源不该复制),而 AI 看不到这些。
诊断阶段,先判断“复制”是不是真需求。 很多场景看似需要原型,实际需要的是不可变对象 + 结构共享(Python 的 replace、Java 的 record)——如果对象可以设计成不可变的,深拷贝问题根本不会出现。另一类场景需要的是参数对象重构(把 13 个字段拆成几个值对象,每个都可独立构造),而不是复制。AI 看到“创建昂贵”就会建议原型,但它不会先问“这个对象能不能做成不可变的”。
生成阶段,用复制语义而非模式名下指令。 “复制出来的报表与原报表在可变集合上完全独立,但数据库连接和模板配置必须共享,且复制不能重新拉取数据”——这个描述会迫使 AI 处理混合拷贝,而不是给一个裸的 super.clone() 或 copy.deepcopy。只说“用原型模式”,AI 大概率给出浅拷贝或全量深拷贝,两者都可能错。
审查阶段,原型的检查清单:
- 复制后的对象与原对象,在每一个可变引用字段上是否独立?逐个字段核对,不要相信“我用了 deepcopy”。
- 有没有字段是“不该被复制”的(连接、锁、缓存句柄、单例)?它们被正确处理了吗?
- 复制是否绕过了构造函数/
__init__?如果有,类的不变量还能保证吗? - 继承链上的子类字段,复制时被覆盖到了吗?
- 复制频率高吗?
deepcopy的开销是否被评估过?能否改用不可变对象 + 结构共享?
一个务实判断:在现代 Java 和 Python 里,原型模式的手写需求正在被两类东西吸收——一类是不可变数据模型(record、frozen dataclass、Pydantic),它们让“复制 + 修改”变成低成本的 replace/evolve;另一类是序列化/映射工具(MapStruct、copy.deepcopy、JSON 往返),它们承担了深拷贝的机械工作。手写原型真正还合理的场景是:需要在运行期用实例替换创建模板(注册表模式)、且对象图包含无法自动深拷贝的资源,必须自定义复制语义。
八、三十秒自测
- 浅拷贝和深拷贝的区别,在“对象持有
List字段”这个例子上具体表现为什么?为什么super.clone()默认是浅的? - 为什么说“复制绕过构造函数”是一把双刃剑?什么情况下这是优点,什么情况下是隐患?
- 在你的项目里找一处“为了生成变体而重复执行昂贵初始化”的地方,判断它该用原型复制、还是该先把对象改成不可变结构以支持廉价共享。
- 点赞
- 收藏
- 关注作者
评论(0)