跳转到内容

第 25 章 · 备忘录(Memento)

一句话:对象自己把内部状态打包成一个不可变的快照交出去,外面只管保存;要恢复时交还给它,由它自己拆开。 解决的痛:编辑器的字段由页面代码一个个抄出去、一个个赋回来;加了“规格图片”字段,恢复代码漏写一行,撤销后价格回来了,图片还是改过的那张。

多练网周边商城的运营后台有个商品详情编辑器,用来改标题、价格和规格列表(青轴、红轴……)。

编辑器要支持两件事:点“撤销”一步步往回退;点“恢复到上次保存”,一步回到最近保存的内容。两件事是同一个动作:修改前把编辑器的状态存一份,需要时再放回去。

编辑器的字段都是公开的。页面代码每次修改前把字段一个个抄进字典,压进撤销栈;撤销时弹出来,再一个个赋回去:

from typing import TypedDict
class ProductEditor: # 商品详情编辑器:字段都是公开的
def __init__(self, title: str, price: int, specs: list[str]) -> None:
self.title = title
self.price = price
self.specs = specs
def show(self) -> str:
return f"{self.title} | {self.price} 元 | 规格 {'/'.join(self.specs)}"
class Saved(TypedDict): # 页面代码自己定的"存档格式"
title: str
price: int
specs: list[str]
def save(editor: ProductEditor) -> Saved: # 字段一个个抄出去
return {"title": editor.title, "price": editor.price, "specs": list(editor.specs)}
def restore(editor: ProductEditor, saved: Saved) -> None: # 再一个个赋回去
editor.title = saved["title"]
editor.price = saved["price"]
editor.specs = list(saved["specs"])
if __name__ == "__main__":
editor = ProductEditor("机械键盘", 399, ["青轴", "红轴"])
undo: list[Saved] = [] # 撤销栈:每次修改前存一份
print(f"原始:{editor.show()}")
undo.append(save(editor))
editor.price = 359
editor.specs.append("茶轴")
print(f"改 1:{editor.show()}")
undo.append(save(editor))
editor.title = "机械键盘 87 键"
print(f"改 2:{editor.show()}")
for _ in range(2):
restore(editor, undo.pop())
print(f"撤销:{editor.show()}")
# 输出:
# 原始:机械键盘 | 399 元 | 规格 青轴/红轴
# 改 1:机械键盘 | 359 元 | 规格 青轴/红轴/茶轴
# 改 2:机械键盘 87 键 | 359 元 | 规格 青轴/红轴/茶轴
# 撤销:机械键盘 | 359 元 | 规格 青轴/红轴/茶轴
# 撤销:机械键盘 | 399 元 | 规格 青轴/红轴

TS 版一样:

class ProductEditor { // 商品详情编辑器:字段都是公开的
constructor(public title: string, public price: number, public specs: string[]) {}
show(): string {
return `${this.title} | ${this.price} 元 | 规格 ${this.specs.join("/")}`;
}
}
type Saved = { title: string; price: number; specs: string[] }; // 页面代码自己定的"存档格式"
function save(editor: ProductEditor): Saved { // 字段一个个抄出去
return { title: editor.title, price: editor.price, specs: [...editor.specs] };
}
function restore(editor: ProductEditor, saved: Saved): void { // 再一个个赋回去
editor.title = saved.title;
editor.price = saved.price;
editor.specs = [...saved.specs];
}
const editor = new ProductEditor("机械键盘", 399, ["青轴", "红轴"]);
const undo: Saved[] = []; // 撤销栈:每次修改前存一份
console.log(`原始:${editor.show()}`);
undo.push(save(editor));
editor.price = 359;
editor.specs.push("茶轴");
console.log(`改 1:${editor.show()}`);
undo.push(save(editor));
editor.title = "机械键盘 87 键";
console.log(`改 2:${editor.show()}`);
for (let i = 0; i < 2; i++) {
restore(editor, undo.pop()!);
console.log(`撤销:${editor.show()}`);
}
// 输出:
// 原始:机械键盘 | 399 元 | 规格 青轴/红轴
// 改 1:机械键盘 | 359 元 | 规格 青轴/红轴/茶轴
// 改 2:机械键盘 87 键 | 359 元 | 规格 青轴/红轴/茶轴
// 撤销:机械键盘 | 359 元 | 规格 青轴/红轴/茶轴
// 撤销:机械键盘 | 399 元 | 规格 青轴/红轴

规格列表用 list(...) 复制一份,免得存档跟着编辑器一起变。三个字段,抄出去三行,赋回来三行,能用。大多数人第一版都这么写。

新需求是:每个规格要带上各仓库的库存({"北京仓": 10, "上海仓": 4} 这样的嵌套字典),编辑器还要加一张“规格图片”。

要改的地方:

  1. ProductEditor:specs 改成 Spec 列表,每个 Spec 带一个库存字典;加 spec_image。这一处躲不掉。
  2. Saved 和 save:加 spec_image;list(...) 只复制最外层,库存字典还和编辑器共用,要改成深拷贝(deep copy)copy.deepcopy。
  3. restore:同样要加 spec_image、改成深拷贝。同一份存档可能被恢复好几次,直接把存档里的列表赋回去,再改库存就改到存档了。

同事改了前两处,第 3 处只改了深拷贝(文件头加 import copy 和 from dataclasses import dataclass):

@dataclass
class Spec: # 新需求:规格带各仓库的库存
name: str
stock: dict[str, int]
class ProductEditor: # ① 加字段
def __init__(self, title: str, price: int, specs: list[Spec], spec_image: str) -> None:
self.title = title
self.price = price
self.specs = specs
self.spec_image = spec_image # 新字段:规格图片
def show(self) -> str:
specs = "/".join(f"{s.name}[" + " ".join(f"{w}{n}" for w, n in s.stock.items()) + "]" for s in self.specs)
return f"{self.title} | {self.price} 元 | 规格 {specs} | 图 {self.spec_image}"
class Saved(TypedDict):
title: str
price: int
specs: list[Spec]
spec_image: str
def save(editor: ProductEditor) -> Saved: # ② 加字段,规格改深拷贝
return {"title": editor.title, "price": editor.price,
"specs": copy.deepcopy(editor.specs), "spec_image": editor.spec_image}
def restore(editor: ProductEditor, saved: Saved) -> None: # ③ 改了深拷贝,漏了 spec_image
editor.title = saved["title"]
editor.price = saved["price"]
editor.specs = copy.deepcopy(saved["specs"])

入口改成先改价、改库存,再换图,然后撤销两步:

if __name__ == "__main__":
editor = ProductEditor("机械键盘", 399, [Spec("青轴", {"北京仓": 10, "上海仓": 4}),
Spec("红轴", {"北京仓": 6})], "kb-v1.png")
undo: list[Saved] = []
print(f"原始:{editor.show()}")
undo.append(save(editor))
editor.price = 359
editor.specs[0].stock["北京仓"] = 7
print(f"改 1:{editor.show()}")
undo.append(save(editor))
editor.spec_image = "kb-v2.png"
print(f"改 2:{editor.show()}")
for _ in range(2):
restore(editor, undo.pop())
print(f"撤销:{editor.show()}")

运行结果如下(TS 版改的是同样的地方,深拷贝用 structuredClone,输出相同):

原始:机械键盘 | 399 元 | 规格 青轴[北京仓10 上海仓4]/红轴[北京仓6] | 图 kb-v1.png
改 1:机械键盘 | 359 元 | 规格 青轴[北京仓7 上海仓4]/红轴[北京仓6] | 图 kb-v1.png
改 2:机械键盘 | 359 元 | 规格 青轴[北京仓7 上海仓4]/红轴[北京仓6] | 图 kb-v2.png
撤销:机械键盘 | 359 元 | 规格 青轴[北京仓7 上海仓4]/红轴[北京仓6] | 图 kb-v2.png
撤销:机械键盘 | 399 元 | 规格 青轴[北京仓10 上海仓4]/红轴[北京仓6] | 图 kb-v2.png

最后两行错了。价格回到 399,北京仓回到 10,图片却还是 kb-v2.png。程序不报错:restore 不读 spec_image,类型检查也不管。运营撤销后以为全回去了,点了发布,商品页挂上了没审过的新图。

问题的形状是:编辑器每加一个字段,编辑器外面的保存、恢复两段代码都要跟着加;拷多深也要每个调用方自己记得,漏一处不报错。

直觉写法:字段由页面代码抄出去、赋回来,恢复时少赋了一个

黄色的 ① 是加了字段的编辑器,浅红的 ②③ 是页面里跟着改的两段代码;红线只写回三个字段,加粗的框是事故。

把保存和恢复交还给编辑器。全部内部状态放进 EditorState;save() 把它整个深拷贝进不可变的快照(snapshot)Snapshot,restore(snapshot) 再整个拷回来。页面代码和 EditHistory 只拿着快照,看不到里面的字段:

import copy
from dataclasses import dataclass
@dataclass
class Spec:
name: str
stock: dict[str, int] # 仓库 -> 库存
@dataclass
class EditorState: # 编辑器的全部内部状态:加字段只加在这里
title: str
price: int
specs: list[Spec]
spec_image: str
@dataclass(frozen=True)
class Snapshot: # 备忘录:建好就不能改;_state 只给 ProductEditor 读,Python 只能靠下划线约定
label: str
_state: EditorState
class ProductEditor: # 发起人:自己存、自己恢复
def __init__(self, state: EditorState) -> None:
self._state = state
def save(self, label: str) -> Snapshot:
return Snapshot(label, copy.deepcopy(self._state)) # 整个状态一起深拷贝,不再逐个列字段
def restore(self, snapshot: Snapshot) -> None:
self._state = copy.deepcopy(snapshot._state) # 再拷一份:恢复后接着改,也改不到快照
def set_price(self, price: int) -> None:
self._state.price = price
def set_stock(self, spec: str, warehouse: str, n: int) -> None:
next(s for s in self._state.specs if s.name == spec).stock[warehouse] = n
def set_image(self, name: str) -> None:
self._state.spec_image = name
def show(self) -> str:
s = self._state
specs = "/".join(f"{x.name}[" + " ".join(f"{w}{n}" for w, n in x.stock.items()) + "]" for x in s.specs)
return f"{s.title} | {s.price} 元 | 规格 {specs} | 图 {s.spec_image}"
class EditHistory: # 负责人:只存快照、按顺序取,不读快照里的内容
def __init__(self) -> None:
self._snapshots: list[Snapshot] = []
def push(self, snapshot: Snapshot) -> None:
self._snapshots.append(snapshot)
def pop(self) -> Snapshot:
return self._snapshots.pop()
def labels(self) -> list[str]:
return [s.label for s in self._snapshots]
if __name__ == "__main__":
editor = ProductEditor(EditorState("机械键盘", 399, [Spec("青轴", {"北京仓": 10, "上海仓": 4}),
Spec("红轴", {"北京仓": 6})], "kb-v1.png"))
undo = EditHistory()
print(f"原始:{editor.show()}")
undo.push(editor.save("改价前"))
editor.set_price(359)
editor.set_stock("青轴", "北京仓", 7)
print(f"改 1:{editor.show()}")
undo.push(editor.save("换图前"))
editor.set_image("kb-v2.png")
print(f"改 2:{editor.show()}")
print(f"历史:{', '.join(undo.labels())}")
for _ in range(2):
editor.restore(undo.pop())
print(f"撤销:{editor.show()}")
# 输出:
# 原始:机械键盘 | 399 元 | 规格 青轴[北京仓10 上海仓4]/红轴[北京仓6] | 图 kb-v1.png
# 改 1:机械键盘 | 359 元 | 规格 青轴[北京仓7 上海仓4]/红轴[北京仓6] | 图 kb-v1.png
# 改 2:机械键盘 | 359 元 | 规格 青轴[北京仓7 上海仓4]/红轴[北京仓6] | 图 kb-v2.png
# 历史:改价前, 换图前
# 撤销:机械键盘 | 359 元 | 规格 青轴[北京仓7 上海仓4]/红轴[北京仓6] | 图 kb-v1.png
# 撤销:机械键盘 | 399 元 | 规格 青轴[北京仓10 上海仓4]/红轴[北京仓6] | 图 kb-v1.png

TS 版一样:

type Spec = { name: string; stock: Record<string, number> }; // 仓库 -> 库存
type EditorState = { // 编辑器的全部内部状态:加字段只加在这里
title: string;
price: number;
specs: Spec[];
specImage: string;
};
let readState: (snapshot: Snapshot) => EditorState; // 读快照内容的"钥匙",只在本文件里交给 ProductEditor
class Snapshot { // 备忘录:内容放在 #state 里,外面的代码读不到,也改不了
readonly #state: EditorState;
constructor(readonly label: string, state: EditorState) {
this.#state = state;
Object.freeze(this);
}
static {
readState = (snapshot) => snapshot.#state; // 只有写在类里面的代码能读 #state,这里把读法交出去
}
}
class ProductEditor { // 发起人:自己存、自己恢复
#state: EditorState;
constructor(state: EditorState) {
this.#state = state;
}
save(label: string): Snapshot {
return new Snapshot(label, structuredClone(this.#state)); // 整个状态一起深拷贝,不再逐个列字段
}
restore(snapshot: Snapshot): void {
this.#state = structuredClone(readState(snapshot)); // 再拷一份:恢复后接着改,也改不到快照
}
setPrice(price: number): void {
this.#state.price = price;
}
setStock(spec: string, warehouse: string, n: number): void {
this.#state.specs.find((s) => s.name === spec)!.stock[warehouse] = n;
}
setImage(name: string): void {
this.#state.specImage = name;
}
show(): string {
const s = this.#state;
const specs = s.specs.map((x) => `${x.name}[${Object.entries(x.stock).map(([w, n]) => `${w}${n}`).join(" ")}]`).join("/");
return `${s.title} | ${s.price} 元 | 规格 ${specs} | 图 ${s.specImage}`;
}
}
class EditHistory { // 负责人:只存快照、按顺序取,不读快照里的内容
readonly #snapshots: Snapshot[] = [];
push(snapshot: Snapshot): void {
this.#snapshots.push(snapshot);
}
pop(): Snapshot {
return this.#snapshots.pop()!;
}
labels(): string[] {
return this.#snapshots.map((s) => s.label);
}
}
const editor = new ProductEditor({
title: "机械键盘", price: 399,
specs: [{ name: "青轴", stock: { 北京仓: 10, 上海仓: 4 } }, { name: "红轴", stock: { 北京仓: 6 } }],
specImage: "kb-v1.png",
});
const undo = new EditHistory();
console.log(`原始:${editor.show()}`);
undo.push(editor.save("改价前"));
editor.setPrice(359);
editor.setStock("青轴", "北京仓", 7);
console.log(`改 1:${editor.show()}`);
undo.push(editor.save("换图前"));
editor.setImage("kb-v2.png");
console.log(`改 2:${editor.show()}`);
console.log(`历史:${undo.labels().join(", ")}`);
for (let i = 0; i < 2; i++) {
editor.restore(undo.pop());
console.log(`撤销:${editor.show()}`);
}
// 输出:
// 原始:机械键盘 | 399 元 | 规格 青轴[北京仓10 上海仓4]/红轴[北京仓6] | 图 kb-v1.png
// 改 1:机械键盘 | 359 元 | 规格 青轴[北京仓7 上海仓4]/红轴[北京仓6] | 图 kb-v1.png
// 改 2:机械键盘 | 359 元 | 规格 青轴[北京仓7 上海仓4]/红轴[北京仓6] | 图 kb-v2.png
// 历史:改价前, 换图前
// 撤销:机械键盘 | 359 元 | 规格 青轴[北京仓7 上海仓4]/红轴[北京仓6] | 图 kb-v1.png
// 撤销:机械键盘 | 399 元 | 规格 青轴[北京仓10 上海仓4]/红轴[北京仓6] | 图 kb-v1.png

TS 版用 #state 把内容真正藏起来。只有 Snapshot 类里面的代码能读 #state,所以用 static {} 静态块(ES2022,类定义时执行一次)把读法交给同文件的 readState,readState 在这时拿到值,供 ProductEditor 用。真实项目里两个类放在一个模块里,readState 不导出,别处只看得到 label,相当于 C++ 的友元(friend)。

备忘录版:编辑器自己存、自己恢复,EditHistory 只保管快照

黄色的三个类是新加的。EditHistory 和 Snapshot 之间那条线写着“只存不读”。

撤销一次:存快照、入栈、修改、出栈、恢复

页面代码从头到尾只是把快照从编辑器搬到 EditHistory,再搬回去。

对照 25.3:

  • 第 ① 处:加字段只加在 EditorState 上,show 和新的 setter 是编辑器自己的事。
  • 第 ② 处:save 一行没改。copy.deepcopy(self._state) 整个拷贝,嵌套的库存字典也一起拷了。
  • 第 ③ 处:restore 一行没改,整个换回来,撤销两步后图片回到 kb-v1.png。“恢复到上次保存”就是拿最近一份快照再调一次 restore,里面的深拷贝保证快照能反复用。

代价也要说清楚。第一,每份快照都是整个状态的完整副本。图片要是存字节而不是文件名,撤销 100 步就是 100 张图。长历史要限条数(如 deque(maxlen=50)),或只存和上一份的差异,那样恢复要一份份往回推,复杂得多。第二,字段不再公开,页面要通过 set_price 这类方法改。第三,Python 拦不住 snapshot._state,下划线只是约定;TS 的 #state 运行时也读不到,private 只在编译时挡。

GoF 对备忘录的定义是:在不破坏封装性的前提下,捕获一个对象的内部状态,并在该对象之外保存这个状态。这样以后就可将该对象恢复到原先保存的状态。

落到本章:“存”放在外面,“拆”留在里面,只有编辑器知道快照里有什么。GoF 说备忘录有两种接口:发起人看到宽接口(wide interface),能读写全部状态;负责人只看到窄接口(narrow interface),只能传来传去。

备忘录的结构:Originator 创建并读回 Memento,Caretaker 只保管

对照案例图:ProductEditor 是 Originator,save、restore 对应 createMemento、setMemento;Snapshot 是 Memento,EditHistory 是 Caretaker。

角色 在本章案例里是谁 职责
发起人(Originator) ProductEditor 把此刻的状态存进备忘录;拿回备忘录时用它恢复
备忘录(Memento) Snapshot 存发起人某一刻的内部状态;只让发起人读,建好不再变
负责人(Caretaker) EditHistory(和调用它的页面代码) 保管备忘录,决定何时存、取哪份,不读不改内容

常见场景:

  • 撤销:编辑器、画图工具、表单。
  • 草稿:定时把表单内容存一份,见 25.9。
  • 游戏存档:读档时关卡、血量、背包整个换回。
  • 事务回滚:数据库改数据前记下旧值,回滚时写回去,大致是这个思路。

例一:random 模块的 getstate 和 setstate(Python)

Section titled “例一:random 模块的 getstate 和 setstate(Python)”

Python 标准库的 random 自带一对备忘录方法:random.getstate() 返回生成器此刻的内部状态,random.setstate(state) 把它放回去,之后的随机数和当时一模一样。种子只能从序列开头重来,状态可以停在序列中间任何一处。文档只承诺这个对象能交回 setstate,没说里面是什么。下面是真实的标准库调用:

import random
# 抽奖服务:开奖前存下随机数生成器的状态,事后有人质疑,就能把开奖过程原样重放一遍
random.seed(2026)
random.random() # 开奖前别的代码已经用过随机数,生成器不在序列开头
state = random.getstate() # 备忘录:此刻的内部状态(CPython 里是:版本号, 梅森旋转算法(Mersenne Twister)的 624 个整数加位置, 缓存)
winners = random.sample(range(1, 1001), 3) # 从 1000 个参与者里抽 3 个
print(f"开奖:{winners}")
print(f"开奖后又抽了一次:{random.randint(1, 100)}") # 状态继续往前走
random.setstate(state) # 恢复到开奖前那一刻
print(f"重放:{random.sample(range(1, 1001), 3)}")
replay = random.Random() # 另一个生成器实例也能接收这份状态
replay.setstate(state)
print(f"另一个实例重放:{replay.sample(range(1, 1001), 3)}")
# state 对调用方是不透明的:存起来、还回去就行,别去拆它,格式随版本可能变
print(f"state 是 {type(state).__name__},{len(state)} 个元素")
# 输出:
# 开奖:[515, 975, 525]
# 开奖后又抽了一次:83
# 重放:[515, 975, 525]
# 另一个实例重放:[515, 975, 525]
# state 是 tuple,3 个元素

random 模块是发起人,state 是备忘录,存着它的抽奖服务是负责人,只存、不拆:CPython 里它是个元组,格式属于实现细节,换个版本就可能变。

例二:编辑器的撤销栈(TypeScript)

Section titled “例二:编辑器的撤销栈(TypeScript)”

前端编辑器的撤销常这样做:每次修改后存一份整个文档的快照,撤销就是换回上一份。Redux 的时间旅行调试(time-travel debugging)也是这个思路:每个 action 之后的 state 都留着,点哪一步就换回哪一份(大致如此)。Redux 约定 reducer 不改旧对象,没变的部分新旧共用,快照不必整个复制。下面用 structuredClone 加 Object.freeze 写一个最小版:

// 简化模型:前端编辑器常见的撤销栈,past / present / future 三段(Redux 文档里的撤销示例大致如此)
type Post = { title: string; tags: string[] };
function snapshot<T>(value: T): T { // 深拷贝再逐层冻结:快照存进去以后谁也改不了
const copy = structuredClone(value);
const freeze = (v: unknown): void => {
if (typeof v === "object" && v !== null) {
Object.values(v).forEach(freeze);
Object.freeze(v);
}
};
freeze(copy);
return copy;
}
class UndoStack<T> {
readonly #past: T[] = [];
readonly #future: T[] = [];
#present: T;
constructor(initial: T) {
this.#present = snapshot(initial);
}
get current(): T { // 交出去的是可以随便改的副本
return structuredClone(this.#present);
}
commit(next: T): void { // 每次修改后存一份快照,新的修改让"重做"失效
this.#past.push(this.#present);
this.#present = snapshot(next);
this.#future.length = 0;
}
undo(): void {
const prev = this.#past.pop();
if (prev === undefined) return;
this.#future.push(this.#present);
this.#present = prev;
}
redo(): void {
const next = this.#future.pop();
if (next === undefined) return;
this.#past.push(this.#present);
this.#present = next;
}
}
const stack = new UndoStack<Post>({ title: "草稿", tags: ["设计模式"] });
const draft = stack.current;
draft.tags.push("备忘录");
stack.commit(draft);
draft.tags.push("手滑多加的"); // commit 之后再改 draft,存进去的快照不受影响
const draft2 = stack.current;
draft2.title = "第 25 章";
stack.commit(draft2);
console.log(`现在:${JSON.stringify(stack.current)}`);
stack.undo();
console.log(`撤销:${JSON.stringify(stack.current)}`);
stack.undo();
console.log(`再撤销:${JSON.stringify(stack.current)}`);
stack.redo();
console.log(`重做:${JSON.stringify(stack.current)}`);
// 输出:
// 现在:{"title":"第 25 章","tags":["设计模式","备忘录"]}
// 撤销:{"title":"草稿","tags":["设计模式","备忘录"]}
// 再撤销:{"title":"草稿","tags":["设计模式"]}
// 重做:{"title":"草稿","tags":["设计模式","备忘录"]}

UndoStack 是负责人,snapshot() 产出的冻结副本是备忘录。这里的状态是普通数据,没有发起人把它藏起来,不可变靠深拷贝加冻结:commit 之后再改 draft,输出里没有“手滑多加的”。

  • 状态很大,保存又很频繁。在线表格每改一格就存一份整张表,几万行撤销 100 步就是 100 份。这时用第 19 章命令模式的反向操作:只记“B3 从 5 改成 8”,撤销时改回 5。
  • 状态里有外部资源。数据库连接、打开的文件拷不成快照:copy.deepcopy 碰到文件对象、socket 直接报错(同第 7 章 7.6 例一的锁);就算拷了,恢复时连接那头早变了。快照只存连接参数,恢复时重新连。
模式 意图 区别
备忘录(Memento) 保存对象的内部状态,以后恢复 撤销靠快照:存下“之前是什么样”,恢复时整个换回
命令(Command) 把一个请求封装成对象 撤销可以靠反向操作:记住“做了什么”,按相反方向做一遍。见第 19 章
原型(Prototype) 复制已有对象来创建新对象 都在复制,目的不同:原型复制出的新对象拿来接着用;备忘录复制出的快照谁也不改,只会交回 restore(),把原对象变回去。见第 7 章

两组都能用一个问题当场判断:

  • 备忘录还是命令:问“撤销时靠存下来的状态,还是靠反向操作”。第 19 章的 ChangePrice 两者都用了:命令记住“做了什么”,快照负责“恢复成什么样”。能算出反向操作的用反向操作,算不出的(比如一键排版改了上百处)再存快照。
  • 备忘录还是原型:问“复制出来的东西给谁用”。第 7 章的 clone() 复制出新图表给用户接着改;本章 save() 复制出的快照谁也不改,只会交回 restore()。

论坛的发帖表单要自动存草稿:每 30 秒存一次快照,最多保留 5 份,可以回到任意一份。写 Form(发起人,有 fill(name, value)、save、restore)、Draft(备忘录)和 AutoSaver(负责人)。AutoSaver.tick() 每调一次算 10 秒,每 3 次存一份。调 20 次、每次往正文多写一句,打印保留了哪几份,再回到其中两份。Python 和 TS 各写一版。

提示:Python 的 collections.deque(maxlen=5) 满了会自动挤掉最旧的;Draft 里存元组或冻结的数组,保证存进去以后改不了。

  • 备忘录让对象自己把内部状态打包成快照交出去,外面只管存,恢复时交还给它自己拆。
  • 加字段只改对象自己,保存、恢复的代码不用跟着改,不会再漏字段。
  • 快照要深拷贝、建好不再变;Python 靠下划线约定藏字段,TS 的 #private 能真正藏住。
  • 代价是内存:每份快照都是完整副本,长历史要限条数或只存差异;状态大、改得勤时改用命令的反向操作。