[控场AI]
· 8 分钟阅读· 4,415 字

用备忘录模式让 Python 撤销功能变得异常简单

用备忘录模式让 Python 撤销功能变得异常简单

用备忘录模式以状态快照替代反向逻辑,结合Python Protocol与泛型实现干净的撤销/重做机制。

备忘录模式(Memento Pattern)通过在操作前拍摄对象的状态快照来实现撤销,彻底回避了为每种操作单独编写反向逻辑的耦合问题。文章以 Python 工作流对象为例,展示了用 `frozen=True` 的 dataclass 表示不可变备忘录、让对象自身承担 `snapshot()`/`restore()` 职责,再以 `Protocol` 定义 `Snapshotable` 抽象、泛型 `History` 类管理快照栈的完整实现。扩展到 redo 只需增加一个栈。文章同时点明了模式的边界:快照应只捕获与恢复目的相关的状态子集,无法撤销外部副作用,大状态会带来内存压力;并将备忘录与命令模式做了对比——前者存状态,后者存操作,适用场景不同却可互补。

当你需要为一个可被用户修改的对象实现「撤销」功能时,最直觉的做法是为每个操作都编写对应的反向逻辑:撤销重命名、撤销添加步骤、撤销移动步骤……更糟的是,每新增一个操作,就得同步写一份反向实现。这种耦合会随着功能增长迅速失控。备忘录模式(Memento Pattern)提供了一条更优雅的路径——它不关心操作本身,而是关心对象的状态。

本文基于一段关于 Python 设计模式的视频讲解,拆解备忘录模式的实现思路,以及它在状态、封装与抽象上给我们的启示。

从反向逻辑到状态快照

假设我们有一个自动化系统里的 Workflow(工作流),它包含名称和若干 Step(步骤),每个步骤有名称和是否启用的标志。可执行的操作包括重命名工作流、添加步骤等。

如果用反向逻辑实现撤销,每一次 mutation 都需要两样东西:反向操作本身,以及足够重建上一状态的信息。后者往往是最烦人的部分。备忘录模式的核心思路截然不同:在做出改变之前,先对当前状态拍一张快照。需要撤销时,直接把对象恢复到快照状态即可。

创建快照以保存修改前的状态

实现快照的第一步,是用一个不可变的数据类来表示它。视频中使用了 frozen=True 的 dataclass,命名为 WorkflowSnapshot,包含 name 字段和一个 steps 的元组(而非列表)——因为快照本身不应该被修改。这个不可变对象就是模式中所谓的「备忘录」(Memento)。

让对象自己负责保存与恢复

接下来,为 Workflow 类扩展两个方法:snapshot() 返回一个 WorkflowSnapshot,把当前的 name 和 steps 元组存进去;restore() 则接收一个快照,把信息写回对象,并将元组还原成列表。

@dataclass(frozen=True)
class WorkflowSnapshot:
    name: str
    steps: tuple[Step, ...]

class Workflow:
    def snapshot(self) -> WorkflowSnapshot:
        return WorkflowSnapshot(self.name, tuple(self.steps))

    def restore(self, snap: WorkflowSnapshot) -> None:
        self.name = snap.name
        self.steps = list(snap.steps)

实现后,整个撤销流程只需:拍快照 → 编辑 → 恢复快照。无需为任何操作编写反向逻辑。即便之后新增「打乱步骤顺序」之类的操作,快照与恢复机制也完全不受影响。

此时已完成备忘录模式三个角色中的两个

这里有个关键的设计原则:工作流对象拥有自己的状态,因此它也应当知道如何快照和恢复自己。而管理历史记录的机制不需要了解工作流内部如何工作,它只需知道「可以 snapshot,可以 restore」。这就是封装带来的解耦。

三个角色与用 Protocol 表达的抽象

到这里,备忘录模式的三个经典角色已出现两个:Workflow 是发起人(Originator),即状态需要被保存和恢复的对象;WorkflowSnapshot 是备忘录(Memento),承载被保存的状态。缺失的是管理者(Caretaker)——负责保管备忘录以便日后恢复的东西。

这些命名来自 1994 年出版的「四人帮」(Gang of Four)设计模式一书。但正如视频所强调的,在现代 Python 里我们不必照搬那张类图。管理者其实不需要知道 Workflow 的存在,它只需要「某个能创建和恢复快照的东西」。这种能力可以用 Protocol 来表达:

from typing import Protocol

class Snapshotable[T](Protocol):
    def snapshot(self) -> T: ...
    def restore(self, snap: T) -> None: ...

得益于 Protocol 的结构化子类型,Workflow 无需继承任何东西就自动满足这个协议。随后便可以写一个泛型的 History 管理者:

class History[T]:
    def __init__(self) -> None:
        self.snapshots: list[T] = []

    def save(self, obj: Snapshotable[T]) -> None:
        self.snapshots.append(obj.snapshot())

    def undo(self, obj: Snapshotable[T]) -> bool:
        if not self.snapshots:
            return False
        obj.restore(self.snapshots.pop())
        return True

undo 操作弹出最新快照并返回 True

History 完全不知道工作流如何运作,它只知道「给我一个能创建和恢复类型 T 快照的东西」。这正是审视经典设计模式的好方法:不要机械照搬,而是看语言本身提供了哪些特性,能让模式表达得更干净。

「四人帮」(Gang of Four)指 Erich Gamma、Richard Helm、Ralph Johnson 和 John Vlissides 四位作者,他们于 1994 年出版了《设计模式:可复用面向对象软件的基础》(Design Patterns: Elements of Reusable Object-Oriented Software)。书中总结了 23 种经典设计模式,备忘录模式是其中之一,原始类图基于强类型的 C++ 和 Java 风格设计,依赖继承和接口声明来约束角色关系。Python 的 Protocol 则采用结构化子类型(structural subtyping,也称鸭子类型的静态版本):一个类只要拥有协议要求的方法签名,就被视为满足该协议,无需显式继承或注册。这与 Java 的 implements 接口声明形成对比,后者要求显式声明意图。Python 3.8 引入 typing.Protocol 后,这种「隐式满足」的能力可以被静态类型检查器(如 mypy、pyright)识别,兼顾了灵活性与类型安全。

扩展到 Redo 与真实世界的边界

一旦有了快照系统,加入「重做」(redo)功能几乎只是多加一个栈。History 维护 undo 和 redo 两个列表:执行 undo 时把快照压入 redo 栈,执行 redo 时再压回 undo 栈,两者保持同步即可。

undo 后再执行 redo 恢复到编辑后的状态

但这个模式并非没有需要权衡的决策:

  • 哪些状态该进快照? 如果工作流还包含运行时信息(如 last_run_at、执行次数),撤销一次重命名是否该把这些值也回退?通常不该。快照不应自动序列化每个属性,而应只捕获与「要恢复的目的」相关的状态。
  • 有些状态难以拷贝。 真实对象可能持有数据库连接、锁、服务、缓存、文件句柄,或大量无关状态。显式的快照应当明确界定「为这个目的恢复对象所需的状态边界」。
  • 恢复状态不等于撤销副作用。 恢复对象无法撤回已发送的邮件、反转一次 API 调用,或把钱退回别人的账户。一旦涉及外部系统,就需要事务、补偿操作等更复杂的架构机制。
  • 内存成本。 巨大的状态加上大量快照会带来内存压力。

何时用备忘录,何时用命令模式

备忘录常被拿来与命令模式(Command Pattern)对比,二者的根本区别是:备忘录存储状态,命令存储操作。

当状态拷贝成本较低、你有许多不同类型的 mutation,且「恢复旧状态」比「反转每个操作」更简单时,备忘录更合适。而当快照会非常庞大、单次改动很小,或操作本身(如「重命名工作流」「删除步骤」)是有意义的领域概念、需要进入审计日志时,命令模式往往更好。两者还能结合——命令记录操作,偶尔用备忘录做检查点(checkpoint),应用于草稿状态、模拟、多步操作失败后的局部回滚等场景。

最后一点提醒:如果只是个很小的对象,直接复制一份就足够了,不必为了套用「四人帮」里的 Originator、Memento、Caretaker 名字而引入一堆基类。本文展示的全部东西,不过是几个 frozen dataclass、一个 Protocol 和一个小小的泛型 History 类。

这个模式真正有价值的设计启示在于:让拥有状态的对象自己负责捕获和恢复状态,模式其余的基础设施只依赖这一能力。在 Python 中,用几个数据类、一个协议和现代泛型语法,就能构建出相当干净的解决方案。

命令模式(Command Pattern)将「操作」本身封装为对象,每个操作对象通常包含 execute() 和 undo() 两个方法,撤销时调用对应的反向逻辑。这意味着系统需要维护一个操作对象队列,每种操作都需要单独实现其反转逻辑。命令模式的优势在于操作本身成为一等公民——可以排队、延迟执行、序列化后跨进程传输,也天然适合审计日志(audit log)场景,因为日志中记录的是「谁在何时做了什么」而非「当时的状态是什么」。在版本控制系统的类比中,备忘录更像是「保存完整快照」,命令模式更像是「记录差异(diff)」——前者恢复代价固定,后者存储效率更高但回放链路更长。两种模式结合时,命令负责细粒度操作记录,备忘录定期做检查点,可在长操作链中快速跳转到任意历史节点。

#备忘录模式#Memento Pattern#Python 撤销功能#设计模式#Protocol 协议#命令模式#状态快照#Python 泛型
分享:

相关推荐