备忘录模式:实现撤销/重做与状态恢复的设计模式详解
1. 项目概述为什么我们需要“后悔药”在软件开发的日常里我们经常遇到一个场景用户正在编辑一份复杂的文档或者在一个图形工具里绘制一幅精密的图纸突然一个误操作或者系统崩溃导致之前半小时的心血付诸东流。这时候用户最渴望的就是一颗“后悔药”能一键回到几分钟前的状态。这种“保存快照、随时恢复”的能力就是备忘录模式要解决的核心问题。备忘录模式英文叫Memento Pattern是23种经典设计模式中行为型模式的一种。它的核心思想非常直观就是在不破坏对象封装性的前提下捕获并外部化一个对象的内部状态以便在将来某个时刻可以将该对象恢复到这个状态。简单说就是给对象拍个“快照”然后把快照存起来等需要的时候再把这个快照“读档”回去。这个模式的名字起得非常贴切“备忘录”就是用来记录某个时刻的关键信息以备后查。这个模式的应用场景远不止文档编辑。在游戏开发中它是实现“存档/读档”功能的基石在事务处理中它可以用来实现回滚操作在IDE里它是“撤销/重做”功能背后的功臣。理解并掌握备忘录模式意味着你为你的系统赋予了一种“状态回溯”的超能力能极大地提升用户体验和系统的健壮性。接下来我们就深入拆解这个模式的里里外外看看如何从零开始把它稳稳地应用到你的项目里。2. 备忘录模式的核心结构与角色解析备忘录模式的结构清晰通常涉及三个核心角色它们各司其职共同协作完成状态的保存与恢复。理解这三个角色及其关系是掌握这个模式的关键。2.1 发起人 (Originator)发起人是拥有需要被保存状态的“当事人”。它知道当前时刻自身的所有内部细节并且负责两件关键事情创建备忘录创建一个新的备忘录对象并用自己当前的状态来初始化这个备忘录。恢复状态接收一个备忘录对象并根据其中保存的信息将自己的状态恢复到备忘录所记录的那个时刻。你可以把发起人想象成一个游戏角色它有自己的生命值、魔法值、装备和位置。当需要存档时它就负责把自己的这些属性打包成一个“存档文件”备忘录。2.2 备忘录 (Memento)备忘录角色是状态的“存储箱”。它的唯一使命就是存储发起人对象的内部状态。这里有一个非常重要的设计原则备忘录对象通常只对发起人对象开放其状态的读写权限而对其他对象比如管理者则隐藏其实现细节只提供一个窄接口。这完美体现了“封装性”原则——状态数据被安全地封装在备忘录内部避免了被不相关的代码随意修改。备忘录可以设计成两种形式白箱实现备忘录的所有字段都是public的任何对象都能访问和修改。这种方式简单但破坏了封装性不推荐在生产中使用。黑箱实现推荐备忘录将状态存储为私有字段并且其本身不提供任何public的修改方法。状态的设置和获取通过只有发起人能调用的包级私有或友元方法来完成在不同语言中实现方式不同如Java的包访问权限、C的友元类。这是标准的、安全的设计。2.3 管理者 (Caretaker)管理者是备忘录的“保管员”。它负责保存备忘录对象但它绝不应该也通常不能对备忘录内部存储的状态进行任何操作。管理者的职责很简单当发起人说“帮我存个档”它就接过备忘录并收好当发起人说“把昨天的档给我”它就把对应的备忘录交还给发起人。管理者可以简单地保存一个备忘录也可以维护一个栈来实现多次撤销Undo或者维护一个列表来实现多个存档点。它是连接用户操作如点击“保存”按钮和发起人状态管理的桥梁。这三个角色的协作流程就像一个标准的存档流程用户触发“保存”操作。管理者向发起人请求“请给我一个你当前的备忘录快照”。发起人创建并返回一个包含自身当前状态的备忘录对象。管理者将这个备忘录对象存储起来放入栈、列表或文件。当用户触发“恢复”或“撤销”操作时管理者将之前保存的备忘录交还给发起人。发起人使用这个备忘录将自己的状态完全恢复到创建该备忘录时的样子。注意在设计备忘录时务必考虑深度拷贝与浅拷贝的问题。如果发起人的状态中包含对其他可变对象的引用如数组、集合、自定义对象简单的字段拷贝浅拷贝会导致备忘录和发起人共享同一份引用后续对状态的修改会相互影响破坏快照的独立性。因此在创建备忘录时通常需要对复杂状态进行深拷贝确保快照的“冻结”效果。3. 从理论到实践一个文本编辑器的撤销功能实现光说不练假把式我们用一个最经典的例子——文本编辑器的撤销Undo功能来完整走一遍备忘录模式的实现。我们将使用Java语言因为它对面向对象特性的支持非常清晰。假设我们有一个简单的文本编辑器它的核心是一个TextEditor类发起人可以输入文本并且我们希望能撤销到最后一次保存的状态。3.1 第一步定义黑箱备忘录首先我们实现一个“黑箱”备忘录。关键点在于TextEditor可以访问TextMemento的内部状态但外部的Caretaker不能。// 备忘录类 - 对外部完全黑箱 public class TextMemento { // 私有字段存储状态 private final String text; // 包级私有构造器只有同包下的发起人能创建它 TextMemento(String textToSave) { this.text textToSave; } // 包级私有的获取状态方法只有同包下的发起人能读取它 String getSavedText() { return this.text; } }注意TextMemento的构造器和getSavedText()方法都是包级私有没有public修饰符。这意味着只有与它位于同一个包例如com.example.memento下的类才能创建和读取备忘录。3.2 第二步实现发起人接下来实现文本编辑器本身即发起人角色。// 发起人类 - 文本编辑器 public class TextEditor { private StringBuilder text; // 使用StringBuilder便于文本操作 public TextEditor() { this.text new StringBuilder(); } // 业务方法添加文本 public void type(String words) { text.append(words); System.out.println(当前文本: text.toString()); } // 业务方法获取当前文本 public String getText() { return text.toString(); } // **核心创建备忘录** - 保存当前状态 public TextMemento save() { System.out.println(保存状态...); // 创建备忘录传入当前文本的副本深拷贝思想这里String本身不可变所以没问题 return new TextMemento(this.text.toString()); } // **核心恢复状态** - 从备忘录恢复 public void restore(TextMemento memento) { // 通过备忘录的包级私有方法获取保存的状态 this.text new StringBuilder(memento.getSavedText()); System.out.println(恢复状态至: this.text.toString()); } }在save()方法中TextEditor创建了一个TextMemento对象。因为它们在同一个包内所以可以调用TextMemento的包级私有构造器。同样在restore()方法中它可以调用getSavedText()方法。而对于包外的类这些细节都是不可见的。3.3 第三步实现管理者管理者Caretaker负责保管备忘录。为了实现撤销功能我们用一个栈Stack来保存历史状态。import java.util.Stack; // 管理者类 - 负责保管备忘录历史 public class Caretaker { // 使用栈来保存历史状态后进先出符合撤销操作 private StackTextMemento history new Stack(); // 保存状态到历史记录 public void saveState(TextMemento memento) { history.push(memento); } // 从历史记录中恢复最近一次状态撤销 public TextMemento undo() { if (!history.isEmpty()) { // 弹出最近一次保存的状态 return history.pop(); } return null; // 或者抛出一个异常表示无法撤销 } // 可选查看历史记录深度 public int getHistorySize() { return history.size(); } }3.4 第四步客户端代码与运行演示最后我们编写客户端代码将三者串联起来模拟用户的编辑和撤销操作。// 客户端代码 public class Client { public static void main(String[] args) { // 1. 创建发起人编辑器和管理者历史记录器 TextEditor editor new TextEditor(); Caretaker history new Caretaker(); // 2. 用户开始编辑 editor.type(Hello, ); // 3. 用户觉得当前状态不错保存一下创建备忘录并由管理者保存 history.saveState(editor.save()); editor.type(World!); System.out.println(继续编辑后: editor.getText()); history.saveState(editor.save()); // 再次保存 editor.type( This is Memento Pattern.); System.out.println(再次编辑后: editor.getText()); // 4. 用户想撤销到最后一次保存的状态 System.out.println(\n--- 执行撤销操作 ---); TextMemento lastState history.undo(); if (lastState ! null) { editor.restore(lastState); System.out.println(撤销后文本: editor.getText()); } // 5. 再撤销一次 System.out.println(\n--- 再次执行撤销操作 ---); lastState history.undo(); if (lastState ! null) { editor.restore(lastState); System.out.println(再次撤销后文本: editor.getText()); } System.out.println(剩余可撤销次数: history.getHistorySize()); } }运行结果预测当前文本: Hello, 保存状态... 当前文本: Hello, World! 继续编辑后: Hello, World! 保存状态... 当前文本: Hello, World! This is Memento Pattern. 再次编辑后: Hello, World! This is Memento Pattern. --- 执行撤销操作 --- 恢复状态至: Hello, World! 撤销后文本: Hello, World! --- 再次执行撤销操作 --- 恢复状态至: Hello, 再次撤销后文本: Hello, 剩余可撤销次数: 0通过这个完整的例子你可以清晰地看到备忘录模式如何优雅地实现了状态的保存与恢复。Caretaker完全不知道TextMemento里存了什么它只负责保管这个“黑盒子”这最大限度地保证了TextEditor内部状态的封装性和安全性。4. 进阶探讨模式变体与实战中的关键决策掌握了基础实现后在实际项目中应用备忘录模式你会面临几个关键的设计抉择。不同的选择会带来不同的复杂度、性能和灵活性。4.1 增量备忘录 vs. 全量备忘录这是最核心的决策之一直接影响到性能和存储开销。全量备忘录就像我们上面的例子每次保存都完整地拷贝发起人的整个状态如整个文档内容。实现简单恢复速度快直接替换但内存消耗大。如果状态很大如一张高清图片、一个复杂模型频繁保存历史会导致内存急剧增长。增量备忘录只保存上一次状态之后发生变化的部分差异。例如在文本编辑中只保存“在位置5插入了字符串‘ABC’”。这极大地节省了存储空间特别适合状态变化频繁但增量小的场景。然而它的实现复杂得多恢复状态时需要从某个基准状态开始顺序应用一系列增量变更恢复速度可能较慢且逻辑容易出错。如何选择状态大小与变化频率状态大、变化部分小如文档编辑优先考虑增量。状态本身不大或者变化总是全局性的用全量更省心。恢复性能要求要求快速恢复如游戏读档全量有优势。可以容忍一定计算开销的增量可行。复杂度权衡项目初期或原型阶段用全量快速实现功能。后期性能成为瓶颈时再考虑重构为增量。4.2 备忘录的存储与持久化备忘录放在内存里程序关闭就没了。对于真正的“存档”功能我们需要持久化到磁盘或数据库。序列化这是最直接的方式。让Memento类实现Serializable接口Java或类似机制。管理者保存时将对象序列化成字节流写入文件恢复时从文件反序列化。优点是简单能保存复杂的对象图。缺点是序列化格式通常与语言绑定不易跨语言读取且类结构变更如增加字段可能导致兼容性问题。自定义格式存储将状态转换成一种自定义的、稳定的数据格式如JSON、XML或Protocol Buffers。Originator在创建备忘录时将状态转为JSON字符串恢复时再从JSON解析。优点是格式人类可读、跨语言、版本兼容性相对好处理。缺点是需要额外的转换代码对于非常复杂的嵌套对象转换逻辑可能很繁琐。命令式存储这与增量备忘录思想结合。不存储状态本身而是存储导致状态变化的命令序列如“AddTextCommand”, “DeleteCommand”。恢复时重新执行命令序列。这在图形编辑器和某些游戏中很常见。实操心得对于需要长期保存、可能需跨版本兼容的存档我强烈推荐JSON等自定义格式。虽然前期多写一些转换代码但后期调试、数据迁移、甚至提供外部工具修改存档都会方便得多。内存中的备忘录对象可以设计成包含一个toJson()和fromJson()方法。4.3 管理者的职责扩展历史栈、分支与重做基础的管理者只用一个栈实现撤销。但完整的编辑器通常需要重做Redo功能这需要两个栈——undoStack和redoStack。执行撤销时从undoStack弹出状态压入redoStack执行重做时反之。新建操作会清空redoStack。历史分支像Git一样支持保存多个命名的快照点并可以在不同点之间切换。这需要管理者维护一个状态节点图每个节点是一个备忘录节点间记录父子关系。复杂度飙升但提供了强大的版本管理能力。状态变更监听管理者可以监听发起人的状态变更自动在合适的时机创建备忘录如每隔5秒自动保存而不是完全由客户端代码驱动。5. 备忘录模式的优缺点与适用场景分析没有一种设计模式是银弹备忘录模式也不例外。清晰认识其利弊才能做出正确的使用决策。5.1 优势封装性得以保持这是它最大的优点。通过黑箱实现发起人对象的内部状态细节被很好地隐藏在备忘录内部其他对象无法直接访问和修改符合面向对象设计原则。简化了发起人职责发起人无需自己管理历史状态只需负责创建和恢复备忘录职责单一。状态保存和管理的逻辑移交给了管理者。易于实现状态回溯提供了一种标准化、可扩展的机制来实现撤销、重做、事务回滚等需要回溯状态的功能。管理者可以灵活管理历史管理者可以决定保存备忘录的频率、存储方式内存、文件、数据库和历史结构栈、列表、树而不影响发起人的代码。5.2 劣势与挑战资源消耗尤其是使用全量备忘录时如果发起人对象状态非常庞大如包含大图片、复杂模型频繁保存备忘录会消耗大量内存和存储空间。管理器职责过重在需要实现复杂历史管理如分支、合并时管理者的逻辑会变得非常复杂。深拷贝的复杂性为了确保备忘录状态的独立性往往需要深拷贝。如果对象图非常深、包含循环引用深拷贝的实现会变得棘手且性能低下。潜在的语言限制在某些语言中实现真正的“黑箱”备忘录仅对发起人可见可能需要使用一些特殊机制如友元类、内部类、包访问权限这可能不如其他模式通用。5.3 典型适用场景当你遇到以下情况时应该考虑备忘录模式需要提供撤销Undo和重做Redo功能文本编辑器、图形绘图软件、IDE等。需要保存对象状态快照并在之后恢复游戏中的存档/读档功能。需要实现事务回滚数据库操作或一系列业务操作在失败时需要回滚到操作前的状态。需要监控对象状态变化并能回溯到任意历史点如配置管理、工作流状态跟踪。5.4 不适用或需谨慎使用的场景对象状态极其庞大或复杂全量保存成本过高需评估是否能用增量模式或是否有其他更轻量的方案如命令模式记录操作。状态变更频率极高例如实时游戏画面每帧都保存状态不现实。通常只在特定检查点Checkpoint或用户请求时保存。对性能有极端要求深拷贝和状态恢复可能带来性能开销在性能关键路径上需仔细评估。语言不支持必要的封装特性如果无法实现安全的黑箱备忘录导致状态暴露则需要权衡其带来的风险。6. 与其他相关模式的对比与抉择备忘录模式常与另外两个行为型模式——命令模式和状态模式——被放在一起讨论和比较因为它们都与“状态”和“行为”密切相关。理解它们的区别能帮助你在具体场景中做出更精准的选择。6.1 备忘录模式 vs. 命令模式这是最容易混淆的一对。两者都能实现撤销功能但思想截然不同。备忘录模式关注于对象状态的快照。它保存的是某一时刻对象的完整或增量数据。撤销时直接用保存的数据覆盖当前状态。命令模式关注于将请求封装为对象。它保存的是“做什么”的指令命令对象以及执行该指令所需的参数。撤销时命令对象会提供一个undo()方法该方法内部通常封装了反向操作逻辑例如AddTextCommand的undo()就是执行删除文本。如何选择如果状态本身易于保存和恢复且反向操作逻辑复杂或难以定义用备忘录模式。例如保存一个复杂的游戏角色所有属性。如果操作命令本身易于定义和反转且状态分散在多个对象中用命令模式。例如图形编辑器中移动、旋转、缩放图形其反向操作很明确。在实践中两者常结合使用命令对象在执行时可以先创建受影响对象的备忘录并保存起来。当需要撤销该命令时命令的undo()方法就利用这个备忘录来恢复对象状态。这样既利用了命令模式组织操作的灵活性又利用了备忘录模式恢复状态的直接性。6.2 备忘录模式 vs. 原型模式原型模式用于克隆对象它也能得到一个对象在某一时刻的副本。区别在于备忘录模式目的是状态恢复关注点在于将对象回滚到某个历史状态。备忘录对象可能只保存部分状态且其生命周期由管理者控制。原型模式目的是创建新对象关注点在于以现有对象为蓝本高效地生成一个独立的新实例。克隆得到的是一个完整的、可独立使用的新对象。6.3 备忘录模式 vs. 状态模式状态模式允许一个对象在其内部状态改变时改变其行为。它和备忘录模式的联系在于一个状态对象本身可能就包含了需要保存的数据。你可以为状态模式中的每个具体状态类实现创建备忘录和恢复状态的方法。当发起人上下文保存状态时实际上可能需要遍历并保存其内部所有状态对象的信息。备忘录模式可以作为状态模式的一个辅助机制用于保存和恢复整个状态机的配置。7. 实战避坑指南与性能优化技巧纸上得来终觉浅绝知此事要躬行。在实际项目中使用备忘录模式我踩过不少坑也总结出一些让模式用得更“顺滑”的技巧。7.1 深拷贝的陷阱与解决方案这是备忘录模式最大的坑之一。浅拷贝会导致备忘录和原始对象共享引用一改俱改。问题示例public class Originator { private ListString items new ArrayList(); // 可变对象引用 public Memento save() { // 错误这只是拷贝了引用items列表在备忘录和Originator中是同一个对象 return new Memento(this.items); } }解决方案使用不可变对象尽可能将状态设计为不可变对象如String、Integer。这样浅拷贝也是安全的。手动实现深拷贝对于集合、数组或自定义对象在创建备忘录时手动创建它们的副本。public Memento save() { // 正确创建列表的深拷贝 ListString copyOfItems new ArrayList(this.items); return new Memento(copyOfItems); }序列化/反序列化利用Java的序列化机制进行深拷贝要求所有相关类实现Serializable。这种方法能处理复杂的对象图但性能开销较大。public Memento save() throws IOException, ClassNotFoundException { ByteArrayOutputStream bos new ByteArrayOutputStream(); ObjectOutputStream oos new ObjectOutputStream(bos); oos.writeObject(this.items); oos.close(); return new Memento(bos.toByteArray()); } public void restore(Memento m) throws IOException, ClassNotFoundException { ByteArrayInputStream bis new ByteArrayInputStream(m.getState()); ObjectInputStream ois new ObjectInputStream(bis); this.items (ListString) ois.readObject(); ois.close(); }使用第三方库如Apache Commons Lang的SerializationUtils.clone()或使用JSON序列化/反序列化来实现深拷贝。7.2 内存管理与历史记录清理如果无限制地保存备忘录内存迟早会耗尽。设置历史深度上限在Caretaker中只保留最近N条记录。当超过上限时丢弃最旧的记录。这对于文本编辑器的撤销功能是常见做法。private StackMemento undoStack new Stack(); private static final int MAX_HISTORY 50; public void saveState(Memento m) { undoStack.push(m); if (undoStack.size() MAX_HISTORY) { // 移除栈底最旧的元素需要一些额外逻辑因为Stack不直接支持 // 更简单的做法是使用DequeArrayDeque } }定期清理或按需保存不要每次状态微小的变化都保存。可以设置一个阈值如文本变化超过10个字符或者由用户显式触发保存如CtrlS。增量存储结合压缩对于增量备忘录可以定期将多个增量合并成一个全量快照并清除中间的增量记录以节省空间和加速恢复。7.3 处理复杂对象图的快照当发起人的状态是一个包含大量相互引用对象的复杂图时创建快照会非常困难。标识对象引用在备忘录中不存储对象本身而是存储对象的唯一标识符ID。恢复时根据ID从一个全局的“对象仓库”中获取对象。这要求所有对象在仓库中是可检索的并且其状态在保存后不会被意外修改。使用“写时复制”技术如果状态对象本身支持不可变视图或拷贝可以延迟拷贝的时机。在创建备忘录时只标记需要保存直到真正恢复时如果发现原始对象已被修改再执行实际的拷贝操作。这需要更精细的控制。领域驱动设计中的聚合根在DDD中通常只对聚合根进行备忘录保存。聚合内部的其他对象状态通过根来保持一致。这简化了快照的边界。7.4 线程安全考量如果发起人对象可能在多线程环境下被修改那么创建备忘录和恢复状态的操作必须是原子的否则可能保存到不一致的中间状态。同步Synchronized在save()和restore()方法上使用synchronized关键字确保同一时间只有一个线程能执行状态保存或恢复。使用不可变快照设计备忘录对象为完全不可变的。这样即使在保存过程中发起人状态发生变化备忘录持有的也是创建那一刻的确定状态。这通常需要在save()方法内部将所需状态先拷贝到局部变量再用这些局部变量构造备忘录。版本号或时间戳为每个备忘录增加一个版本号或创建时间戳。管理者在恢复时可以检查版本是否匹配或者让用户选择恢复到哪个时间点的状态。备忘录模式是一个强大而优雅的工具它将状态管理的复杂性从业务对象中剥离出来赋予了系统“时光倒流”的能力。从简单的文本撤销到复杂的游戏存档其思想一脉相承。关键在于理解其封装状态的核心思想并根据你的具体场景在全量与增量、内存与持久化、简单与复杂之间做出恰当的权衡。下次当你需要给用户一颗“后悔药”时不妨想想备忘录模式它很可能就是那个优雅的解决方案。

相关新闻

EL Shield:轻量级日志异常检测系统设计与实战

EL Shield:轻量级日志异常检测系统设计与实战

1. 项目缘起:从“裸奔”到“武装”的运维觉醒 在运维和开发这个行当里,日志系统就像我们每天呼吸的空气,无处不在,却又常常被忽视。直到某天深夜,线上服务突然告警,流量曲线断崖式下跌,而你面对…

2026/8/3 10:18:42 阅读更多
Luna模型:高性价比AI编程助手实战指南与本地部署详解

Luna模型:高性价比AI编程助手实战指南与本地部署详解

如果你正在寻找一个既能理解复杂指令、又能快速生成代码,同时还能保持极低推理成本的AI助手,那么最近在开发者圈子里热议的Luna模型,可能就是你一直在等待的那个“性价比之选”。 过去几个月,AI编程助手领域看似热闹,…

2026/8/3 10:18:42 阅读更多
5分钟极速配置:网盘直链解析工具完全指南

5分钟极速配置:网盘直链解析工具完全指南

5分钟极速配置:网盘直链解析工具完全指南 【免费下载链接】Online-disk-direct-link-download-assistant 一个基于 JavaScript 的网盘文件下载地址获取工具。基于【网盘直链下载助手】修改 ,支持 百度网盘 / 阿里云盘 / 中国移动云盘 / 天翼云盘 / 迅雷云…

2026/8/3 10:18:42 阅读更多
Java键值对类实现方案与性能对比

Java键值对类实现方案与性能对比

1. Java键值对类基础解析当我们需要在Java中处理仅包含两个属性的简单键值对数据结构时,通常会面临多种选择。这类场景在实际开发中非常常见,比如配置参数存储、临时数据传递等。不同于复杂的Map结构,简单键值对类更轻量且类型安全。Java生态…

2026/8/3 10:08:41 阅读更多
3分钟搞定!QQ空间历史说说完整备份终极指南

3分钟搞定!QQ空间历史说说完整备份终极指南

3分钟搞定!QQ空间历史说说完整备份终极指南 【免费下载链接】GetQzonehistory 获取QQ空间发布的历史说说 项目地址: https://gitcode.com/GitHub_Trending/ge/GetQzonehistory 你是否曾想过,那些年发过的QQ空间说说,那些记录青春的文字…

2026/8/2 0:04:01 阅读更多
AMAT 0100-02186 I/O 分配 PCB

AMAT 0100-02186 I/O 分配 PCB

AMAT 0100-02186 I/O分配PCB板是应用材料(Applied Materials)公司生产的一款用于半导体设备的I/O信号分配电路板。该型号(0100-02186)的核心特点如下:专用于Endura等半导体工艺腔室。集成信号路由与分配功能。连接控制…

2026/8/2 2:51:21 阅读更多
Nissei Corp FFMN-32L-10-T0 40AX 三相异步电动机

Nissei Corp FFMN-32L-10-T0 40AX 三相异步电动机

Nissei Corp FFMN-32L-10-T0 40AX 三相异步电动机是日本日清(Nissei)品牌的一款工业用三相异步电机,适用于自动化设备及通用机械驱动。该型号(FFMN-32L-10-T0 40AX)的核心特点如下:三相交流异步电动机。额定…

2026/8/2 2:52:49 阅读更多