第 7 章 · 原型(Prototype)
一句话:让对象自己提供一个
clone()方法,要一个相似的新对象时,复制一份已有的再改几处。 解决的痛:逐字段复制的代码散在好几个地方,类一加字段就有地方漏掉;复制列表和嵌套对象时只复制了引用,改副本会把原件一起改坏。
7.1 一个场景
Section titled “7.1 一个场景”还是那家做在线课程的公司。运营后台有一个报表页,运营可以自己配图表:图表类型、标题、一组配色、横轴和纵轴、是否显示图例,还有一个嵌套的样式对象(字号、背景色)。真实产品里有十几个选项,本章的代码留了七个。
配好一张“各区域月度销售额”柱状图要花不少工夫。运营常常想要一张差不多的,比如“各区域月度退款额”,只换标题和一种颜色。页面上有两个按钮做这件事:编辑器里的“复制图表”,在标题后面加”(副本)“;模板页的”存为模板”,把标题清成“未命名”。
你要做的就是这两个按钮背后的函数:给一张图表,返回一张新的。
7.2 先按直觉写
Section titled “7.2 先按直觉写”最直接的写法是新建一个 ChartConfig,把旧图表的字段一个个抄过去:
from dataclasses import dataclass, field
@dataclassclass Style: font_size: int = 12 background: str = "white"
@dataclassclass ChartConfig: # 真实产品里有十几个选项,这里留七个 chart_type: str = "bar" title: str = "" colors: list[str] = field(default_factory=list) x_axis: str = "" y_axis: str = "" show_legend: bool = True style: Style = field(default_factory=Style)
def render(self) -> str: return f"{self.title}:{self.chart_type},颜色 {'/'.join(self.colors)},y 轴 {self.y_axis},字号 {self.style.font_size}"
# editor.py:"复制图表"按钮def duplicate_chart(old: ChartConfig) -> ChartConfig: return ChartConfig( chart_type=old.chart_type, title=old.title + "(副本)", colors=old.colors, x_axis=old.x_axis, y_axis=old.y_axis, show_legend=old.show_legend, style=old.style, )
# templates.py:"存为模板"按钮def save_as_template(old: ChartConfig) -> ChartConfig: return ChartConfig( chart_type=old.chart_type, title="未命名", colors=old.colors, x_axis=old.x_axis, y_axis=old.y_axis, show_legend=old.show_legend, style=old.style, )
if __name__ == "__main__": sales = ChartConfig( chart_type="bar", title="各区域月度销售额", colors=["#4e79a7", "#f28e2b", "#59a14f"], x_axis="月份", y_axis="销售额", style=Style(font_size=14), ) print(sales.render()) print(duplicate_chart(sales).render()) print(save_as_template(sales).render())
# 输出:# 各区域月度销售额:bar,颜色 #4e79a7/#f28e2b/#59a14f,y 轴 销售额,字号 14# 各区域月度销售额(副本):bar,颜色 #4e79a7/#f28e2b/#59a14f,y 轴 销售额,字号 14# 未命名:bar,颜色 #4e79a7/#f28e2b/#59a14f,y 轴 销售额,字号 14TypeScript 版用构造参数属性(parameter properties),参数顺序就是字段顺序:
interface Style { fontSize: number; background: string;}
class ChartConfig { // 真实产品里有十几个选项,这里留七个 constructor( public chartType = "bar", public title = "", public colors: string[] = [], public xAxis = "", public yAxis = "", public showLegend = true, public style: Style = { fontSize: 12, background: "white" }, ) {}
render(): string { return `${this.title}:${this.chartType},颜色 ${this.colors.join("/")},y 轴 ${this.yAxis},字号 ${this.style.fontSize}`; }}
// editor.ts:"复制图表"按钮function duplicateChart(old: ChartConfig): ChartConfig { return new ChartConfig( old.chartType, old.title + "(副本)", old.colors, old.xAxis, old.yAxis, old.showLegend, old.style, );}
// templates.ts:"存为模板"按钮function saveAsTemplate(old: ChartConfig): ChartConfig { return new ChartConfig( old.chartType, "未命名", old.colors, old.xAxis, old.yAxis, old.showLegend, old.style, );}
const sales = new ChartConfig( "bar", "各区域月度销售额", ["#4e79a7", "#f28e2b", "#59a14f"], "月份", "销售额", true, { fontSize: 14, background: "white" },);console.log(sales.render());console.log(duplicateChart(sales).render());console.log(saveAsTemplate(sales).render());
// 输出:// 各区域月度销售额:bar,颜色 #4e79a7/#f28e2b/#59a14f,y 轴 销售额,字号 14// 各区域月度销售额(副本):bar,颜色 #4e79a7/#f28e2b/#59a14f,y 轴 销售额,字号 14// 未命名:bar,颜色 #4e79a7/#f28e2b/#59a14f,y 轴 销售额,字号 14字段就这几个,抄一遍一目了然,复制出来的图表和原来的一模一样。大多数人第一版都这么写。
7.3 需求变了
Section titled “7.3 需求变了”两件事同时来了。一是报表要支持堆叠柱状图:ChartConfig 加三个字段,stacked(柱子是否叠起来)、show_data_labels(柱子上是否标数值)、y_unit(纵轴单位)。二是运营报了一个 bug:复制出退款图,把第一种颜色改成红色,原来的销售额图也变红了。
要改的地方:
ChartConfig加三个字段,render把它们显示出来。这一处躲不掉。editor.py的duplicate_chart加三行:
stacked=old.stacked,show_data_labels=old.show_data_labels,y_unit=old.y_unit,templates.py的save_as_template也要加这三行。加字段的同事搜了编辑器,没想到模板页还有一份,漏了。不报错:三个新字段都有默认值,漏掉的就悄悄取默认值。存出来的模板是这样的:
print(sales.render())print(save_as_template(sales).render())# 各区域月度销售额:bar,堆叠,有数据标签,颜色 #4e79a7/#f28e2b/#59a14f,y 轴 销售额(万元),字号 14# 未命名:bar,不堆叠,无数据标签,颜色 #4e79a7/#f28e2b/#59a14f,y 轴 销售额,字号 14用这个模板新建的图表,柱子不叠了,柱子上没有数字,纵轴没有单位。运营只会觉得“模板坏了”。
- 变红的 bug。
colors=old.colors复制的是列表的引用,副本和原件共用同一个列表:
refund = duplicate_chart(sales)print(sales.render())refund.colors[0] = "#e15759" # 退款图用红色print(sales.render())# 各区域月度销售额:bar,堆叠,有数据标签,颜色 #4e79a7/#f28e2b/#59a14f,y 轴 销售额(万元),字号 14# 各区域月度销售额:bar,堆叠,有数据标签,颜色 #e15759/#f28e2b/#59a14f,y 轴 销售额(万元),字号 14style=old.style 也一样,改副本的字号,原件跟着变。修法是两个复制点都改成 colors=list(old.colors)、style=Style(font_size=old.style.font_size, background=old.style.background);以后 Style 再加字段,又是两处要改。
问题的形状是:每加一个字段,每个复制点都要加一行,漏了不报错;每个列表、每个嵌套对象,每个复制点都要记得另复制一份。
图中 ①–④ 是上面列的四处,colors 还被三个对象共用。
7.4 用原型模式重写
Section titled “7.4 用原型模式重写”原型(Prototype)模式的做法是让对象自己负责复制:ChartConfig 提供一个 clone(),调用方只管调用它,不再列字段。Python 用标准库的 copy.deepcopy:
import copyfrom dataclasses import dataclass, fieldfrom typing import Protocol, Self
class Prototype(Protocol): def clone(self) -> Self: ...
@dataclassclass Style: font_size: int = 12 background: str = "white"
@dataclassclass ChartConfig(Prototype): chart_type: str = "bar" title: str = "" colors: list[str] = field(default_factory=list) x_axis: str = "" y_axis: str = "" show_legend: bool = True style: Style = field(default_factory=Style) stacked: bool = False # 新加的三个字段 show_data_labels: bool = False y_unit: str = ""
def clone(self) -> Self: return copy.deepcopy(self) # 所有字段都跟着走,colors 和 style 各复制一份新的
def render(self) -> str: stacked = "堆叠" if self.stacked else "不堆叠" labels = "有数据标签" if self.show_data_labels else "无数据标签" unit = f"({self.y_unit})" if self.y_unit else "" return (f"{self.title}:{self.chart_type},{stacked},{labels}," f"颜色 {'/'.join(self.colors)},y 轴 {self.y_axis}{unit},字号 {self.style.font_size}")
# editor.py:"复制图表"按钮def duplicate_chart(old: ChartConfig) -> ChartConfig: chart = old.clone() chart.title += "(副本)" return chart
# templates.py:"存为模板"按钮def save_as_template(old: ChartConfig) -> ChartConfig: template = old.clone() template.title = "未命名" return template
if __name__ == "__main__": sales = ChartConfig( chart_type="bar", title="各区域月度销售额", colors=["#4e79a7", "#f28e2b", "#59a14f"], x_axis="月份", y_axis="销售额", style=Style(font_size=14), stacked=True, show_data_labels=True, y_unit="万元", ) print(sales.render())
refund = duplicate_chart(sales) refund.title = "各区域月度退款额" refund.y_axis = "退款额" refund.colors[0] = "#e15759" # 退款图用红色 print(refund.render()) print(sales.render()) # 原件没变 print(save_as_template(sales).render())
# 输出:# 各区域月度销售额:bar,堆叠,有数据标签,颜色 #4e79a7/#f28e2b/#59a14f,y 轴 销售额(万元),字号 14# 各区域月度退款额:bar,堆叠,有数据标签,颜色 #e15759/#f28e2b/#59a14f,y 轴 退款额(万元),字号 14# 各区域月度销售额:bar,堆叠,有数据标签,颜色 #4e79a7/#f28e2b/#59a14f,y 轴 销售额(万元),字号 14# 未命名:bar,堆叠,有数据标签,颜色 #4e79a7/#f28e2b/#59a14f,y 轴 销售额(万元),字号 14TS 版用 structuredClone 拷贝数据。它只认数据:structuredClone(this) 不报错,但拿回来的是一个普通对象,没有 render() 也没有 clone()。所以分两步,先拷数据,再造一个空白的 ChartConfig 把数据盖上去:
interface Prototype<T> { clone(): T;}
interface Style { fontSize: number; background: string;}
class ChartConfig implements Prototype<ChartConfig> { constructor( public chartType = "bar", public title = "", public colors: string[] = [], public xAxis = "", public yAxis = "", public showLegend = true, public style: Style = { fontSize: 12, background: "white" }, public stacked = false, // 新加的三个字段 public showDataLabels = false, public yUnit = "", ) {}
clone(): ChartConfig { const data = structuredClone(this); // 拷回来的是普通对象:colors 和 style 各复制一份新的,方法拷不过来 return Object.assign(new ChartConfig(), data); // 先造一个空白图表(带方法),再把数据盖上去 }
render(): string { const stacked = this.stacked ? "堆叠" : "不堆叠"; const labels = this.showDataLabels ? "有数据标签" : "无数据标签"; const unit = this.yUnit ? `(${this.yUnit})` : ""; return `${this.title}:${this.chartType},${stacked},${labels},` + `颜色 ${this.colors.join("/")},y 轴 ${this.yAxis}${unit},字号 ${this.style.fontSize}`; }}
// editor.ts:"复制图表"按钮function duplicateChart(old: ChartConfig): ChartConfig { const chart = old.clone(); chart.title += "(副本)"; return chart;}
// templates.ts:"存为模板"按钮function saveAsTemplate(old: ChartConfig): ChartConfig { const template = old.clone(); template.title = "未命名"; return template;}
const sales = new ChartConfig( "bar", "各区域月度销售额", ["#4e79a7", "#f28e2b", "#59a14f"], "月份", "销售额", true, { fontSize: 14, background: "white" }, true, true, "万元",);console.log(sales.render());
const refund = duplicateChart(sales);refund.title = "各区域月度退款额";refund.yAxis = "退款额";refund.colors[0] = "#e15759"; // 退款图用红色console.log(refund.render());console.log(sales.render()); // 原件没变console.log(saveAsTemplate(sales).render());
// 输出:// 各区域月度销售额:bar,堆叠,有数据标签,颜色 #4e79a7/#f28e2b/#59a14f,y 轴 销售额(万元),字号 14// 各区域月度退款额:bar,堆叠,有数据标签,颜色 #e15759/#f28e2b/#59a14f,y 轴 退款额(万元),字号 14// 各区域月度销售额:bar,堆叠,有数据标签,颜色 #4e79a7/#f28e2b/#59a14f,y 轴 销售额(万元),字号 14// 未命名:bar,堆叠,有数据标签,颜色 #4e79a7/#f28e2b/#59a14f,y 轴 销售额(万元),字号 14style 在 TS 里是普通对象,所以拷得完整;如果某个字段本身是带方法的类实例,它同样会丢掉方法,得由那个类自己提供 clone(),在这里调用。字段里要是有函数(比如一个格式化回调),structuredClone 会直接抛错。
标红的是 clone() 和副本自己的 colors:两份列表互不相干。
对照 7.3 的四处:
- 第 1 处,加字段:照样要改,这是需求本身。
- 第 2、3 处,两个复制点:现在只剩一行
old.clone(),不列字段。运行结果最后一行,模板保住了堆叠、数据标签和“万元”。以后再加字段,复制点一行不用动。 - 第 4 处,变红:
deepcopy和structuredClone都会把列表、嵌套对象一层层复制下去,运行结果第三行,原件的颜色没变。Style加字段也不用管。
也要说清它没做到什么。clone() 把“怎么复制”收到了类里一处,复制并没有因此变得免费,“拷多深”也还得你来定:deepcopy 不加区分地全拷一遍,字段里要是挂着一把锁、一个数据库连接或几万行查询结果,要么报错,要么白白多占一份内存。这时就得自己写 clone(),决定哪些共享、哪些新建,7.6 例一就是这种情况。
7.5 模式定义
Section titled “7.5 模式定义”GoF 对原型的定义是:用原型实例指定创建对象的种类,并且通过拷贝这些原型创建新的对象。
换成本章的话说:已经配好的那张销售额图就是原型。要新图表时不说“造一个什么类的对象”,而是“照着它复制一份”,造出来的种类自然和原型一样。
图里是 GoF 的角色,下表对应到本章的类。
| 角色 | 在本章案例里是谁 | 职责 |
|---|---|---|
| 原型(Prototype) | Prototype |
声明 clone() |
| 具体原型(ConcretePrototype) | ChartConfig |
实现 clone(),决定每个字段怎么复制 |
| 客户(Client) | duplicate_chart、save_as_template |
调用 clone() 拿到新对象,再改需要改的地方 |
常见场景:
- 复制一个配好的对象再小改:本章的图表、文档模板。
- 创建代价高:对象构造时要查数据库或解析大文件,复制一个现成的比重新造便宜。
- 种类到运行时才知道:编辑器里用户从组件栏拖出一个组件,代码手里只有一个对象,调它的
clone()就行,不用先判断它是哪个类。
浅拷贝和深拷贝。浅拷贝(shallow copy)只复制最外面一层,字段里的列表、对象还是原来那个;7.3 的变红 bug,就是手写了一个浅拷贝。深拷贝(deep copy)一层层往下复制,副本和原件不再共用任何可变的东西。Python 里分别是 copy.copy 和 copy.deepcopy;JS 里 { ...obj } 是浅拷贝,structuredClone 是深拷贝。
7.6 真实系统里的例子
Section titled “7.6 真实系统里的例子”例一:copy 模块的 __copy__ 和 __deepcopy__(Python)
Section titled “例一:copy 模块的 __copy__ 和 __deepcopy__(Python)”Python 标准库的 copy 模块本身就是这个模式:copy.copy(x) 会找 x 的类有没有 __copy__,copy.deepcopy(x) 会找 __deepcopy__,有就交给对象自己复制,没有才用默认做法。标准库的 decimal.Decimal 就定义了这两个方法:它不可变,两个方法都直接返回自己。下面这个看板类带一把锁,deepcopy 碰到锁会报错,所以它自己决定:图表列表复制,锁新建一把。
import copyimport threadingfrom typing import Any, Self
class Dashboard: def __init__(self, name: str, charts: list[str]) -> None: self.name = name self.charts = charts self.lock = threading.Lock() # 防止两个请求同时改看板
def __copy__(self) -> Self: # copy.copy(x) 调它 new = self.__class__.__new__(self.__class__) new.__dict__.update(self.__dict__) # 浅拷贝:charts 还是同一个列表 new.lock = threading.Lock() return new
def __deepcopy__(self, memo: dict[int, Any]) -> Self: # copy.deepcopy(x) 调它 new = self.__class__.__new__(self.__class__) memo[id(self)] = new # 先登记:遇到循环引用时直接用 new,不会无限递归 new.name = self.name new.charts = copy.deepcopy(self.charts, memo) # 往下复制时把 memo 传下去 new.lock = threading.Lock() # 锁不复制,新建一把 return new
if __name__ == "__main__": try: copy.deepcopy(threading.Lock()) except TypeError as e: print(f"直接复制锁:{e}") board = Dashboard("销售看板", ["销售额", "区域占比"]) copy.copy(board).charts.append("退款额") print(f"浅拷贝加图后,原件:{board.charts}") copy.deepcopy(board).charts.append("客单价") print(f"深拷贝加图后,原件:{board.charts}")
# 输出:# 直接复制锁:cannot pickle '_thread.lock' object# 浅拷贝加图后,原件:['销售额', '区域占比', '退款额']# 深拷贝加图后,原件:['销售额', '区域占比', '退款额']__deepcopy__ 站在 ChartConfig.clone() 的位置上,调用 copy.deepcopy 的代码是客户。不同的是 Python 把 clone 统一成了这两个双下划线方法,任何代码都用同一个入口复制任何对象。
例二:Object.create 和原型链(TypeScript)
Section titled “例二:Object.create 和原型链(TypeScript)”JavaScript 的对象一般都有一个原型对象,读属性时自己身上没有,就沿着原型链(prototype chain)往上找。Object.create(proto) 造一个以 proto 为原型的空对象。它也叫“原型”,但和 GoF 的原型模式同名不同义:
const baseChart = { chartType: "bar", fontSize: 12, describe(): string { return `${this.chartType},字号 ${this.fontSize}`; },};
const salesChart: typeof baseChart = Object.create(baseChart); // 没有复制,只是把原型指向 baseChartsalesChart.fontSize = 14; // 只在 salesChart 自己身上加了一个属性console.log(salesChart.describe());console.log(`自己身上的属性:${Object.keys(salesChart).join("、")}`);
baseChart.chartType = "line"; // 改原型console.log(salesChart.describe()); // salesChart 跟着变了
// 输出:// bar,字号 14// 自己身上的属性:fontSize// line,字号 14GoF 的原型是复制,复制完两个对象互不相干;原型链不复制,新对象只是借用原型的属性,改了原型上的属性,所有自己没有同名属性的对象都跟着变。两者只共享一点:从一个已有的对象出发造新对象。
7.7 什么时候别用
Section titled “7.7 什么时候别用”- 对象没有嵌套的可变状态。字段全是字符串、数字,直接构造更清楚。Python 的 dataclass 用
dataclasses.replace(chart, title="退款额"),TS 的普通对象用{ ...chart, title: "退款额" },一行就是“复制再改”。再写个clone(),读者还得点进去看它拷了什么。 - 语言自带的通用拷贝已经够用。
clone()的实现只有一行copy.deepcopy(self)时,调用方直接写copy.deepcopy(chart)效果一样。显式的clone()只在要控制拷多深时才值得:哪些字段共享,哪些新建,像例一的锁。 - 对象挂着大量数据。如果
ChartConfig里还存着查询出来的全部数据行,每点一次“复制”,这些行就在内存里多一份。数据应该共享,只复制配置,这时不能直接deepcopy了事。
7.8 容易混淆的模式
Section titled “7.8 容易混淆的模式”| 模式 | 意图 | 区别 |
|---|---|---|
| 建造者(Builder) | 一步步构造一个复杂对象 | 建造者从零开始,一项项填;原型从一个已经配好的对象出发,复制后只改几处 |
| 工厂方法(Factory Method) | 由子类决定创建哪个类 | 工厂方法要为每种产品准备一个子类;原型不需要知道类,手里有一个现成对象就能造出同类的新对象 |
7.9 练一练
Section titled “7.9 练一练”游戏里的敌人用 EnemySpec 描述:名字 name、血量 hp、攻击力 attack、技能列表 skills、掉落表 drops(物品名到掉落概率)。策划配好了一只“基础哥布林”,想从它克隆出“精英哥布林”:改名之外再改两处,血量翻倍,技能列表里加一个“狂暴”。
请给 EnemySpec 写 clone(),克隆出精英哥布林,打印两只哥布林,并检查基础哥布林的血量和技能列表没被改动。Python 和 TS 各写一版。
提示:先用逐字段复制、直接 skills=old.skills 写一遍,看看基础哥布林是不是也学会了“狂暴”。TS 版想想 structuredClone 之后,EnemySpec 的方法去哪了。
7.10 小结
Section titled “7.10 小结”- 原型模式让对象自己提供
clone(),要相似的新对象时复制一份再改,调用方不用列字段。 - 字段增减只改类内部,复制点不用跟着改,也就不会有哪一处漏了新字段。
- 浅拷贝共用列表和嵌套对象,深拷贝一层层复制;
clone()把这个选择收到一处,但选择本身还得你来做。 - Python 的
copy模块和__copy__、__deepcopy__就是现成的原型协议;TS 的structuredClone只拷数据,类实例要自己重建。 - JavaScript 的原型链和原型模式同名不同义:前者是借用,后者是复制。