跳转到内容

第 7 章 · 原型(Prototype)

一句话:让对象自己提供一个 clone() 方法,要一个相似的新对象时,复制一份已有的再改几处。 解决的痛:逐字段复制的代码散在好几个地方,类一加字段就有地方漏掉;复制列表和嵌套对象时只复制了引用,改副本会把原件一起改坏。

还是那家做在线课程的公司。运营后台有一个报表页,运营可以自己配图表:图表类型、标题、一组配色、横轴和纵轴、是否显示图例,还有一个嵌套的样式对象(字号、背景色)。真实产品里有十几个选项,本章的代码留了七个。

配好一张“各区域月度销售额”柱状图要花不少工夫。运营常常想要一张差不多的,比如“各区域月度退款额”,只换标题和一种颜色。页面上有两个按钮做这件事:编辑器里的“复制图表”,在标题后面加”(副本)“;模板页的”存为模板”,把标题清成“未命名”。

你要做的就是这两个按钮背后的函数:给一张图表,返回一张新的。

最直接的写法是新建一个 ChartConfig,把旧图表的字段一个个抄过去:

from dataclasses import dataclass, field
@dataclass
class Style:
font_size: int = 12
background: str = "white"
@dataclass
class 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 轴 销售额,字号 14

TypeScript 版用构造参数属性(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

字段就这几个,抄一遍一目了然,复制出来的图表和原来的一模一样。大多数人第一版都这么写。

两件事同时来了。一是报表要支持堆叠柱状图:ChartConfig 加三个字段,stacked(柱子是否叠起来)、show_data_labels(柱子上是否标数值)、y_unit(纵轴单位)。二是运营报了一个 bug:复制出退款图,把第一种颜色改成红色,原来的销售额图也变红了。

要改的地方:

  1. ChartConfig 加三个字段,render 把它们显示出来。这一处躲不掉。
  2. editor.py 的 duplicate_chart 加三行:
stacked=old.stacked,
show_data_labels=old.show_data_labels,
y_unit=old.y_unit,
  1. 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

用这个模板新建的图表,柱子不叠了,柱子上没有数字,纵轴没有单位。运营只会觉得“模板坏了”。

  1. 变红的 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 轴 销售额(万元),字号 14

style=old.style 也一样,改副本的字号,原件跟着变。修法是两个复制点都改成 colors=list(old.colors)、style=Style(font_size=old.style.font_size, background=old.style.background);以后 Style 再加字段,又是两处要改。

问题的形状是:每加一个字段,每个复制点都要加一行,漏了不报错;每个列表、每个嵌套对象,每个复制点都要记得另复制一份。

朴素写法:四处要改的位置

图中 ①–④ 是上面列的四处,colors 还被三个对象共用。

原型(Prototype)模式的做法是让对象自己负责复制:ChartConfig 提供一个 clone(),调用方只管调用它,不再列字段。Python 用标准库的 copy.deepcopy:

import copy
from dataclasses import dataclass, field
from typing import Protocol, Self
class Prototype(Protocol):
def clone(self) -> Self: ...
@dataclass
class Style:
font_size: int = 12
background: str = "white"
@dataclass
class 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 轴 销售额(万元),字号 14

TS 版用 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 轴 销售额(万元),字号 14

style 在 TS 里是普通对象,所以拷得完整;如果某个字段本身是带方法的类实例,它同样会丢掉方法,得由那个类自己提供 clone(),在这里调用。字段里要是有函数(比如一个格式化回调),structuredClone 会直接抛错。

原型版:clone() 与两份独立的 colors

标红的是 clone() 和副本自己的 colors:两份列表互不相干。

对照 7.3 的四处:

  • 第 1 处,加字段:照样要改,这是需求本身。
  • 第 2、3 处,两个复制点:现在只剩一行 old.clone(),不列字段。运行结果最后一行,模板保住了堆叠、数据标签和“万元”。以后再加字段,复制点一行不用动。
  • 第 4 处,变红:deepcopy 和 structuredClone 都会把列表、嵌套对象一层层复制下去,运行结果第三行,原件的颜色没变。Style 加字段也不用管。

也要说清它没做到什么。clone() 把“怎么复制”收到了类里一处,复制并没有因此变得免费,“拷多深”也还得你来定:deepcopy 不加区分地全拷一遍,字段里要是挂着一把锁、一个数据库连接或几万行查询结果,要么报错,要么白白多占一份内存。这时就得自己写 clone(),决定哪些共享、哪些新建,7.6 例一就是这种情况。

GoF 对原型的定义是:用原型实例指定创建对象的种类,并且通过拷贝这些原型创建新的对象。

换成本章的话说:已经配好的那张销售额图就是原型。要新图表时不说“造一个什么类的对象”,而是“照着它复制一份”,造出来的种类自然和原型一样。

原型的结构:Client 只调 Prototype 的 clone(),具体原型各自复制自己

图里是 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 是深拷贝。

例一: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 copy
import threading
from 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); // 没有复制,只是把原型指向 baseChart
salesChart.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,字号 14

GoF 的原型是复制,复制完两个对象互不相干;原型链不复制,新对象只是借用原型的属性,改了原型上的属性,所有自己没有同名属性的对象都跟着变。两者只共享一点:从一个已有的对象出发造新对象。

  1. 对象没有嵌套的可变状态。字段全是字符串、数字,直接构造更清楚。Python 的 dataclass 用 dataclasses.replace(chart, title="退款额"),TS 的普通对象用 { ...chart, title: "退款额" },一行就是“复制再改”。再写个 clone(),读者还得点进去看它拷了什么。
  2. 语言自带的通用拷贝已经够用。clone() 的实现只有一行 copy.deepcopy(self) 时,调用方直接写 copy.deepcopy(chart) 效果一样。显式的 clone() 只在要控制拷多深时才值得:哪些字段共享,哪些新建,像例一的锁。
  3. 对象挂着大量数据。如果 ChartConfig 里还存着查询出来的全部数据行,每点一次“复制”,这些行就在内存里多一份。数据应该共享,只复制配置,这时不能直接 deepcopy 了事。
模式 意图 区别
建造者(Builder) 一步步构造一个复杂对象 建造者从零开始,一项项填;原型从一个已经配好的对象出发,复制后只改几处
工厂方法(Factory Method) 由子类决定创建哪个类 工厂方法要为每种产品准备一个子类;原型不需要知道类,手里有一个现成对象就能造出同类的新对象

游戏里的敌人用 EnemySpec 描述:名字 name、血量 hp、攻击力 attack、技能列表 skills、掉落表 drops(物品名到掉落概率)。策划配好了一只“基础哥布林”,想从它克隆出“精英哥布林”:改名之外再改两处,血量翻倍,技能列表里加一个“狂暴”。

请给 EnemySpec 写 clone(),克隆出精英哥布林,打印两只哥布林,并检查基础哥布林的血量和技能列表没被改动。Python 和 TS 各写一版。

提示:先用逐字段复制、直接 skills=old.skills 写一遍,看看基础哥布林是不是也学会了“狂暴”。TS 版想想 structuredClone 之后,EnemySpec 的方法去哪了。

  • 原型模式让对象自己提供 clone(),要相似的新对象时复制一份再改,调用方不用列字段。
  • 字段增减只改类内部,复制点不用跟着改,也就不会有哪一处漏了新字段。
  • 浅拷贝共用列表和嵌套对象,深拷贝一层层复制;clone() 把这个选择收到一处,但选择本身还得你来做。
  • Python 的 copy 模块和 __copy__、__deepcopy__ 就是现成的原型协议;TS 的 structuredClone 只拷数据,类实例要自己重建。
  • JavaScript 的原型链和原型模式同名不同义:前者是借用,后者是复制。