C++备忘录模式实战:封装对象状态实现撤销与恢复

📅 2026/7/15 13:26:57
C++备忘录模式实战:封装对象状态实现撤销与恢复
1. 项目概述为什么我们需要备忘录模式在C项目开发中尤其是涉及到复杂对象状态管理的场景比如图形编辑器、游戏存档、事务回滚或者配置管理我们经常会遇到一个核心需求如何在不破坏对象封装性的前提下保存其某一时刻的完整状态并在未来某个时间点精准地恢复到那个状态新手程序员可能会直接想到将对象的所有成员变量公开或者提供一个SaveState和LoadState函数将数据序列化到文件或内存中。但这样做无异于将对象的“内脏”完全暴露在外任何外部代码都可以随意修改其内部数据封装性荡然无存代码的健壮性和可维护性会急剧下降。备忘录模式Memento Pattern正是为解决这一矛盾而生的优雅设计。它像一个时光胶囊允许对象我们称之为“原发器”自行创建其状态的快照并将这个快照封装在一个特殊的“备忘录”对象中。这个备忘录对象的内容对外界除了创建它的原发器是黑盒只有原发器自己知道如何解读和恢复。而负责保存和管理这些历史快照的“负责人”类则只负责保管“时光胶囊”无权窥探或修改其中的内容。这种职责分离的设计完美地平衡了状态保存的需求与面向对象封装的核心原则。在C中实现备忘录模式我们尤其需要关注内存管理、深拷贝与浅拷贝、以及如何利用友元或嵌套类等语言特性来实现严格的访问控制。接下来我将结合一个具体的文本编辑器示例手把手带你从设计思路到代码实现深入理解如何在C中优雅地应用备忘录模式。2. 核心角色与设计思路拆解备忘录模式通常涉及三个核心角色理解它们之间的关系是正确实现该模式的关键。2.1 原发器 (Originator)原发器是拥有需要保存状态的那个对象。在我们的文本编辑器例子中Editor类就是原发器。它内部可能包含文本内容、光标位置、选中区域等私有数据。原发器需要提供两个关键方法CreateMemento(): 用于创建当前状态的备忘录。这个方法会读取自身的所有关键状态并利用这些数据构造并返回一个Memento对象。RestoreFromMemento(const Memento memento): 用于从传入的备忘录对象中恢复状态。这个方法能解读备忘录内部的数据并将其赋值给自身的成员变量。注意原发器是唯一一个被允许“打开”备忘录并读取其内部数据的角色。这通常通过将Memento类声明为Editor的友元或者将Memento作为Editor的嵌套私有类来实现。2.2 备忘录 (Memento)备忘录对象是状态快照的载体。它应该设计为不可变对象即一旦创建其内部保存的状态数据就不能再被修改。这保证了历史状态的“纯洁性”。备忘录通常只提供构造函数用于原发器初始化状态和一些元数据查询接口如获取快照创建时间但绝不提供修改其内部状态的公共接口。备忘录存储什么数据完全由原发器决定。它可能存储原发器所有数据的副本也可能只存储自上次快照以来的差异增量快照后者在状态很大时能节省内存但实现更复杂。2.3 负责人 (Caretaker)负责人负责管理备忘录的历史记录。它不知道备忘录里具体存了什么也不关心如何恢复状态。它只做三件事在适当的时机如用户执行某个操作前请求原发器创建一个备忘录。将这个备忘录保存起来通常是用栈、列表或堆栈等数据结构。在需要撤销时从历史记录中取出最新的备忘录并将其交还给原发器进行恢复。负责人和备忘录之间的交互应通过一个受限的接口如果备忘录不是嵌套类或者负责人根本不能直接操作备忘录的数据成员。设计思路的核心在于将状态保存与恢复的“能力”赋予原发器将状态历史的“管理权”赋予负责人而将状态数据的“保管权”赋予一个对外界只读的备忘录。这样每个类的职责单一且清晰符合高内聚、低耦合的设计原则。3. C实现详解从类定义到内存管理理论讲完了我们来看代码。C的实现需要特别注意资源管理和访问控制。这里我们采用经典的、利用friend和嵌套类的方式这也是C社区最常用的实现方式之一。3.1 定义备忘录 (Memento) 类首先我们定义Memento类。为了严格封装我们将其定义为Editor类的私有嵌套类。这样只有Editor能访问Memento的私有成员。// Editor.h #include string #include memory class EditorMemento; // 前向声明 class Editor { public: Editor(); // ... 其他公共接口 // 关键方法创建和恢复备忘录 std::unique_ptrEditorMemento CreateMemento() const; void RestoreFromMemento(const EditorMemento memento); private: // 原发器的私有状态 std::string text_; int cursorX_; int cursorY_; int selectionWidth_; // 声明备忘录为友元允许备忘录访问Editor的私有状态如果需要并非必须 // friend class EditorMemento; // 本例中通过构造函数传递数据不需要友元 // 备忘录作为私有嵌套类 class EditorMemento { public: // 备忘录的构造函数应该是公开的但只由Editor调用。 // 它接收Editor状态的副本。 EditorMemento(const std::string text, int curX, int curY, int selWidth); // 备忘录通常不提供修改其内部状态的公共接口。 // 但可以提供只读的元数据访问或者不提供任何数据访问。 // 恢复操作由Editor的RestoreFromMemento完成它需要访问备忘录的数据。 // 因此我们将Editor设为友元或者将数据访问权限保持在同一个编译单元内。 private: // 备忘录保存的状态数据通常是原发器状态的副本。 std::string savedText_; int savedCursorX_; int savedCursorY_; int savedSelectionWidth_; // 声明原发器Editor为友元使得Editor::RestoreFromMemento可以读取私有数据。 friend class Editor; }; };关键点解析EditorMemento是Editor的私有嵌套类。这意味着在Editor类的外部无法直接声明或操作EditorMemento对象这提供了第一层封装保护。EditorMemento的构造函数是公开的但因为它是一个私有嵌套类所以实际上只能在Editor类的成员函数内部被调用。这确保了只有Editor能创建备忘录。我们将Editor声明为EditorMemento的friend。这是关键一步它允许Editor::RestoreFromMemento函数访问EditorMemento的所有私有成员savedText_,savedCursorX_等从而读取快照数据。而其他类如负责人即使拿到了EditorMemento对象也无法读取其内部数据因为它们不是友元。我们使用std::unique_ptrEditorMemento作为创建备忘录的返回类型。这明确了备忘录的所有权转移备忘录由Editor创建然后所有权转移给调用者负责人。unique_ptr确保了内存的自动管理避免了手动delete。3.2 实现原发器 (Editor) 的关键方法接下来我们在Editor.cpp中实现关键方法。// Editor.cpp #include Editor.h #include iostream Editor::Editor() : text_(), cursorX_(0), cursorY_(0), selectionWidth_(0) {} // EditorMemento 构造函数的实现 Editor::EditorMemento::EditorMemento(const std::string text, int curX, int curY, int selWidth) : savedText_(text), savedCursorX_(curX), savedCursorY_(curY), savedSelectionWidth_(selWidth) {} // 创建备忘录捕获当前状态 std::unique_ptrEditor::EditorMemento Editor::CreateMemento() const { // 使用std::make_unique创建备忘录对象并传入当前状态。 // 注意这里发生了状态的拷贝。对于大型对象需要考虑性能。 return std::make_uniqueEditorMemento(text_, cursorX_, cursorY_, selectionWidth_); } // 从备忘录恢复状态 void Editor::RestoreFromMemento(const Editor::EditorMemento memento) { // 由于Editor是EditorMemento的友元可以直接访问其私有成员。 text_ memento.savedText_; cursorX_ memento.savedCursorX_; cursorY_ memento.savedCursorY_; selectionWidth_ memento.savedSelectionWidth_; std::cout 状态已恢复。文本长度 text_.length() , 光标位置: ( cursorX_ , cursorY_ )\n; } // 其他Editor方法例如修改状态 void Editor::SetText(const std::string text) { text_ text; } void Editor::SetCursor(int x, int y) { cursorX_ x; cursorY_ y; } void Editor::SetSelectionWidth(int width) { selectionWidth_ width; } void Editor::PrintState() const { std::cout 当前状态 - 文本: \ text_ \, 光标: ( cursorX_ , cursorY_ ), 选择宽度: selectionWidth_ std::endl; }实操心得深拷贝问题在CreateMemento中我们拷贝了std::string text_。std::string管理自己的堆内存所以这是深拷贝是安全的。如果你的原发器状态包含原始指针如int* data_则必须在EditorMemento的构造函数中进行深拷贝例如new int[*data_]并在其析构函数中正确释放内存或者直接使用智能指针和STL容器来避免手动管理。性能考量频繁创建包含大量数据的备忘录如整个文档的文本会消耗大量内存和CPU时间。在实际应用中可以考虑使用增量快照、压缩技术或仅在关键操作点创建快照。3.3 实现负责人 (Caretaker) 类负责人类相对简单它持有一个备忘录的集合通常用栈来模拟撤销历史。// History.h #include stack #include memory #include Editor.h class History { public: History() default; // 保存状态 void Push(std::unique_ptrEditor::EditorMemento memento) { // 将备忘录的所有权转移到历史栈中 history_.push(std::move(memento)); } // 撤销取出最近的状态如果栈不为空 std::unique_ptrEditor::EditorMemento Pop() { if (history_.empty()) { return nullptr; // 或者抛出一个异常 } auto top std::move(history_.top()); history_.pop(); return top; // 将所有权转移给调用者通常是Editor用于恢复 } bool IsEmpty() const { return history_.empty(); } size_t Size() const { return history_.size(); } private: // 使用栈来保存历史记录后进先出符合撤销操作的习惯。 std::stackstd::unique_ptrEditor::EditorMemento history_; };注意事项我们使用std::unique_ptr来管理备忘录的生命周期。Push方法通过std::move取得传入备忘录的所有权Pop方法则转移栈顶备忘录的所有权给调用者。这种明确的所有权语义避免了内存泄漏和悬空指针。负责人History类不包含任何Editor的头文件除了前向声明可能需要的它只操作Editor::EditorMemento的指针。它完全不知道EditorMemento里面存了什么也不知道如何恢复状态它只是一个保管员。4. 完整工作流程与代码演示让我们把所有的部分组装起来看看它们是如何协同工作的。// main.cpp #include Editor.h #include History.h #include iostream int main() { Editor editor; History history; // 初始状态 editor.SetText(Hello, World!); editor.SetCursor(5, 0); editor.PrintState(); // 输出当前状态 - 文本: Hello, World!, 光标: (5, 0), 选择宽度: 0 // 执行操作前保存状态 (快照1) history.Push(editor.CreateMemento()); // 执行一些操作修改状态 editor.SetText(Hello, Design Patterns!); editor.SetCursor(7, 0); editor.SetSelectionWidth(10); editor.PrintState(); // 输出当前状态 - 文本: Hello, Design Patterns!, 光标: (7, 0), 选择宽度: 10 // 再次保存状态 (快照2) history.Push(editor.CreateMemento()); // 继续修改状态 editor.SetText(This text will be undone.); editor.PrintState(); // 输出当前状态 - 文本: This text will be undone., 光标: (7, 0), 选择宽度: 10 // 用户触发撤销恢复到快照2 std::cout \n--- 执行撤销 ---\n; auto mementoToRestore history.Pop(); if (mementoToRestore) { editor.RestoreFromMemento(*mementoToRestore); } editor.PrintState(); // 输出状态已恢复。文本长度23, 光标位置: (7, 0) // 输出当前状态 - 文本: Hello, Design Patterns!, 光标: (7, 0), 选择宽度: 10 // 再次触发撤销恢复到快照1 std::cout \n--- 再次撤销 ---\n; mementoToRestore history.Pop(); if (mementoToRestore) { editor.RestoreFromMemento(*mementoToRestore); } editor.PrintState(); // 输出状态已恢复。文本长度13, 光标位置: (5, 0) // 输出当前状态 - 文本: Hello, World!, 光标: (5, 0), 选择宽度: 0 // 尝试第三次撤销历史已空 std::cout \n--- 尝试第三次撤销 ---\n; mementoToRestore history.Pop(); if (!mementoToRestore) { std::cout 没有更多历史记录可以撤销。\n; } return 0; }这个流程清晰地展示了备忘录模式的工作方式状态修改前保存在编辑器状态可能改变的关键操作点调用editor.CreateMemento()生成快照并由History保存。状态恢复当需要撤销时History交出最新的快照编辑器调用RestoreFromMemento用快照数据覆盖当前状态。职责分离Editor负责状态的生成与解读History负责状态的保管与序列管理Memento则是两者之间安全的数据传输载体。5. 进阶话题与性能优化策略基础的实现已经能工作但在实际项目中我们还需要考虑更多。5.1 结合命令模式实现原子操作撤销/重做单纯的备忘录模式提供了状态恢复的能力但通常我们想要撤销的是某个“操作”或“命令”。这时结合命令模式是更佳实践。// 一个简单的命令接口 class Command { public: virtual ~Command() default; virtual void Execute() 0; virtual void Undo() 0; }; // 具体的文本修改命令 class ChangeTextCommand : public Command { public: ChangeTextCommand(Editor editor, const std::string newText) : editor_(editor), newText_(newText) { // 在执行命令前先保存当前状态到备忘录 backup_ editor_.CreateMemento(); } void Execute() override { // 实际执行文本修改 editor_.SetText(newText_); } void Undo() override { // 撤销用备份的备忘录恢复状态 if (backup_) { editor_.RestoreFromMemento(*backup_); } } private: Editor editor_; std::string newText_; std::unique_ptrEditor::EditorMemento backup_; // 命令持有备忘录 };在这种模式下每个Command对象在创建时或执行前保存一个状态快照备忘录。History类保存的是Command对象的序列。执行撤销时调用栈顶命令的Undo()方法该方法利用自己保存的备忘录来恢复状态。这实现了操作级别的撤销并且命令对象可以封装更复杂的逻辑。5.2 内存优化增量快照与压缩如果原发器状态非常庞大例如一个包含大量图元的图形文档每次全量保存快照会消耗巨大内存。解决方案有增量快照备忘录只保存自上次快照以来发生变化的部分。这需要原发器能识别出状态的差异。压缩存储对保存的状态数据进行压缩例如对于文本如果连续空白很多可以使用游程编码。懒快照/检查点并非每次操作都保存快照而是在特定“检查点”保存全量快照中间的操作通过重放命令日志来实现回滚。这类似于数据库的事务日志。5.3 序列化支持与持久化有时我们需要将状态保存到磁盘或通过网络传输。备忘录模式可以很好地与序列化结合。让EditorMemento类支持序列化例如实现toJson()、fromJson()方法或重载和操作符。History类可以将备忘录序列化后存入文件或数据库。恢复时从存储中反序列化出备忘录对象再交给原发器恢复。注意事项序列化时要注意深拷贝和指针问题。最好在备忘录中只存储值类型数据或可序列化的对象。6. 常见陷阱、问题排查与最佳实践在实际使用备忘录模式时我踩过不少坑这里总结一下。6.1 深拷贝与浅拷贝的坑这是C实现中最常见的错误。如果原发器的状态包含指针或引用在创建备忘录时必须进行深拷贝。// 错误示例浅拷贝 class BadEditor { char* largeText_; // 动态分配的字符数组 public: class BadMemento { char* savedText_; // 只拷贝了指针 }; }; // 正确做法在Memento的构造函数和析构函数中管理深拷贝 class GoodEditor { std::unique_ptrchar[] largeText_; // 使用智能指针是更好的选择 public: class GoodMemento { std::unique_ptrchar[] savedText_; // 智能指针自动管理深拷贝和释放 GoodMemento(const std::unique_ptrchar[] src) : savedText_(std::make_uniquechar[](strlen(src.get()) 1)) { strcpy(savedText_.get(), src.get()); } }; };最佳实践尽量使用STL容器std::string,std::vector和智能指针std::unique_ptr,std::shared_ptr来管理资源它们默认就支持深拷贝或安全的资源管理能极大降低出错概率。6.2 循环引用与内存泄漏如果备忘录持有原发器的引用或指针而原发器又以某种方式持有备忘录的引用就可能形成循环引用导致内存无法释放。在使用std::shared_ptr时需要特别注意。// 潜在循环引用 class Originator { std::shared_ptrMemento memento_; }; class Memento { std::shared_ptrOriginator originator_; // 危险 };解决方案仔细审视对象间的关系。在备忘录模式中备忘录通常不需要持有原发器的指针。如果确实需要例如在“封装更加严格的实现”变体中可以考虑使用std::weak_ptr来打破循环引用。6.3 访问控制与友元的滥用我们使用了friend来让Editor访问EditorMemento的私有数据。虽然这实现了严格的封装但过度使用友元会削弱封装性。替代方案是嵌套类私有成员如前所示备忘录作为原发器的私有嵌套类原发器可以直接访问其私有成员无需friend。这是最简洁的方式。公有Getter不推荐为备忘录的成员提供公有Getter但这破坏了备忘录对外的数据隐藏原则。接口隔离定义一个纯虚的IMemento接口只包含元数据方法。具体的ConcreteMemento继承它并私有化数据成员同时将原发器声明为友元。负责人通过IMemento接口与备忘录交互无法访问具体数据。6.4 性能问题排查清单当你发现撤销/重做操作变慢时可以按以下清单排查快照频率是否过高是否每个微小的操作都创建了快照可以考虑在更高层级的操作或用户显式保存时创建快照。快照数据量是否过大是否保存了整个文档是否可以考虑增量快照或压缩历史记录是否无限增长是否实现了历史记录上限在达到上限时可以丢弃最旧的快照。拷贝开销是否巨大检查CreateMemento中是否发生了不必要的深拷贝。对于大型数据结构考虑使用写时复制Copy-On-Write技术。6.5 最佳实践总结明确职责严格区分原发器、备忘录和负责人的角色。原发器负责状态备忘录负责数据负责人负责历史。优先使用嵌套类在C中将备忘录作为原发器的私有嵌套类是实现封装的最干净利落的方式。利用现代C特性使用std::unique_ptr管理所有权使用STL容器避免手动内存管理使用移动语义提升性能。考虑结合其他模式对于复杂的撤销/重做需求优先考虑备忘录模式命令模式的组合。命令模式封装操作备忘录模式保存状态两者结合威力巨大。性能与功能的权衡根据应用场景决定快照的粒度全量/增量和保存策略每次操作/检查点。对于内存敏感的环境历史记录需要有淘汰策略。为备忘录设计序列化如果状态需要持久化提前为备忘录设计好序列化接口这会让未来的扩展如保存到文件、网络同步更容易。备忘录模式不是一个经常被挂在嘴边的模式但它在需要状态回溯的场景下是无可替代的优雅解决方案。理解其精髓——将状态封装在只有所有者能打开的“黑盒”里——不仅能帮你实现撤销功能更能深化你对面向对象封装和职责分离原则的理解。下次当你需要保存对象状态时别再想着粗暴地暴露所有成员变量了试试备忘录模式你会发现代码的健壮性和可维护性提升了一个档次。