做了这么多年C++开发,备忘录模式是我在项目中用得最顺手、却也是最容易写砸的一个设计模式。说它顺手,是因为它的核心思想极其朴素——存档、读档,跟玩游戏一模一样;说它容易写砸,是因为C++的拷贝语义、封装边界、生命周期管理这些老生常谈的坑,在这个模式里会被成倍放大。今天这篇文章,我想用做过项目实战的视角,把C++里备忘录模式的来龙去脉、代码怎么组织、有哪些坑,一次性讲透。无论你是刚入门的C++新手,还是已经在写业务代码的老手,这篇文章都值得花十分钟读完——至少能帮你少踩几个我当年踩过的坑。
1. 备忘录模式:一句话说清楚它是什么
1.1 一个让人头秃的场景
先想象一个非常常见的需求:你写了一个文本编辑器,用户在里面噼里啪啦敲了一大段文字,然后又改来改去。突然,用户按下了Ctrl+Z,期望回到五分钟前的某个状态。这时候,你的程序需要"时光倒流"。
再想象一个游戏场景:Boss血量5%,你操控的角色只剩一丝血,眼看就要通关,结果一个走位失误被秒了。这时你需要从上一个存档点重新开始,而不是从头跑图。
这两个场景背后是同一个问题:对象的内部状态需要被保存,并且在某个时刻被完整恢复,但外部又不希望直接依赖对象内部的所有字段。
这就是备忘录模式(Memento Pattern)存在的意义。它是GoF二十三个经典设计模式之一,行为型模式家族的重要成员。核心定义很精简:
在不破坏封装的前提下,捕获一个对象的内部状态,并在该对象之外保存这个状态,以便之后可以将对象恢复到原先保存的状态。
注意这句话里的关键词:"不破坏封装"。这一点在C++里特别敏感,因为C++的private是出了名的铁面无私。很多人写备忘录模式,写到最后发现根本访问不了对象的私有成员,只能把代码改成全员public,这其实就是一种变相的"破坏封装",代码能跑,但设计已经烂了。
1.2 三个角色,各司其职
备忘录模式里一共三个角色,记住它们,代码结构就清晰了:
| 角色 | 英文名 | 职责 | 类比 |
|---|---|---|---|
| 发起人 | Originator | 需要被保存/恢复状态的对象,负责生产备忘录和恢复状态 | 游戏里的玩家角色 |
| 备忘录 | Memento | 保存发起人状态快照的对象,核心数据对外不可见 | 游戏存档文件 |
| 管理者 | Caretaker | 负责保存和保管备忘录,但不读、不改备忘录内容 | 存档管理界面/存档点 |
这三者协作起来其实是一条非常简单的流水线:Caretaker让Originator生成一个Memento,把Memento留好;等需要恢复时,把Memento交还给Originator,Originator从Memento里取数据恢复自身状态。
这里最容易让人迷惑的点是:**什么时候需要Caretaker?**很多新手写代码时直接把Memento扔数组里就完事了,不建立Caretaker这个角色。但实际项目中,Caretaker负责的是历史版本的管理逻辑——撤销栈的push/pop、存档文件的读写、版本数量的上限控制等等。如果这些逻辑散落在业务代码里,很快就会乱成一锅粥。记住一个原则:Caretaker专管"放"和"取",不看内容、不知细节。
2. 封装性的三难困境:C++实现的核心矛盾
2.1 方案一:嵌套类加友元,最正统的选择
先说说我最早实现的版本。C++里要保证Memento能访问Originator的私有成员,同时不让外部随意篡改快照数据,最直接的做法就是把Memento定义成Originator的嵌套类,并且声明友元关系。
代码长这样:
#include <iostream> #include <string> class Editor { public: // 让Caretaker能持有Memento的类型,但不暴露其内部结构 class Memento { private: friend class Editor; std::string text; int cursorX = 0, cursorY = 0; size_t version = 0; Memento(const std::string& t, int x, int y, size_t v) : text(t), cursorX(x), cursorY(y), version(v) {} public: ~Memento() = default; }; void setText(const std::string& t) { text = t; } void setCursor(int x, int y) { cursorX = x; cursorY = y; } void print() const { std::cout << "text: " << text << ", cursor: (" << cursorX << ", " << cursorY << ")\n"; } // 生成快照:这一行把内部状态全部打包 Memento createSnapshot(size_t v) const { return Memento(text, cursorX, cursorY, v); } // 恢复快照:把Memento里的数据一股脑倒回来 void restore(const Memento& m) { text = m.text; cursorX = m.cursorX; cursorY = m.cursorY; } private: std::string text; int cursorX = 0; int cursorY = 0; };这里有几个关键设计点:
Memento的构造函数是私有的,只有友元类
Editor能调用。外部拿到Memento对象后,根本没有办法直接构造一个假快照——这在撤销系统里非常重要,防止业务代码绕过Originator直接伪造存档。字段也全部私有,且只有Editor能访问。Caretaker拿着Memento一点辙都没有,它想改里面的数据,编译器直接拒绝。这就做到了"不破坏封装"。
Memento的析构函数是public的,这让Caretaker能自由地持有、拷贝、销毁Memento对象。
friend class Editor这行是关键中的关键。C++嵌套类默认是不能访问外部类的私有成员的,反过来外部类也不能访问嵌套类的私有成员,必须靠友元打通路径。
Caretaker这边就很简单了:
#include <vector> #include <memory> class History { public: // 压栈存档 void push(const Editor::Memento& m) { stack.push_back(m); } // 弹出最新存档(注意:这里不检查空栈,真实代码里要加) Editor::Memento pop() { Editor::Memento m = stack.back(); stack.pop_back(); return m; } bool empty() const { return stack.empty(); } size_t size() const { return stack.size(); } private: std::vector<Editor::Memento> stack; }; int main() { Editor editor; History history; editor.setText("你好,世界"); editor.setCursor(2, 0); history.push(editor.createSnapshot(1)); // 第一次存档 editor.setText("你好,C++备忘录模式!"); editor.setCursor(6, 0); history.push(editor.createSnapshot(2)); // 第二次存档 // 撤销一次 editor.restore(history.pop()); editor.print(); // 输出 text: 你好,世界, cursor: (2, 0) return 0; }History就是标准的Caretaker,它只关心push和pop,完全不知道快照里装的是什么。这个方案的优点是信息隐藏做到位,所有不该被外部知道的数据都被锁死了。
2.2 方案二:公开POD类型,简单换来妥协
第二种实现粗暴直接:把Memento定义成一个所有成员公开的结构体,Originator直接往里面塞数据。
struct EditorMemento { std::string text; int cursorX = 0; int cursorY = 0; size_t version = 0; }; class Editor { // ... public: EditorMemento createSnapshot(size_t v) const { return EditorMemento{text, cursorX, cursorY, v}; } void restore(const EditorMemento& m, size_t v) { text = m.text; cursorX = m.cursorX; cursorY = m.cursorY; } };老实的说,如果项目里就一个人写代码、快照结构又极简,这个做法能省不少事——编译快、代码短、没有花里胡哨的访问控制。但一旦团队协作或多模块调用,这个方案就会埋雷。
最大的问题是:外部代码能随意篡改快照内容,甚至能凭空捏造一个快照塞给restore()。比如别的模块为了省事,直接改memento.text = "hacked",那这个撤销系统就成了公开的合规性漏洞。另外,Memento的字段变化会直接暴露给所有依赖方——今天加一个字段,所有引用EditorMemento的地方全要重新编译。
个人经验是:小工具、一次性脚本、内存受限的嵌入式场景,方案二完全够用;但凡是面向长期维护的项目,方案一的多花的那点代码量绝对值得。毕竟C++这门语言,最值钱的就是"把不可变性和封装性用编译器锁死"。
2.3 方案对比:为什么"正统"胜出
| 维度 | 嵌套类 + 友元 | 公开POD结构体 |
|---|---|---|
| 封装性 | 外部完全不可见、不可改 | 完全公开,可任意修改 |
| 构造权限 | 仅Originator能构造 | 任何人都能构造 |
| 编译隔离 | 改Memento内部字段,外部调用方需重编 | 改字段,所有依赖方重编 |
| 代码复杂度 | 稍高(友元、嵌套类) | 极低 |
| 适用场景 | 中大型项目、多人协作、长期维护 | 简单脚本、demo、快速原型 |
说白了,备忘录模式在C++里玩的就是"访问控制"的游戏。如果你愿意付出一点点代码量,把Memento做成一个"只出不进"的黑盒,整个设计会稳固得多。
3. 完整实战:从零实现文本编辑器的撤销重做
3.1 需求拆解:不只是撤销,还有重做
光讲概念太空泛了,我直接带大家实现一个带撤销(Undo)和重做(Redo)功能的文本编辑器核心逻辑。除了上面说的三个基本角色,还需要引入两个栈:
- 撤销栈(undo stack):保存历史快照,压栈顺序从旧到新。
- 重做栈(redo stack):撤销时弹出的快照先放这里,重做时再从重做栈取回。
核心操作逻辑可以这样理解:
- 用户每次修改文本,都生成一个新快照压进撤销栈,同时清空重做栈(因为新的修改意味着之前的分支失效了)。
- 撤销时:撤销栈弹出一个快照恢复,把这个快照压进重做栈。
- 重做时:重做栈弹出一个快照恢复,把它压回撤销栈。
3.2 完整代码结构
#include <iostream> #include <string> #include <vector> #include <stdexcept> class Document { public: class Memento { private: friend class Document; std::string content; size_t cursorPos = 0; size_t version = 0; Memento(const std::string& c, size_t pos, size_t ver) : content(c), cursorPos(pos), version(ver) {} public: ~Memento() = default; size_t getVersion() const { return version; } }; void insert(const std::string& text, size_t pos) { if (pos > content.size()) pos = content.size(); content.insert(pos, text); cursorPos = pos + text.size(); ++version; } void erase(size_t pos, size_t len) { if (pos >= content.size()) return; content.erase(pos, len); cursorPos = pos; ++version; } Memento createSnapshot() const { return Memento(content, cursorPos, version); } void restore(const Memento& m) { content = m.content; cursorPos = m.cursorPos; version = m.version; } void print() const { std::cout << "内容: " << content << " | 光标: " << cursorPos << " | 版本: " << version << "\n"; } private: std::string content; size_t cursorPos = 0; size_t version = 0; }; class History { public: void save(const Document& doc) { undoStack.push_back(doc.createSnapshot()); // 新操作之后,重做历史作废 redoStack.clear(); // 控制栈大小,避免无限膨胀 const size_t MAX_UNDO = 50; if (undoStack.size() > MAX_UNDO) { undoStack.erase(undoStack.begin()); } } void undo(Document& doc) { if (undoStack.empty()) { throw std::runtime_error("没有可撤销的操作"); } redoStack.push_back(undoStack.back()); undoStack.pop_back(); if (!undoStack.empty()) { doc.restore(undoStack.back()); } else { doc.restore(initialState); } } void redo(Document& doc) { if (redoStack.empty()) { throw std::runtime_error("没有可重做的操作"); } undoStack.push_back(redoStack.back()); doc.restore(redoStack.back()); redoStack.pop_back(); } void setInitialState(const Document::Memento& m) { initialState = m; } private: std::vector<Document::Memento> undoStack; std::vector<Document::Memento> redoStack; Document::Memento initialState; // 注意:这里需要Document::Memento有默认构造 }; int main() { Document doc; History history; // 记录初始状态 auto initial = doc.createSnapshot(); // 为了让initialState可被默认构造,这里做个小处理 // 注意:这样直接编译会有点小问题,见下文分析 return 0; }等等,上面代码里History包含Document::Memento initialState;这个成员,但Document::Memento只有一个带参构造函数,没有默认构造函数,这段代码编译会失败。实际上更合理的做法是给Memento加一个默认构造,或者用std::optional<Memento>来存放初始状态。下节课我详细讲这个坑。
先校正一下,把History改成这样:
class History { public: void save(const Document& doc) { /* 同上 */ } void undo(Document& doc) { if (undoStack.empty()) { throw std::runtime_error("没有可撤销的操作"); } redoStack.push_back(undoStack.back()); undoStack.pop_back(); if (!undoStack.empty()) { doc.restore(undoStack.back()); } else { // 撤销到初始状态 if (hasInitial) doc.restore(initialState); } } void setInitialState(const Document& doc) { initialState = doc.createSnapshot(); hasInitial = true; } private: std::vector<Document::Memento> undoStack; std::vector<Document::Memento> redoStack; Document::Memento initialState{Document("", 0, 0)}; // 需要默认构造支持才行 bool hasInitial = false; };为了让这个设计能跑通,给Document::Memento增加一个默认构造函数:
class Memento { private: friend class Document; std::string content; size_t cursorPos = 0; size_t version = 0; Memento() = default; // 默认构造,便于History持有 Memento(const std::string& c, size_t pos, size_t ver) : content(c), cursorPos(pos), version(ver) {} public: ~Memento() = default; size_t getVersion() const { return version; } };这样Caretaker就能保存"初始状态"——一个文档刚创建时的快照。在撤销栈为空时,直接恢复到初始状态,算是把"史上最空状态"也纳入历史管理了。
运行效果大致是:
int main() { Document doc; History history; history.setInitialState(doc); // 保存初始空文档状态 doc.insert("Hello", 0); history.save(doc); doc.insert(", World", 5); history.save(doc); doc.print(); // 内容: Hello, World | 光标: 12 | 版本: 2 history.undo(doc); doc.print(); // 内容: Hello | 光标: 5 | 版本: 1 history.undo(doc); doc.print(); // 内容: (空) | 光标: 0 | 版本: 0 history.redo(doc); doc.print(); // 内容: Hello | 光标: 5 | 版本: 1 return 0; }这套Undo/Redo核心逻辑大约80行代码,结构却非常清楚——Document只管业务数据和快照的生成恢复,History专心管理两个栈和版本历史,互不越界。
3.3 版本号的妙用
我在Memento里专门保存了一个version字段,这在调试和单元测试里超级有用。比如你可以断言:
Document doc; History history; doc.insert("abc", 0); auto s1 = doc.createSnapshot(); doc.insert("defg", 3); // 此时doc的版本比s1大 if (s1.getVersion() < /* doc当前版本 */) { // 说明快照是过去的版本,可以用于"是否过期"判断 }另外,版本号还能用来做"防止脏写"——比如恢复快照时,对比一下当前版本和快照版本,如果当前版本比快照还旧,说明可能是乱序恢复,直接抛异常。这在多线程访问同一文档时特别有用。
4. 备忘录模式的六大坑:每个都是我踩过或见过的真实教训
4.1 坑一:浅拷贝陷阱
C++对象里有指针、有容器时,默认的拷贝构造和赋值运算符往往做的是浅拷贝。比如某次我给一个图像编辑器的Document类加了一个ImageBuffer*指针,然后非常自信地用Document::Memento直接保存整个Document的状态。结果用户撤销一次,图像直接花屏——因为旧快照的指针和当前对象的指针指向的是同一个缓冲区,恢复时只是改了指针,缓冲区的内容早被覆盖了。
正确做法:在Memento的构造里,必须做深拷贝;或者干脆规定Memento保存的是可以序列化的值类型,比如std::string、std::vector<char>、std::map这些自带值语义的类型。实在需要保存指针,建议用std::shared_ptr托管内存,但要注意深浅拷贝语义——拷贝shared_ptr只是引用计数+1,并不会复制底层数据。
4.2 坑二:快照太肥,内存爆炸
这是备忘录模式最经典的性能问题。如果文档正文有几十MB,每次用户敲一个字符就生成一份完整快照,撤销栈很快就把内存吃光了。
业界常用的优化手段有几种:
- 增量快照/命令模式混合:不用保存全量状态,而是保存"做了哪些操作"(比如插入字符的位置和内容),撤销时逆序执行。这个思路本质上更接近Command模式,不少生产级编辑器(如VS Code底层那套撤销系统)用的就是命令+状态混合的模型。
- 压缩快照:对
std::string这类数据做差分或压缩后再存,但编码复杂度直线上升。 - 限定栈深度:像我在上面的代码里做的,限制撤销栈最大50条,超了就把最老的记录丢掉。这是激进但实用的做法,很多真实产品就这么干。
我个人推荐的做法是:先用"全量快照 + 限制栈深度"上线,等内存分析确认快照是瓶颈后,再引入增量或Command模式。过早优化是万恶之源,但完全不优化也不是工程的态度。
4.3 坑三:Memento不能访问Originator的私有成员
这是新手最常撞的编译错误。如果你把Memento定义成独立类,然后想直接读取Originator的私有字段,编译器会毫不留情地报错。
我见过最土的解决方法是:把Originator所有字段改成public。一旦这么干,就相当于把这整个对象的所有内部细节捅给全世界了,任何代码都能随手改它的text、cursor、version,封闭性彻底破产。
正确解法还是那句话:要么用嵌套类加友元,让Originator控制Memento的构造和恢复;要么Memento用公开POD,但定义成Originator能一手包办的结构,且明确约定"谁都不准直接改Memento字段"。
4.4 坑四:Caretaker没有保存初始状态
很多实现里,撤销栈为空时,用户一按Ctrl+Z程序直接崩溃或什么都不做。但业务上,用户期望的是"撤销到初始空状态"。
上面的代码里我特意加了setInitialState方法,保存一个最初状态的快照。这样撤销目录序列为:初始状态 → 操作A → 操作B → 操作C,用户连续撤销三次,最终回到创建时刻。这是用户最容易感知到的细节,但文档里很少告诉你。
4.5 坑五:Caretaker里过度依赖Memento的公开接口
我见过的反面案例是,Caretaker为了做撤销按钮的置灰判断,去读Memento里的version或者某个状态字段,来判断栈是否为空。这会让Caretaker对Memento的内部结构产生依赖,也丧失了"管理者不关心内容"的纯粹性。
正确的做法:Caretaker只应该问"空不空、多大、能存吗",不该问"里面装了什么"。判断撤销按钮是否可用,直接查undoStack.empty()就完了,不要去翻Memento内部字段。
4.6 坑六:友元滥用
很多为了省事,直接写了friend class Everything或者把友元声明打在好几个类上。这违背了友元的初衷——友元应该是"最小范围的信任"。我一般只在Originator和它自己的Memento之间声明友元,第三方类一律不给。
5. 备忘录模式的适用边界与扩展:什么时候别用它
备忘录模式不是万能药。有几种情况,我建议你换个方案:
1. 状态太庞大或太动态。比如一个大场景的物理引擎,每帧都要保存几十万个粒子的位置,全量快照就是灾难。这时候应该让粒子系统自己管理增量状态,或者用Command模式记录操作序列。
2. 状态恢复触发大量级联副作用。备忘录模式恢复的是"数值状态",但如果状态恢复还伴随着重新连接数据库、重新初始化网络连接、触发回调等副作用,那么简单复制字段是远不够的。这种场景需要更强大的"状态重建"机制,而不能只是赋值。
3. 你真正需要的是撤销"操作",而不是撤销"状态"。想象一个场景:用户拖拽一个UI控件,你希望撤销的是"拖动这个动作"而不仅仅是"把控件位置改回去"——移动的同时可能还涉及数据校验、UI通知等操作。这种需求更适合Command模式,或者Command与Memento混合使用:Memento保存状态基线,Command负责重放或者反转操作。
下面我用一个简单表格对比一下这两种模式的选择思路:
| 需求侧重 | 推荐模式 | 理由 |
|---|---|---|
| 保存/恢复完整状态(存档、检查点) | 备忘录模式 | 快照即全貌,恢复简单 |
| 撤销/重做复杂操作序列 | Command模式 | 记录操作指令,便于组合、批量撤销 |
| 撤销状态+执行副作用 | Command + Memento混合 | Command触发动作,Memento保存基线 |
备忘录模式最好的搭档其实是Command模式。我现在负责的一个重要项目里,编辑器用Command封装每个用户的修改变更(插入字符、删除范围、改变字体),同时用Memento保存文档在重大版本号处的快照(作为"里程碑")。用户做普通撤销走Command逆序执行;遇到大版本回滚或崩溃恢复时,直接恢复Memento快照。两者结合得极好,既避免了快照频繁导致的性能开销,又保证了长距离回滚的可行性。
6. 现代C++的备忘录优化:移动语义和三方库的好处
6.1 移动语义带来的效率提升
如果Memento内部的std::string、std::vector很大,保持值传递并通过返回值返回快照时,C++11之后的移动语义已经能把大部分拷贝开销消灭掉。
比如:
Memento createSnapshot() const { // 这里是一次拷贝构造, 但返回值优化(RVO)通常能消除多余拷贝 return Memento(content, cursorPos, version); }如果Memento保存的是std::string,返回时编译器优先走RVO,直接原地构造。就算需要移动,std::string的移动构造也只是指针交换那点开销。真正要小心的是那些"不支持移动/拷贝开销极大"的自定义类型,比如持有std::mutex的对象——mutex锁对象既不能拷贝也不能移动。如果Memento得携带这种成员,只能用std::shared_ptr间接存储。
6.2shared_ptr<const Memento>的妙用
Caretaker存储快照时,可以考虑用std::shared_ptr<const Memento>,好处有两个:
- 多个Caretaker能共享同一份历史快照,不需要复制。
- 类型是
const的,从类型层面就限制了外部篡改Memento成员的可能性——即使Memento里的字段是public,const引用也让你只能读不能写。
class History { public: using MementoPtr = std::shared_ptr<const Document::Memento>; void push(MementoPtr m) { stack.push_back(std::move(m)); } MementoPtr pop() { MementoPtr m = stack.back(); stack.pop_back(); return m; } private: std::vector<MementoPtr> stack; };然后在Document里生成快照时返回shared_ptr<const Memento>:
auto createSnapshot() const -> std::shared_ptr<const Memento> { return std::make_shared<const Memento>(content, cursorPos, version); }这样Caretaker存储和转发快照非常安全,还免去了大对象的重复拷贝。
6.3 序列化与跨进程存档
有时候快照需要写盘或者跨机器传递,比如把游戏存档写进一个文件。这时Memento可以增加serialize和deserialize方法:
class Memento { public: std::string serialize() const { std::ostringstream oss; oss << version << ' ' << cursorPos << '\n'; // 这里可以用二进制方式,为了可读性用文本 oss << content; return oss.str(); } static Memento deserialize(const std::string& data) { std::istringstream iss(data); size_t ver, pos; std::string content; iss >> ver >> pos; std::getline(iss, content); if (!content.empty() && content.front() == '\n') content.erase(0, 1); return Memento(content, pos, ver); } };设计备忘录模式时,不要把"恢复"局限在内存里。存档读档、崩溃恢复、跨版本兼容——这些工程需求都会用到序列化。早一点给Memento加上serialize/deserialize接口,后面会省很多事。
7. 备忘录模式与C++惯用法的更多结合
现在C++社区里越来越流行一种 "泛型备忘录" 的思路,用模板实现一个通用的Snapshotable<T>接口。思路大致如下:
template<typename T> requires requires(const T& t) { t.serialize(); } // C++20 concept约束 class GenericSnapshot { public: template<typename Originator> static auto create(const Originator& orig) { return typename Originator::Memento(orig); } };这种模板化的思路可以写,但说实话,我见过的大多数代码库里,泛型备忘录的收益并不大。原因很简单:每个Originator的Memento内部字段都各不相同,你很难写一个通用的恢复算法,到头来还是得为每个类型手写createSnapshot和restore。所以这种方案的适用范围比较窄,但在某些需要统一接口的框架里(比如一个统一的"可持久化实体"框架),用concept约束所有类型都必须支持serialize/restore接口,也是种不错的抽象。
如果要用模板方案,一个可行的简化做法是:定义一个统一的抽象接口ISnapshot,内部用std::any或std::variant保存不同Originator的快照对象,然后配合std::type_index做类型安全的类型分发。这样做泛用性和类型安全能保住,但运行期类型擦除又带来额外的复杂性。具体取舍看项目规模,小项目别用这么重的抽象。
8. 实战经验总结:写C++备忘录模式的一些个人心得
结合这么多年做C++项目的体会,我想强调几个看似简单但格外重要的经验。
第一,**先把"恢复状态之后的副作用"考虑清楚再写代码。**很多初学者只关注把text、cursor这些字段赋值回去,却忽略了状态恢复后UI刷新、缓存失效、历史栈同步等实际问题。Memento模式只保证状态的一致性,不保证业务的一致性。一旦状态恢复触发了什么副作用,比如网络请求、事件通知,必须由调用方自行处理。
第二,**别把Memento写成God Object。**Memento里应该有"恢复所需的状态",但不要塞进一堆临时计算字段、缓存数据、派生数据。多余字段不仅增加拷贝开销,也让恢复逻辑变复杂。每次往Memento里加字段,都要先问自己:这个字段在恢复时真的需要吗?
第三,**利用好编译器,把设计了断死在代码里。**C++的好处是,很多东西可以在编译期就约束住。该私有就私有,该const就const,该删除拷贝就删除拷贝。前面说的嵌套类+友元方案,就是把"外部不能篡改快照"这件事用编译器锁死了,谁想违反都得编译错误。
第四,**做一个能跑通的完整例程再往项目里搬。**我写过无数个"原始版"备忘录,最后发现正真合身的是那种能在自己具体业务环境下压测过的版本。比如文档编辑器场景,Document内容很大,单一快照方案必然卡顿;游戏场景,状态虽多但每个状态都很小,Memo模式就非常合适。没有通用的最优解,只有最合适当前业务的解。
第五,**别忘了单元测试。**备忘录模式恢复逻辑是容易出错的地方,一定要为createSnapshot和restore的组合写单元测试。重点测:修改-恢复-修改的幂等性、多步撤销/重做的序列正确性、深拷贝之后修改旧快照不影响新状态。
最后再分享一个小技巧:当发现Memento需要被频繁复制但内容又不变大时,可以考虑用std::shared_ptr<const Memento>保存真正数据,而Memento本身只是一个很薄的包装类。这样复制Memento对象的成本几乎为零,也能在历史栈很长的情况下有效降低内存碎片和拷贝开销。这是我在一个编辑器项目里踩过内存瓶颈后总结出来的,实测下来效果很明显。