news 2026/7/15 13:26:56

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C++备忘录模式实战:封装对象状态实现撤销与恢复

1. 项目概述:为什么我们需要备忘录模式?

在C++项目开发中,尤其是涉及到复杂对象状态管理的场景,比如图形编辑器、游戏存档、事务回滚或者配置管理,我们经常会遇到一个核心需求:如何在不破坏对象封装性的前提下,保存其某一时刻的完整状态,并在未来某个时间点精准地恢复到那个状态?新手程序员可能会直接想到将对象的所有成员变量公开,或者提供一个SaveStateLoadState函数,将数据序列化到文件或内存中。但这样做,无异于将对象的“内脏”完全暴露在外,任何外部代码都可以随意修改其内部数据,封装性荡然无存,代码的健壮性和可维护性会急剧下降。

备忘录模式(Memento Pattern)正是为解决这一矛盾而生的优雅设计。它像一个时光胶囊,允许对象(我们称之为“原发器”)自行创建其状态的快照,并将这个快照封装在一个特殊的“备忘录”对象中。这个备忘录对象的内容对外界(除了创建它的原发器)是黑盒,只有原发器自己知道如何解读和恢复。而负责保存和管理这些历史快照的“负责人”类,则只负责保管“时光胶囊”,无权窥探或修改其中的内容。这种职责分离的设计,完美地平衡了状态保存的需求与面向对象封装的核心原则。

在C++中实现备忘录模式,我们尤其需要关注内存管理、深拷贝与浅拷贝、以及如何利用友元或嵌套类等语言特性来实现严格的访问控制。接下来,我将结合一个具体的文本编辑器示例,手把手带你从设计思路到代码实现,深入理解如何在C++中优雅地应用备忘录模式。

2. 核心角色与设计思路拆解

备忘录模式通常涉及三个核心角色,理解它们之间的关系是正确实现该模式的关键。

2.1 原发器 (Originator)

原发器是拥有需要保存状态的那个对象。在我们的文本编辑器例子中,Editor类就是原发器。它内部可能包含文本内容、光标位置、选中区域等私有数据。原发器需要提供两个关键方法:

  1. CreateMemento(): 用于创建当前状态的备忘录。这个方法会读取自身的所有关键状态,并利用这些数据构造并返回一个Memento对象。
  2. RestoreFromMemento(const Memento& memento): 用于从传入的备忘录对象中恢复状态。这个方法能解读备忘录内部的数据,并将其赋值给自身的成员变量。

注意:原发器是唯一一个被允许“打开”备忘录并读取其内部数据的角色。这通常通过将Memento类声明为Editor的友元,或者将Memento作为Editor的嵌套私有类来实现。

2.2 备忘录 (Memento)

备忘录对象是状态快照的载体。它应该设计为不可变对象,即一旦创建,其内部保存的状态数据就不能再被修改。这保证了历史状态的“纯洁性”。备忘录通常只提供构造函数(用于原发器初始化状态)和一些元数据查询接口(如获取快照创建时间),但绝不提供修改其内部状态的公共接口

备忘录存储什么数据,完全由原发器决定。它可能存储原发器所有数据的副本,也可能只存储自上次快照以来的差异(增量快照),后者在状态很大时能节省内存,但实现更复杂。

2.3 负责人 (Caretaker)

负责人负责管理备忘录的历史记录。它不知道备忘录里具体存了什么,也不关心如何恢复状态。它只做三件事:

  1. 在适当的时机(如用户执行某个操作前)请求原发器创建一个备忘录。
  2. 将这个备忘录保存起来(通常是用栈、列表或堆栈等数据结构)。
  3. 在需要撤销时,从历史记录中取出最新的备忘录,并将其交还给原发器进行恢复。

负责人和备忘录之间的交互应通过一个受限的接口(如果备忘录不是嵌套类),或者负责人根本不能直接操作备忘录的数据成员。

设计思路的核心在于:将状态保存与恢复的“能力”赋予原发器,将状态历史的“管理权”赋予负责人,而将状态数据的“保管权”赋予一个对外界只读的备忘录。这样,每个类的职责单一且清晰,符合高内聚、低耦合的设计原则。

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_ptr<EditorMemento> 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; }; };

关键点解析

  1. EditorMementoEditor的私有嵌套类。这意味着在Editor类的外部,无法直接声明或操作EditorMemento对象,这提供了第一层封装保护。
  2. EditorMemento的构造函数是公开的,但因为它是一个私有嵌套类,所以实际上只能在Editor类的成员函数内部被调用。这确保了只有Editor能创建备忘录。
  3. 我们将Editor声明为EditorMementofriend。这是关键一步,它允许Editor::RestoreFromMemento函数访问EditorMemento的所有私有成员(savedText_,savedCursorX_等),从而读取快照数据。而其他类(如负责人)即使拿到了EditorMemento对象,也无法读取其内部数据,因为它们不是友元。
  4. 我们使用std::unique_ptr<EditorMemento>作为创建备忘录的返回类型。这明确了备忘录的所有权转移:备忘录由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_ptr<Editor::EditorMemento> Editor::CreateMemento() const { // 使用std::make_unique创建备忘录对象,并传入当前状态。 // 注意:这里发生了状态的拷贝。对于大型对象,需要考虑性能。 return std::make_unique<EditorMemento>(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_ptr<Editor::EditorMemento> memento) { // 将备忘录的所有权转移到历史栈中 history_.push(std::move(memento)); } // 撤销:取出最近的状态,如果栈不为空 std::unique_ptr<Editor::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::stack<std::unique_ptr<Editor::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; }

这个流程清晰地展示了备忘录模式的工作方式:

  1. 状态修改前保存:在编辑器状态可能改变的关键操作点,调用editor.CreateMemento()生成快照,并由History保存。
  2. 状态恢复:当需要撤销时,History交出最新的快照,编辑器调用RestoreFromMemento,用快照数据覆盖当前状态。
  3. 职责分离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_ptr<Editor::EditorMemento> backup_; // 命令持有备忘录 };

在这种模式下,每个Command对象在创建时(或执行前)保存一个状态快照(备忘录)。History类保存的是Command对象的序列。执行撤销时,调用栈顶命令的Undo()方法,该方法利用自己保存的备忘录来恢复状态。这实现了操作级别的撤销,并且命令对象可以封装更复杂的逻辑。

5.2 内存优化:增量快照与压缩

如果原发器状态非常庞大(例如一个包含大量图元的图形文档),每次全量保存快照会消耗巨大内存。解决方案有:

  • 增量快照:备忘录只保存自上次快照以来发生变化的部分。这需要原发器能识别出状态的差异。
  • 压缩存储:对保存的状态数据进行压缩(例如,对于文本,如果连续空白很多,可以使用游程编码)。
  • 懒快照/检查点:并非每次操作都保存快照,而是在特定“检查点”保存全量快照,中间的操作通过重放命令日志来实现回滚。这类似于数据库的事务日志。

5.3 序列化支持与持久化

有时我们需要将状态保存到磁盘或通过网络传输。备忘录模式可以很好地与序列化结合。

  1. EditorMemento类支持序列化(例如,实现toJson()fromJson()方法,或重载<<>>操作符)。
  2. History类可以将备忘录序列化后存入文件或数据库。
  3. 恢复时,从存储中反序列化出备忘录对象,再交给原发器恢复。

注意事项:序列化时要注意深拷贝和指针问题。最好在备忘录中只存储值类型数据或可序列化的对象。

6. 常见陷阱、问题排查与最佳实践

在实际使用备忘录模式时,我踩过不少坑,这里总结一下。

6.1 深拷贝与浅拷贝的坑

这是C++实现中最常见的错误。如果原发器的状态包含指针或引用,在创建备忘录时必须进行深拷贝。

// 错误示例:浅拷贝 class BadEditor { char* largeText_; // 动态分配的字符数组 public: class BadMemento { char* savedText_; // 只拷贝了指针! }; }; // 正确做法:在Memento的构造函数和析构函数中管理深拷贝 class GoodEditor { std::unique_ptr<char[]> largeText_; // 使用智能指针是更好的选择 public: class GoodMemento { std::unique_ptr<char[]> savedText_; // 智能指针自动管理深拷贝和释放 GoodMemento(const std::unique_ptr<char[]>& src) : savedText_(std::make_unique<char[]>(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_ptr<Memento> memento_; }; class Memento { std::shared_ptr<Originator> originator_; // 危险! };

解决方案:仔细审视对象间的关系。在备忘录模式中,备忘录通常不需要持有原发器的指针。如果确实需要(例如在“封装更加严格的实现”变体中),可以考虑使用std::weak_ptr来打破循环引用。

6.3 访问控制与友元的滥用

我们使用了friend来让Editor访问EditorMemento的私有数据。虽然这实现了严格的封装,但过度使用友元会削弱封装性。替代方案是:

  • 嵌套类+私有成员:如前所示,备忘录作为原发器的私有嵌套类,原发器可以直接访问其私有成员,无需friend。这是最简洁的方式。
  • 公有Getter(不推荐):为备忘录的成员提供公有Getter,但这破坏了备忘录对外的数据隐藏原则。
  • 接口隔离:定义一个纯虚的IMemento接口,只包含元数据方法。具体的ConcreteMemento继承它并私有化数据成员,同时将原发器声明为友元。负责人通过IMemento接口与备忘录交互,无法访问具体数据。

6.4 性能问题排查清单

当你发现撤销/重做操作变慢时,可以按以下清单排查:

  1. 快照频率是否过高?是否每个微小的操作都创建了快照?可以考虑在更高层级的操作或用户显式保存时创建快照。
  2. 快照数据量是否过大?是否保存了整个文档?是否可以考虑增量快照或压缩?
  3. 历史记录是否无限增长?是否实现了历史记录上限?在达到上限时,可以丢弃最旧的快照。
  4. 拷贝开销是否巨大?检查CreateMemento中是否发生了不必要的深拷贝。对于大型数据结构,考虑使用写时复制(Copy-On-Write)技术。

6.5 最佳实践总结

  1. 明确职责:严格区分原发器、备忘录和负责人的角色。原发器负责状态,备忘录负责数据,负责人负责历史。
  2. 优先使用嵌套类:在C++中,将备忘录作为原发器的私有嵌套类是实现封装的最干净利落的方式。
  3. 利用现代C++特性:使用std::unique_ptr管理所有权,使用STL容器避免手动内存管理,使用移动语义提升性能。
  4. 考虑结合其他模式:对于复杂的撤销/重做需求,优先考虑备忘录模式+命令模式的组合。命令模式封装操作,备忘录模式保存状态,两者结合威力巨大。
  5. 性能与功能的权衡:根据应用场景决定快照的粒度(全量/增量)和保存策略(每次操作/检查点)。对于内存敏感的环境,历史记录需要有淘汰策略。
  6. 为备忘录设计序列化:如果状态需要持久化,提前为备忘录设计好序列化接口,这会让未来的扩展(如保存到文件、网络同步)更容易。

备忘录模式不是一个经常被挂在嘴边的模式,但它在需要状态回溯的场景下是无可替代的优雅解决方案。理解其精髓——将状态封装在只有所有者能打开的“黑盒”里——不仅能帮你实现撤销功能,更能深化你对面向对象封装和职责分离原则的理解。下次当你需要保存对象状态时,别再想着粗暴地暴露所有成员变量了,试试备忘录模式,你会发现代码的健壮性和可维护性提升了一个档次。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/7/15 13:25:09

2026年必转方向!Agent开发火爆,高薪岗位等你来,错过再等一年!

放眼2026的技术圈&#xff0c;Agent 绝对是当下最热门的方向之一。不管是大厂的技术动向&#xff0c;还是最近新增的岗位&#xff0c;核心都围绕着 Agent 开发。 从26届秋招、春招&#xff0c;以及最近社招的反馈中&#xff0c;我们也能清晰看到趋势&#xff1a; “传统后端开发…

作者头像 李华
网站建设 2026/7/15 13:21:16

RAG失败的7种坑,你踩了几个?

RAG 系统从检索到生成是一条级联管线——前一个环节的输出是后一个的输入。任何一个环节出问题,错误会逐级放大。了解失败模式才能精准定位问题根源。 RAG失败的两大阶段 RAG 的失败模式分成两大阶段,一共七种典型故障。检索阶段有四种,生成阶段有三种。先分清阶段,再定位…

作者头像 李华
网站建设 2026/7/15 13:21:15

如何贡献Kuzushiji-MNIST基准测试结果:提交你的模型性能数据

如何贡献Kuzushiji-MNIST基准测试结果&#xff1a;提交你的模型性能数据 【免费下载链接】kmnist Repository for Kuzushiji-MNIST, Kuzushiji-49, and Kuzushiji-Kanji 项目地址: https://gitcode.com/gh_mirrors/km/kmnist 想要为Kuzushiji-MNIST数据集贡献你的模型性…

作者头像 李华
网站建设 2026/7/15 13:21:11

Winhance中文版:为你的Windows系统注入新活力

Winhance中文版&#xff1a;为你的Windows系统注入新活力 【免费下载链接】Winhance-zh_CN A Chinese version of Winhance. C# application designed to optimize and customize your Windows experience. 项目地址: https://gitcode.com/gh_mirrors/wi/Winhance-zh_CN …

作者头像 李华
网站建设 2026/7/15 13:20:48

【软件工程】结构化设计:结构化设计方法(SC图)

考点频率&#xff1a;★★★★☆&#xff08;选择题常考&#xff0c;下午题偶尔结合DFD考查&#xff09; 难度&#xff1a;⭐⭐⭐ 建议&#xff1a;重点掌握SC图与DFD的关系、模块划分原则&#xff0c;以及两种映射方法1️⃣ 从DFD到SC图&#xff1a;从“分析”到“设计” 上一…

作者头像 李华
网站建设 2026/7/15 13:19:48

开关电源设计核心挑战与四大拓扑选型指南

1. 开关电源设计的核心挑战与避坑逻辑从事硬件设计这些年&#xff0c;我拆解过上百款开关电源&#xff0c;也踩过几乎所有能想到的坑。新手工程师最常犯的错误&#xff0c;就是直接照搬参考设计&#xff0c;却对背后的原理一知半解。当电路出现振荡、发热甚至炸机时&#xff0c…

作者头像 李华