第 25 章 · 备忘录(Memento)
一句话:对象自己把内部状态打包成一个不可变的快照交出去,外面只管保存;要恢复时交还给它,由它自己拆开。 解决的痛:编辑器的字段由页面代码一个个抄出去、一个个赋回来;加了“规格图片”字段,恢复代码漏写一行,撤销后价格回来了,图片还是改过的那张。
25.1 一个场景
Section titled “25.1 一个场景”多练网周边商城的运营后台有个商品详情编辑器,用来改标题、价格和规格列表(青轴、红轴……)。
编辑器要支持两件事:点“撤销”一步步往回退;点“恢复到上次保存”,一步回到最近保存的内容。两件事是同一个动作:修改前把编辑器的状态存一份,需要时再放回去。
25.2 先按直觉写
Section titled “25.2 先按直觉写”编辑器的字段都是公开的。页面代码每次修改前把字段一个个抄进字典,压进撤销栈;撤销时弹出来,再一个个赋回去:
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(...) 复制一份,免得存档跟着编辑器一起变。三个字段,抄出去三行,赋回来三行,能用。大多数人第一版都这么写。
25.3 需求变了
Section titled “25.3 需求变了”新需求是:每个规格要带上各仓库的库存({"北京仓": 10, "上海仓": 4} 这样的嵌套字典),编辑器还要加一张“规格图片”。
要改的地方:
ProductEditor:specs改成Spec列表,每个Spec带一个库存字典;加spec_image。这一处躲不掉。Saved和save:加spec_image;list(...)只复制最外层,库存字典还和编辑器共用,要改成深拷贝(deep copy)copy.deepcopy。restore:同样要加spec_image、改成深拷贝。同一份存档可能被恢复好几次,直接把存档里的列表赋回去,再改库存就改到存档了。
同事改了前两处,第 3 处只改了深拷贝(文件头加 import copy 和 from dataclasses import dataclass):
@dataclassclass 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,类型检查也不管。运营撤销后以为全回去了,点了发布,商品页挂上了没审过的新图。
问题的形状是:编辑器每加一个字段,编辑器外面的保存、恢复两段代码都要跟着加;拷多深也要每个调用方自己记得,漏一处不报错。
黄色的 ① 是加了字段的编辑器,浅红的 ②③ 是页面里跟着改的两段代码;红线只写回三个字段,加粗的框是事故。
25.4 用备忘录模式重写
Section titled “25.4 用备忘录模式重写”把保存和恢复交还给编辑器。全部内部状态放进 EditorState;save() 把它整个深拷贝进不可变的快照(snapshot)Snapshot,restore(snapshot) 再整个拷回来。页面代码和 EditHistory 只拿着快照,看不到里面的字段:
import copyfrom dataclasses import dataclass
@dataclassclass Spec: name: str stock: dict[str, int] # 仓库 -> 库存
@dataclassclass 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.pngTS 版一样:
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.pngTS 版用 #state 把内容真正藏起来。只有 Snapshot 类里面的代码能读 #state,所以用 static {} 静态块(ES2022,类定义时执行一次)把读法交给同文件的 readState,readState 在这时拿到值,供 ProductEditor 用。真实项目里两个类放在一个模块里,readState 不导出,别处只看得到 label,相当于 C++ 的友元(friend)。
黄色的三个类是新加的。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 只在编译时挡。
25.5 模式定义
Section titled “25.5 模式定义”GoF 对备忘录的定义是:在不破坏封装性的前提下,捕获一个对象的内部状态,并在该对象之外保存这个状态。这样以后就可将该对象恢复到原先保存的状态。
落到本章:“存”放在外面,“拆”留在里面,只有编辑器知道快照里有什么。GoF 说备忘录有两种接口:发起人看到宽接口(wide interface),能读写全部状态;负责人只看到窄接口(narrow interface),只能传来传去。
对照案例图:ProductEditor 是 Originator,save、restore 对应 createMemento、setMemento;Snapshot 是 Memento,EditHistory 是 Caretaker。
| 角色 | 在本章案例里是谁 | 职责 |
|---|---|---|
| 发起人(Originator) | ProductEditor |
把此刻的状态存进备忘录;拿回备忘录时用它恢复 |
| 备忘录(Memento) | Snapshot |
存发起人某一刻的内部状态;只让发起人读,建好不再变 |
| 负责人(Caretaker) | EditHistory(和调用它的页面代码) |
保管备忘录,决定何时存、取哪份,不读不改内容 |
常见场景:
- 撤销:编辑器、画图工具、表单。
- 草稿:定时把表单内容存一份,见 25.9。
- 游戏存档:读档时关卡、血量、背包整个换回。
- 事务回滚:数据库改数据前记下旧值,回滚时写回去,大致是这个思路。
25.6 真实系统里的例子
Section titled “25.6 真实系统里的例子”例一: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,输出里没有“手滑多加的”。
25.7 什么时候别用
Section titled “25.7 什么时候别用”- 状态很大,保存又很频繁。在线表格每改一格就存一份整张表,几万行撤销 100 步就是 100 份。这时用第 19 章命令模式的反向操作:只记“B3 从 5 改成 8”,撤销时改回 5。
- 状态里有外部资源。数据库连接、打开的文件拷不成快照:
copy.deepcopy碰到文件对象、socket 直接报错(同第 7 章 7.6 例一的锁);就算拷了,恢复时连接那头早变了。快照只存连接参数,恢复时重新连。
25.8 容易混淆的模式
Section titled “25.8 容易混淆的模式”| 模式 | 意图 | 区别 |
|---|---|---|
| 备忘录(Memento) | 保存对象的内部状态,以后恢复 | 撤销靠快照:存下“之前是什么样”,恢复时整个换回 |
| 命令(Command) | 把一个请求封装成对象 | 撤销可以靠反向操作:记住“做了什么”,按相反方向做一遍。见第 19 章 |
| 原型(Prototype) | 复制已有对象来创建新对象 | 都在复制,目的不同:原型复制出的新对象拿来接着用;备忘录复制出的快照谁也不改,只会交回 restore(),把原对象变回去。见第 7 章 |
两组都能用一个问题当场判断:
- 备忘录还是命令:问“撤销时靠存下来的状态,还是靠反向操作”。第 19 章的
ChangePrice两者都用了:命令记住“做了什么”,快照负责“恢复成什么样”。能算出反向操作的用反向操作,算不出的(比如一键排版改了上百处)再存快照。 - 备忘录还是原型:问“复制出来的东西给谁用”。第 7 章的
clone()复制出新图表给用户接着改;本章save()复制出的快照谁也不改,只会交回restore()。
25.9 练一练
Section titled “25.9 练一练”论坛的发帖表单要自动存草稿:每 30 秒存一次快照,最多保留 5 份,可以回到任意一份。写 Form(发起人,有 fill(name, value)、save、restore)、Draft(备忘录)和 AutoSaver(负责人)。AutoSaver.tick() 每调一次算 10 秒,每 3 次存一份。调 20 次、每次往正文多写一句,打印保留了哪几份,再回到其中两份。Python 和 TS 各写一版。
提示:Python 的 collections.deque(maxlen=5) 满了会自动挤掉最旧的;Draft 里存元组或冻结的数组,保证存进去以后改不了。
25.10 小结
Section titled “25.10 小结”- 备忘录让对象自己把内部状态打包成快照交出去,外面只管存,恢复时交还给它自己拆。
- 加字段只改对象自己,保存、恢复的代码不用跟着改,不会再漏字段。
- 快照要深拷贝、建好不再变;Python 靠下划线约定藏字段,TS 的
#private能真正藏住。 - 代价是内存:每份快照都是完整副本,长历史要限条数或只存差异;状态大、改得勤时改用命令的反向操作。