C++备忘录模式实战:从撤销功能到游戏存档的状态恢复方案
发布时间:2026/9/28 14:20:53 锦皓数字建站

如果你维护过带撤销功能的编辑器或者给游戏写过存档系统大概率会撞上一个绕不开的问题怎么把一个对象的状态完整保存下来之后还能原样恢复最直接的想法是把成员变量一个个拷出来可在 C 里这么干基本等于把封装性扔进垃圾桶。今天这篇就来聊聊行为型设计模式里的备忘录模式Memento Pattern它专门解决这类“状态保存与恢复”的场景也是我平时做状态回滚、事务补偿、游戏快照时用得最多的模式之一。备忘录模式的核心思路并不复杂把“要恢复的状态”单独封装成一个对象由持有状态的对象负责生成和读取再由另一个角色负责保管。整个过程不暴露内部结构外部代码拿到的只是一个“不透明的存档”。这篇文章我会从模式本身讲起然后是 C 实现里的关键设计决策、完整可运行的代码最后是实战扩展和一堆我实测踩过的坑。适合刚学完 C 面向对象、想系统了解设计模式的人也适合已经在项目里写过状态恢复但总觉得“哪里别扭”的开发者。1. 备忘录模式拆解一份“存档”背后的三个角色1.1 从编辑器的撤销功能说起先想一个具体的场景你正在做一个文本编辑器用户输入一行字又觉得不对按一下 CtrlZ内容退回上一步。要支持这个功能程序至少得回答两个问题把什么东西存下来存到哪里去最简单粗暴的做法是在每次修改前把整个编辑器的成员变量复制一份扔到一个全局 vector 里。这在玩具项目里能跑但很快你会发现问题编辑器类里可能有一个几十 MB 的文本缓冲区、一堆光标位置、选区范围、撤销栈本身的状态……如果全部暴露给外部代码去复制编辑器类的私有字段就全被看光了外部代码和内部实现紧紧耦合在一起。今天想改一下内部存储结构明天所有依赖这些字段的存档逻辑全都要跟着改。备忘录模式就是在这种背景下被设计出来的。它把“状态”本身抽成一个独立的备忘录对象只有原始对象自己知道怎么往备忘录里写、怎么从备忘录里读外部对象只负责“保存”和“取出”不能碰备忘录里的任何细节。这样一来封装性保住了撤销逻辑也能独立演进。1.2 三个角色的分工与协作备忘录模式有三个核心角色我用一份游戏存档来打比方特别容易记。第一个是发起人Originator对应游戏里的主角。它拥有完整的状态负责两件事生成一份存档把当时的血量、位置、装备状态封装进备忘录以及从存档中恢复状态。别人不能绕过它直接改主角的数据所有状态变更都通过它来完成。第二个是备忘录Memento对应存档文件本身。它只是一个“状态容器”里面存着发起人在某个时刻的内部数据。对于外部世界来说这个容器是不透明的别人知道它是一个存档但不知道里面具体存了什么。第三个是负责人Caretaker对应游戏里的存档管理界面。它只负责保管备忘录比如把存档放进槽位、取出某个槽位的存档或者管理一个可以回退的撤销栈。负责人从不修改备忘录的内容也根本看不到里面的具体状态。这三者的协作顺序非常清晰发起人调用自己的 save() 生成一个备忘录交给负责人保管当需要回退时负责人把备忘录交还给发起人发起人调用 restore() 把状态恢复到那一瞬间。整个过程里负责人不需要知道状态长什么样备忘录也可以完全不在乎自己会被存在 vector 里还是被序列化到磁盘上。1.3 为什么备忘录模式能守住封装边界很多人第一次看这个模式会觉得“就这”——把状态塞进一个类里再让另一个类存起来这有什么好说的重点其实在于“访问边界”。假设不用备忘录模式让负责人直接读取发起人的私有成员那就必须在发起人里给负责人开友元或者干脆把成员改成 public。前者会让负责人和发起人的内部实现强耦合后者更是直接把封装性放弃了。而备忘录模式把“状态数据”单独隔离出来对外只暴露一个不透明的接口负责人拿到的只是一个“黑盒存档”。在 C 这样的语言里这种边界甚至可以在编译期强制外部代码想碰备忘录的内部数据编译器直接报错连运行期踩雷的机会都没有。当然C 里实现这种“部分可见、部分不可见”的效果有好几种姿势嵌套类、友元、抽象接口都能做具体怎么选下一章详细拆。2. C实现备忘录模式的关键设计访问控制、拷贝策略与存储方式2.1 宽接口与窄接口让“发起人”独占内部状态在 GoF 的经典描述里备忘录模式有一个很微妙的设计点同一个备忘录对发起人和负责人应该呈现两种不同的接口。对发起人它需要“宽接口”也就是能读写备忘录里所有内部数据的方法对其他对象它只需要“窄接口”也就是一个“这是一个存档”的身份标识就够了。用语言特性来翻译这句话备忘录类的状态字段应该是私有的只有发起人能够访问负责人只能持有这个对象的引用或指针但不能读写其内部成员。Java 里通常靠包访问权限和内部类来“约定”这种边界而在 C 里我们有更硬核的手段——把构造函数和访问函数设为 private再让发起人通过友元或嵌套关系来获取访问权。这样做的实际好处是外部代码即使拿到了备忘录对象也没有办法篡改存档内容。这意味着存档一旦生成就处于“只读”状态不会因为你后续操作失误而悄悄变掉。这在调试撤销问题时特别重要——我曾经遇到过一次很隐蔽的 bug存档内容被外部误改导致撤销之后状态错乱排查了一天。用编译期访问控制把这条路堵死这类问题从根本上就消失了。2.2 嵌套类还是友元以及不太一样的替代方案C 里实现“只让发起人访问备忘录内部”最干净的方案是把备忘录类定义成发起人的嵌套类并让发起人成为备忘录的友元。这么做的好处有三个第一备忘录和发起人的关系在代码结构上就一目了然第二备忘录的私有成员只有发起人能碰其他代码想调用私有 getter 连编译都过不去第三负责人只需要声明std::shared_ptrEditor::Memento这种类型就能存储和传递完全不需要知道内部细节。嵌套类方案有一个值得注意的地方虽然嵌套类在发起人的作用域内但它并不会自动获得访问发起人私有成员的权利反过来也一样。所以仍然需要friend class Editor;这行声明让发起人能够读写备忘录的私有数据。这个 friend 声明是安全的因为作用域被限定在“发起人-备忘录”这一对类之间没有把访问权扩散给外部。另一种偏传统的方式是直接把备忘录声明成独立的类再在发起人里写friend class Memento;。这种方式也能工作但会让备忘录类散落在命名空间里职责边界不如嵌套类清晰。还有一种更隐蔽的手段是定义一个空的抽象接口类让备忘录继承它负责人只持有抽象接口的指针。这种方式在跨模块、需要隐藏具体实现类型的场景下很有用但代价是多一次虚函数查表而且在 C 里做“把抽象接口恢复成具体备忘录”的向下转换时需要额外小心类型安全。我自己的经验是同一个项目内部优先用嵌套类跨 DLL 边界或者需要严格二进制兼容时再考虑抽象接口方案。2.3 三种快照存储方式的对比与选型确定了访问控制下一个问题是备忘录里到底用什么形式保存状态不同选择直接决定了内存占用、恢复速度、代码复杂度和跨版本兼容性。存储方式内存成本恢复速度实现复杂度适合场景完整对象拷贝高快低对象小、状态简单增量快照 / 操作日志低中高大文档、高频小改动序列化JSON/二进制高慢中跨版本、持久化、分布式完整对象拷贝是最符合“备忘录”直觉的方案把发起人需要恢复的字段一个一个复制进备忘录。优点是恢复代码极简单缺点是大对象下内存会迅速膨胀。比如一个画布程序每次笔触都存一整张位图几十步撤销下来内存直接爆炸。增量快照的思路是只记录“从上次快照到这次快照之间变化的部分”比如文本编辑器里只存“修改的起止位置 替换前的内容”。这能大幅压缩内存但实现时需要维护基线版本恢复时要沿着增量链反向应用复杂度明显上升。序列化则是把状态变成字节流或字符串优点是便于持久化和跨平台传输缺点是每次保存和恢复都要经过编解码性能是三种方案里最差的。如果对象里还有裸指针序列化还能顺便逼着你把指针换成可重建的 ID反而能暴露不少设计问题。选型没有标准答案我一般按这个经验判断如果快照对象里只有几个 int、string、vector直接用完整对象拷贝如果对象很大但每次改动很小先考虑操作日志如果需要存档跨版本兼容比如游戏版本升级后读旧存档那就必须序列化而且要给每个字段带版本号。别一上来就上最复杂的方案先用简单方案跑通等 profiling 告诉你内存确实成瓶颈了再改成增量快照也不迟。3. 从零手写一个带撤销功能的编辑器3.1 基础版本单层撤销是怎么跑通的前面讲了这么多理论现在上一份可以直接编译运行的代码。这个例子是一个最简单的内容编辑器支持保存状态和恢复到上一步。我用 C17 写编译时开-stdc17就行。#include iostream #include memory #include string #include vector // 发起人负责生成和恢复备忘录 class Editor { public: // 嵌套备忘录类型对外不透明只有 Editor 能访问内部数据 class Memento { private: friend class Editor; Memento(const std::string text, int cursor) : m_text(text), m_cursor(cursor) {} std::string getText() const { return m_text; } int getCursor() const { return m_cursor; } std::string m_text; int m_cursor 0; }; void setText(const std::string text) { m_text text; } void setCursor(int pos) { m_cursor pos; } // 保存当前状态生成一份快照 std::shared_ptrMemento save() const { return std::make_sharedMemento(m_text, m_cursor); } // 从快照恢复状态 void restore(const std::shared_ptrMemento memento) { if (!memento) { return; } m_text memento-getText(); m_cursor memento-getCursor(); } void print() const { std::cout 文本: m_text , 光标位置: m_cursor std::endl; } private: std::string m_text; int m_cursor 0; }; // 负责人只负责保存和取出快照无法查看快照内容 class History { public: void push(std::shared_ptrEditor::Memento memento) { m_history.push_back(std::move(memento)); } std::shared_ptrEditor::Memento pop() { if (m_history.empty()) { return nullptr; } auto memento m_history.back(); m_history.pop_back(); return memento; } bool empty() const { return m_history.empty(); } private: std::vectorstd::shared_ptrEditor::Memento m_history; }; int main() { Editor editor; History history; editor.setText(第一行); editor.setCursor(5); history.push(editor.save()); editor.setText(第一行修改后的内容); editor.setCursor(20); history.push(editor.save()); editor.setText(这是一段想撤销的错误内容); editor.setCursor(99); auto snapshot history.pop(); editor.restore(snapshot); editor.print(); return 0; }这段代码里最值得玩味的是Memento类的设计。它的构造函数是 privategetter 也是 private唯一的例外是friend class Editor。这意味着除了 Editor 自己任何其他代码都没法 new 出一个备忘录也没法读取里面的文本和光标位置。History 类虽然持有std::shared_ptrEditor::Memento但它只能把快照当“黑盒”存起来想偷看一眼内容编译器直接拒绝。程序的输出结果如下文本: 第一行修改后的内容, 光标位置: 20也就是说最后一次setText之后的操作被撤销掉了编辑器回到了第二次修改完成时的状态。3.2 进阶版本无限次撤销与重做基础版本只演示了“撤销一步”真实项目里通常需要能连续撤销外加重做。思路是在负责人或者发起人内部维护两个栈一个 undo 栈一个 redo 栈。每次修改正文前先把当前状态压入 undo 栈执行撤销时把当前状态移入 redo 栈再从 undo 栈弹出最近一份快照来恢复执行重做时反向操作。我把这个逻辑直接写进 Editor 里这样 History 类就只需要提供一个栈式存储。关键代码片段如下class Editor { public: class Memento { // 结构和基础版本一致省略 }; void modify(const std::string text, int cursor) { // 修改前先自动存快照进入 undo 栈 m_undo.push_back(save()); m_text text; m_cursor cursor; // 新操作产生后redo 栈必须清空 m_redo.clear(); } bool canUndo() const { return !m_undo.empty(); } void undo() { if (!canUndo()) { return; } // 当前状态推到 redo 栈再从 undo 栈恢复 m_redo.push_back(save()); auto snapshot m_undo.back(); m_undo.pop_back(); restore(snapshot); } bool canRedo() const { return !m_redo.empty(); } void redo() { if (!canRedo()) { return; } m_undo.push_back(save()); auto snapshot m_redo.back(); m_redo.pop_back(); restore(snapshot); } private: std::string m_text; int m_cursor 0; std::vectorstd::shared_ptrMemento m_undo; std::vectorstd::shared_ptrMemento m_redo; };这里有一个很容易忽略的细节为什么在undo()和redo()里也要先save()一下再恢复因为 undo 和 redo 本质上也是状态迁移当前状态本身也是一个历史节点。如果不先把当前状态推进对侧栈连续撤销几次之后用户就会丢失“撤销前那一刻”的状态重做就会出现跳档。这个细节是我在写一个小工具时踩过的坑最初只在 modify 时存快照结果连续 undo 两次再 redo 一次内容完全对不上。后来把撤消和重做各自也看作一次“状态切换”问题才消失。如果你不想把 undo/redo 栈放在发起人里也可以继续保持三个角色分离用一个 History 类同时维护 undo 栈和 redo 栈。两种方式在逻辑上等价区别只在于“谁拥有状态栈”。我倾向于把栈放在发起人外面也就是负责人持有两个栈这样发起人不需要知道自己正在被撤销还是重做职责更纯粹。3.3 编译运行与实测细节用下面这条命令把代码存成memento.cpp编译运行g -stdc17 -Wall -Wextra memento.cpp -o memento ./memento如果你用的 VSCode MinGW 或者 Visual Studio 配置好了 C 开发环境直接新建源文件粘贴运行即可。这里提醒一下编译时打开-Wall -Wextra能帮你提前发现不少问题比如忘写const、参数未使用、隐式类型转换警告等。我自己写设计模式示例时会默认开启这两个选项因为设计模式的代码往往需要频繁调整接口警告积少成多会掩盖真正的问题。还有一个实测中的细节备忘录类的复制成本。上面的代码里save()每次都完整拷贝m_text和m_cursor。如果m_text很大频繁保存会明显拖慢性能。优化方向不是急着改结构而是先确认快照频率和快照大小哪个才是瓶颈。我在一次性能排查中误以为备忘录拷贝是罪魁祸首结果 profiling 显示真正耗时的是界面层刷新白白改了一通代码。先测量再优化这个顺序在 C 里尤其重要。4. 实战扩展游戏存档、快照优化与命令模式组合4.1 用备忘录模式给游戏角色做存档编辑器撤销只是备忘录模式的一种应用。另一个经典场景是游戏存档玩家在某个节点保存进度之后无论怎么操作都能回到那个时间点。我来演示一个简化版的角色存档顺便展示一个稍微复杂的备忘录数据结构。假设角色有血量、坐标、经验值和背包物品列表struct Vec2 { float x 0; float y 0; }; struct Item { int id 0; int count 0; }; class Player { public: class Memento { private: friend class Player; Memento(int hp, const Vec2 pos, int exp, std::vectorItem items) : m_hp(hp), m_pos(pos), m_exp(exp), m_items(std::move(items)) {} int m_hp; Vec2 m_pos; int m_exp; std::vectorItem m_items; }; std::shared_ptrMemento save() const { return std::make_sharedMemento(m_hp, m_pos, m_exp, m_items); } void restore(const std::shared_ptrMemento memento) { if (!memento) return; m_hp memento-m_hp; m_pos memento-m_pos; m_exp memento-m_exp; m_items memento-m_items; // vector 会自己深拷贝 } void setState(int hp, const Vec2 pos, int exp, std::vectorItem items) { m_hp hp; m_pos pos; m_exp exp; m_items std::move(items); } private: int m_hp 100; Vec2 m_pos; int m_exp 0; std::vectorItem m_items; };注意到细节没有save()直接按值拷贝了整个std::vectorItem。这就是 C 值语义的方便之处标准容器拷贝会深拷贝元素不会出现两个对象共享同一块堆内存的悬案。如果背包里存的是裸指针那就要小心了这是我在第 5 章会专门展开的重灾区。游戏存档的负责人可以是SaveManager它维护多个存档槽位。它只关心“哪个槽位放了哪份快照”完全不关心快照里是血条还是背包。这样后续如果要在存档列表里显示角色等级、游戏时间等元信息正确的做法是额外保存一份“摘要数据”到存档管理表里而不是让负责人去读备忘录内部内容。4.2 快照太大会爆内存增量快照与压缩策略一旦把备忘录模式用到真实项目内存膨胀几乎是必然遇到的事。以画布程序为例用户画一笔你就保存一份完整画布快照画个一百笔内存就把几百份“几乎相同但完全复制”的位图全占了。这时候需要认真对待快照体积。我常用的优化思路有四个层次从低到高第一层缩小快照范围。不要一股脑把所有字段全部抄进去只保存“当前操作涉及的、恢复时需要的最小状态”。比如编辑器撤销光标位置和文档内容通常是必须的但那些临时缓存、计算用的中间变量就不必进快照。很多新手写备忘录习惯把发起人所有成员全部复制一遍其实恢复用不了那么多。第二层做增量快照。每次只保存和上一份快照的差异。这个方案实现起来最灵活需要给每份快照加上“基于哪份基线”的引用。比如文本编辑器存“从第几行第几列到第几行第几列原文本是什么新文本是什么”撤销时对当前文本做一次反向替换就行。这种方式内存开销很小但代码复杂度会明显上升而且伴随着增量链越来越长恢复一个很老的快照可能要应用很多次增量。第三层对快照做压缩。如果快照里是大段文本或像素数据先压缩再保存能显著降低内存占用代价是保存和恢复时多一次压缩/解压的 CPU 开销。我之前的一个项目里把文本快照用 zlib 压缩后内存占用降了大约 80%恢复速度依然能接受属于性价比很高的一步。第四层限制快照数量。撤销栈不是越长越好给 undo/redo 栈设置上限超过就丢弃最老的快照。很多编辑器的撤销上限在 50 到 200 步之间。这个策略不是“省内存”的全部但能防止用户长期操作后内存无限增长属于必须做的兜底。做增量快照时尤其要注意分代问题如果用户撤销到中间状态又进行了新的修改那这条分支上后续的增量快照全部失效需要清空重做栈。这和我在 3.2 节提到的“新操作清空 redo”是同一个原则只是在增量场景下还要把所有基于失效分支的快照一并清理否则恢复时会走到一条已经不存在的时间线上。4.3 备忘录模式和命令模式的组合套路备忘录模式和命令模式Command Pattern经常被放在一起讨论因为它们的适用场景高度重叠但视角不同。命令模式记录的是“做了什么操作”比如“插入一个字符”备忘录模式记录的是“对象变成了什么样子”比如“此时文档全文是什么”。前者是操作导向后者是状态导向。组合使用时最典型的形态是命令对象负责在execute()中执行具体操作并在操作前通过发起人生成一份备忘录负责人把“命令 快照”一起压入历史栈。撤销时先通过备忘录恢复状态再通过命令对重做栈做补偿。这样做的好处是撤销逻辑从“记住每个操作细节”中解放出来只需要“存状态恢复状态”就够了命令仍然承载了操作语义可以用于在其他地方做日志、宏录制或权限控制。我在实际项目中偏好的组合方式是这样Command 负责写日志和描述操作Memento 负责保存与恢复状态。光用 Command 时每个命令都要实现execute和undo如果某个操作的 undo 逻辑特别复杂比如涉及多对象联动就很容易出错光用 Memento 时虽然恢复状态很轻松但完全丢失了“这一步是在做什么”的语义。两者配合既保留了操作语义又把“回到过去”这个高风险动作简化为一次状态替换。如果你正在设计一个需要 Undo/Redo 的中大型系统建议先从“命令模式记录操作 备忘录模式保存状态”的组合入手不要一开始就尝试纯命令式撤销。纯命令式要求每个操作都能精确逆推真实项目里不少操作根本做不到完美逆推。状态快照虽然笨但它永远忠实于那一刻的现场。5. 高频踩坑与调试实录5.1 浅拷贝陷阱恢复之后两个对象共享同一块内存备忘录模式最常见的坑是快照里含有指针成员而save()只拷贝了指针本身没有拷贝指针指向的数据。这种情况下原始对象和快照会共享同一块堆内存。之后原始对象修改了这块数据快照里的“历史状态”也跟着变了如果两边各自释放内存还会造成 double free 或者悬垂指针。我在一次调试一个崩溃问题时遇到过这种场景程序里有一个Editor它的文本缓冲用char*管理备忘录保存时直接m_buffer src.m_buffer结果编辑一段内容后所有历史快照的文本内容全部变成了最新值撤销等于没撤最后在退出程序时还触发了重复释放。排查过程之所以痛苦是因为崩溃点不在赋值代码附近而在几十秒后的析构阶段。解决办法很简单凡是指针成员保存时都必须做深拷贝。标准容器如std::string、std::vector天然深拷贝可以直接存裸指针、char*、第三方库的句柄必须显式复制数据。如果你实在不想做深拷贝另一个方案是把指向的数据做成不可变的配合shared_ptr共用同一块内存恢复时只替换“快照的版本指针”。但这种方式要保证数据真的不再修改否则又会走回原来的坑。最推荐的还是从设计上消灭裸指针把发起人里的char*换成std::string把Node*换成std::shared_ptrNode让“拷贝对象就是拷贝状态”这个等式成立。你会在备忘录实现中省掉大量心力。5.2 保存了“半成品”状态快照时机比快照内容更重要备忘录存的内容对不对和你在什么时候存快照关系同样大。最常见的问题是在“修改已经开始但还没结束”的中间状态上打了快照。比如一个表单编辑器用户正在拖动滑块改变数值拖动过程中数值连续变化。如果每变化一次就存快照撤销时回到的就不是“操作前状态”而是“拖动到一半的状态”用户会非常困惑。正确的快照时机应该是“操作提交点”也就是一次完整操作结束的时刻。比如鼠标松开时、文本输入框失去焦点时、函数调用链全部完成时。这个时机选得好不好直接决定撤销是否可用但代码里完全没有显式提示只能靠开发者对业务边界有清晰认识。我习惯把“何时保存快照”提到和“保存什么”同等重要的位置。在设计阶段就先画出“哪些操作步骤算一个原子操作”快照只允许在原子操作的边界上打。比如画布工具里一次拖拽画线从 mousedown 到 mouseup 是一整个原子操作中间哪怕移动了一百次鼠标也只保存一次快照也就是 mousedown 之前的状态。如果在已经打了快照之后又发现状态不对还有一个补救手段在发起人里提供rollback()逻辑把当前状态恢复到最后一份快照。这个逻辑通常用于操作失败时的自动回滚。比如文件写入失败就把编辑器内容回滚到写入前的那一刻。这种“保存-操作-回滚”的流程在业务系统里比单纯的撤销栈更常见。5.3 备忘录的生命周期与内存管理备忘录的生命周期比普通对象更敏感。它被负责人长期持有意味着只要主管对象不清空历史栈备忘录里的数据就会一直占据内存同时它又被频繁地恢复给发起人如果生命周期管理不当很容易出现悬垂引用。这里有几条硬规矩。第一负责人内部用std::unique_ptr或std::shared_ptr持有备忘录不要裸指针到处传。如果你用裸指针发生拷贝或者异常时很容易漏掉释放。第二备忘录在保存时应该尽量以值语义拷贝状态不要保存指向发起人内部资源的引用或迭代器。第三恢复操作发生时发起人必须在备忘录的有效生命周期内调用 restore不要先把备忘录销毁再恢复——这听起来是废话但在异步回调里很容易犯。举个例子你有一个异步任务任务完成时恢复某个状态但历史栈可能在任务执行期间被另一段代码清了空。等到回调真正执行 restore 时你手上的备忘录已经析构了。这种问题很难复现我遇到过一次最后靠延迟绑定解决异步恢复前重新从负责人那里取最新的快照而不是自己缓存一份。如果你希望快照在回调期间保持合法最简单的做法就是让负责人返回shared_ptr回调持有它生命周期自然被延长。5.4 多线程场景下的状态一致性多线程下使用备忘录模式首要问题是快照的一致性。假设两个线程同时在改同一个发起人对象线程 A 在保存快照时线程 B 正在修改一半那么这份快照可能既不是修改前的状态也不是修改后的状态而是一个“缝合怪”。这类状态一旦被恢复程序会表现出一堆匪夷所思的行为坐标和血量对不上、文档内容和光标位置矛盾。处理方案大致有三种。第一种快照操作加锁。保存和恢复都用同一个互斥锁保护确保和修改操作互斥。优点是简单可靠缺点是频繁快照时锁竞争会影响性能。第二种在发起人上维护一个从不变化的历史镜像修改操作复制出一份临时副本在副本上应用修改完成后原子替换。这样任何人拿到的快照都是一个一致性完好的版本。这种思路在函数式语言里很常见C 里也可以用std::atomicstd::shared_ptrconst State实现。第三种避免共享写。如果多个线程都只读发起人、只有一个线程负责写入那么快照保存只需要在写线程里做其他线程完全不构成威胁。很多 UI 场景其实都天然符合这个模式只是代码里经常把写入和读取混在一起导致不得不上锁。我的建议是先用最直观的互斥锁跑通正确性再根据实际性能瓶颈做优化。多线程下状态一致性问题一旦出现往往非常隐蔽优化前先把正确性守住是第一优先级。6. 我实测后的几点体会备忘录模式代码量不大但用好的关键从来不在模式本身而在于两个朴素的问题“保存多少状态”和“用什么方式保存”。我自己的经验是快照应该只包含“恢复所需要的最小状态”而不是发起人的全部家当。很多成员天然不需要参与恢复临时缓存、派生数据、UI 状态、线程局部存储这些东西要么能重新计算要么恢复后自动刷新把它们塞进快照只有坏处没有好处——白白增加内存恢复时还要多复制一堆无效数据。所以每次写 save() 之前我会先问自己一句把对象恢复到这一时刻缺了哪几个字段就转不起来只保存这几个字段就够了。关于保存方式同进程内恢复用结构化快照最直接跨进程、跨版本、跨机器就得上序列化。但无论选哪种都不要为了省事把整个发起人对象一股脑扔给通用序列化框架。我见过一个项目把整个配置文件对象序列化成一坨 JSON 字符串塞进撤销栈结果每次撤销都要重新解析 JSON一卡就是几百毫秒体验极差。后来改成只存变化的字段内存占用和响应时间都好了很多。这就是典型的“模式没抄错但每一步都选了最费劲的路径”。最后补充一个细节备忘录模式不要单打独斗。如果你发现代码里需要“撤销一个操作”但那个操作本身已经写好了完整的逆逻辑那直接用命令模式可能更合适如果你发现撤销操作很难逆推比如涉及外部系统、文件 IO、随机数生成那就用备忘录模式存状态快照。两者不是互斥关系很多成熟项目两者都在用甚至会在同一条撤销链里混用。你越早想清楚“操作语义”和“状态快照”各自的边界后续代码就越不容易在设计上打架。如果你正在做编辑器、游戏存档、事务回滚、配置回滚这类功能希望这篇内容能帮你少踩几个坑。备忘录模式本身不难真正值钱的是“哪些状态值得存”的判断力而这种判断力只能靠一次次真实的回滚现场来打磨。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。