行业资讯

GoF设计模式——备忘录模式

发布时间:2026/7/10 10:31:07
GoF设计模式——备忘录模式 前言为什么需要备忘录模式假设正在做一个文本编辑器需要支持 CtrlZ 撤销功能。最直觉的写法是每次修改前把整个 Editor 对象的内部字段抄一份出来放到栈里class Editor {public String content; // 为了保存历史只能开放成 publicpublic int cursorPos;public String selection;}class History {private DequeObject[] snapshots new ArrayDeque();public void save(Editor editor) { // 直接读取 editor 的内部字段 snapshots.push(new Object[]{editor.content, editor.cursorPos, editor.selection}); } public void undo(Editor editor) { Object[] snap snapshots.pop(); editor.content (String) snap[0]; editor.cursorPos (int) snap[1]; editor.selection (String) snap[2]; }}这种写法有两个致命问题。第一Editor 内部所有字段被迫暴露成 public封装性荡然无存——任何人都能随意修改编辑器内部状态。第二一旦 Editor 新增或删除字段History 的保存/恢复代码都得跟着改两个类死死绑定。我之前一直以为撤销就是把变量拷一份存起来后来才明白真正的问题不是拷贝本身而是谁能看到快照里存了什么。历史管理者只需要保管快照不需要理解快照。备忘录模式解决的就是这个问题——让状态既能被保存又不破坏对象的封装边界。概念备忘录模式Memento Pattern也叫快照Snapshot模式是一种行为型设计模式核心思想是在不破坏封装性的前提下捕获一个对象的内部状态并将该状态保存到对象外部以便之后能将对象恢复到先前的状态。可以把它想象成银行保险箱储户发起人把重要物品装进保险箱备忘录后交给银行管理者保管银行只知道自己保管着一个箱子但没有钥匙打不开——只有储户本人能打开取出物品。银行负责调度箱子存进哪个格子、什么时候取出但永远不知道箱子里装了什么。备忘录模式涉及三个角色Originator发起人需要保存和恢复状态的对象负责创建 Memento记录当前状态和使用 Memento恢复历史状态Memento备忘录存储发起人内部状态的快照对象对发起人提供宽接口完整读写对外部提供窄接口只能传递不能查看/修改Caretaker管理者负责保管备忘录但永远不操作或查看备忘录的内容职责仅限于拿着和递回去创建并读取保管通知保存/恢复Originator-state: StringsetState(state: String)getState() : StringcreateMemento() : MementorestoreFromMemento(m: Memento)Memento-state: String-Memento(state: String)-getState() : StringCaretaker-history: Dequesave(o: Originator)undo(o: Originator)图中各类之间的关系Originator 创建 Memento 并能读取其内部状态Caretaker 只持有 Memento 的引用但不能访问其字段构造器和 getter 都是私有的Caretaker 通过调用 Originator 的 createMemento 和 restoreFromMemento 方法完成保存与恢复——Memento 的内容始终对 Caretaker 不透明。实现GoF 原书用的是双接口方案Memento 同时暴露两套接口——对 Originator 提供宽接口能读写完整状态对 Caretaker 提供窄接口一个几乎是空的接口只有标识信息不能查看内容原书把它命名为 MementoIF。Caretaker 只持有这个窄接口类型因此在编译期就调不出任何读写状态的方法Originator 拿回来时再强转成宽类型恢复完整能力。这套方案用接口隔离来实现发起人能看、管理者看不到。在 Java 中更自然的落地方式是内部类——把 Memento 作为 Originator 的静态内部类用访问控制替代接口隔离。Originator 作为外部类可以访问内部类的所有私有成员而外部包括 Caretaker只能声明 Memento 类型的变量无法调用其私有构造器和私有 getter。两者效果相同发起人可读写、管理者不可见但机制不同一个靠接口类型隔离一个靠成员访问控制。这样天然模拟了 C 中 friend 机制的效果下文示例都采用这种方式。基础实现定义 Originator 类内部持有 state 字段并提供 createMemento创建快照和 restoreFromMemento恢复快照方法。Memento 作为 Originator 的静态内部类构造器和 getter 均为 private——只有 Originator 能创建和读取。Caretaker 用 DequeOriginator.Memento 保存历史快照能引用 Memento 类型但看不到其内部状态。需要说明的是真正的封装靠的是成员 private构造器和 getter 都不对外开放而不是类本身的可见性。Memento 类可以是包级私有default防止其他包直接引用也可以是 public——只要成员是 privateCaretaker 就只能持有却无法打开。// Originator发起人class Originator {private String state;public void setState(String state) { this.state state; } public String getState() { return state; } // 创建备忘录只有自己能构造 Memento public Memento createMemento() { return new Memento(state); } // 恢复状态只有自己能读取 Memento 的私有字段 public void restoreFromMemento(Memento memento) { this.state memento.getState(); } // Memento备忘录静态内部类 static class Memento { private final String state; private Memento(String state) { // 私有构造只有 Originator 能创建 this.state state; } private String getState() { // 私有 getter只有 Originator 能读取 return state; } }}// Caretaker管理者class Caretaker {private final DequeOriginator.Memento history new ArrayDeque();public void save(Originator originator) { history.push(originator.createMemento()); } public void undo(Originator originator) { if (!history.isEmpty()) { originator.restoreFromMemento(history.pop()); } }}角色对照Originator发起人Originator持有 state提供 createMemento 和 restoreFromMementoMemento备忘录Originator.Memento 静态内部类构造器和 getter 均为 privateCaretaker管理者Caretaker持有 DequeOriginator.Memento负责保存和取出快照关键点Caretaker 能声明 Originator.Memento 类型的变量并保存到栈里但既不能自己构造它也不能读取它的 state 字段——Memento 对它是完全不透明的。Originator 作为外部类可以自由访问内部类的所有私有成员这是 Java 内部类的特权。引入一个具体场景文本编辑器的撤销/重做功能。用户每次编辑前先保存快照CtrlZ 恢复上一个快照CtrlY 重做被撤销的操作。编辑器的内部内容不应该被历史管理器直接访问。// Originator文本编辑器class TextEditor {private StringBuilder content new StringBuilder();public void write(String text) { content.append(text); } public void deleteLast(int n) { int start Math.max(0, content.length() - n); content.delete(start, content.length()); } public String getContent() { return content.toString(); } // 创建快照 public Snapshot save() { return new Snapshot(content.toString()); } // 恢复快照 public void restore(Snapshot snapshot) { content new StringBuilder(snapshot.getContent()); } // Memento编辑器快照内部类 static class Snapshot { private final String content; private Snapshot(String content) { this.content content; } private String getContent() { return content; } }}// Caretaker编辑历史管理器class EditorHistory {private final DequeTextEditor.Snapshot undoStack new ArrayDeque();private final DequeTextEditor.Snapshot redoStack new ArrayDeque();public void save(TextEditor editor) { undoStack.push(editor.save()); redoStack.clear(); // 新操作使重做历史失效 } public void undo(TextEditor editor) { if (!undoStack.isEmpty()) { redoStack.push(editor.save()); // 当前状态存入重做栈 editor.restore(undoStack.pop()); // 恢复到上一个快照 } } public void redo(TextEditor editor) { if (!redoStack.isEmpty()) { undoStack.push(editor.save()); // 当前状态存入撤销栈 editor.restore(redoStack.pop()); // 恢复到下一个快照 } }}角色对照Originator发起人TextEditor持有编辑器内容提供 save 和 restoreMemento备忘录TextEditor.Snapshot只保存 content 字符串Caretaker管理者EditorHistory同时维护撤销栈和重做栈关键点EditorHistory 不知道 TextEditor 内部存了什么——Snapshot 里的 content 对它完全隐藏。undo 和 redo 之前都先把当前状态保存到对方栈里这样可以在撤销和重做之间无损切换。每次新操作save清空 redoStack因为编辑历史已经分叉。⚠️ 深拷贝陷阱上面的例子里content.toString() 恰好是安全的——String 不可变快照存的字符串永远不会被后续修改影响。但这是个巧合一旦 Originator 的状态包含可变对象List、Map、数组、自定义可变类只做浅拷贝就会埋下 bug——快照和当前状态共享同一个引用恢复时恢复的是被改过的同一个对象。// ❌ 错误示范Memento 只存了 List 的引用class Document {private List lines new ArrayList();public void addLine(String line) { lines.add(line); } public Memento save() { return new Memento(lines); // 存的是引用不是副本 } public void restore(Memento m) { this.lines m.getLines(); } static class Memento { private final ListString lines; private Memento(ListString lines) { this.lines lines; } private ListString getLines() { return lines; } }}Document doc new Document();doc.addLine(“第一行”);Memento snap doc.save(); // 想快照当前的 [“第一行”]doc.addLine(“第二行”); // 继续编辑doc.restore(snap); // 期望恢复成 [“第一行”]// 实际结果[“第一行”, “第二行”] —— 快照里的 List 就是当前这个 List早被改花了根因是快照和发起人指向同一个可变对象。正确做法是存取两端都做深拷贝——不光 save() 时要拷一份进 Mementorestore() 时也要再拷一份出来否则恢复后 Originator 又和 Memento 内部的 List 共享了引用(这个快照如果还留在栈里,比如 redo 场景可能被复用,后续修改就会穿透回去,快照就不快了)public Memento save() {return new Memento(new ArrayList(lines)); // 存:拷一份副本进快照}public void restore(Memento m) {this.lines new ArrayList(m.getLines()); // 取:再拷一份出来,不共享引用}只有两端都深拷贝才能保证 Originator 和 Memento 永远不共享同一个可变对象。状态嵌套更深时如 MapString, List浅层 new ArrayList(…) 还不够——里层元素仍是共享引用需要逐层深拷贝。此时可以借助原型模式的 clone()或用序列化再反序列化Serializable ObjectOutputStream做一次性深拷贝。这也呼应了后文「备忘录 vs 原型」里两者互补的关系。一句话记忆Memento 存的是快照不是引用。只要字段可变创建备忘录时就必须深拷贝。总结本质在不破坏封装性的前提下捕获对象内部状态并支持稍后恢复到该状态。什么时候用需要实现撤销/重做CtrlZ、CtrlY功能需要在特定时机保存对象快照如游戏存档、事务保存点需要回滚到某个历史状态如工作流回退、审批撤回需要在对象状态复杂时避免让外部通过 getter/setter 挨个读写什么时候不用对象状态简单直接备份字段更清晰内存敏感的场景——每个快照都是完整状态副本大对象频繁快照会爆内存状态可以通过反向操作轻松撤销如加法→减法用命令模式更省内存状态变化不需要历史追溯只关心最新值内存管理策略备忘录最大的代价就是内存——每个快照都是一份完整副本。真实项目中常用以下手段控制开销限制历史栈深度Caretaker 设一个最大容量如只保留最近 50 步入栈超限时丢弃最老的快照可用 ArrayDeque 手动裁剪或 LinkedHashMap 的 LRU 淘汰增量/差量快照不存完整状态只存相对上一个快照的变化部分类似 Git 的 diff恢复时叠加回放。适合状态大、单次改动小的场景持久化到磁盘对可序列化的状态把较老的快照写到磁盘或数据库释放堆内存需要时再读回游戏存档、事务日志都是这个思路简单记忆状态存进保险箱箱子交给别人扛只有自己能打开历史随时能返场。相似模式区分总览模式 核心意图 典型场景备忘录 保存对象状态快照用于恢复 撤销/重做、游戏存档、事务回滚命令 将操作请求封装为对象可撤销/排队/记录 菜单命令、宏录制、事务日志原型 通过克隆现有对象创建新实例 对象创建成本高、避免重新初始化简单记忆备忘录管回到过去命令管执行逆操作原型管复制一份。备忘录 vs 命令两者都常用于实现撤销功能但本质不同维度 备忘录模式 命令模式核心意图 保存对象状态快照用于回滚 将操作请求封装为对象可执行/撤销结构差异 Memento 存状态数据Originator 创建和恢复 Command 存行为接收者execute/undoInvoker 调度关注点 封装内部状态外部不可见 解耦请求发送者与接收者典型场景 编辑器内容、游戏存档、事务回滚 菜单命令、宏、事务日志逐步区分法如果撤销时只需要恢复成原来的样子 → 选备忘录用状态回滚如果撤销时需要执行反向操作 → 选命令如加法的逆操作是减法如果状态数据量极大如大文本、图像 → 选命令更省内存只记录操作而非完整状态如果操作本身不可逆如发送邮件、扣款 → 只能用备忘录保存操作前的完整状态简单记忆口诀备忘录是时光倒流替换状态命令是撤销操作执行逆操作。推荐状态简单可以计算逆操作时用命令状态复杂或不可逆时用备忘录。两者也可以结合——命令内部持有备忘录撤销时用备忘录恢复状态。备忘录 vs 原型两者都涉及复制对象状态容易混淆维度 备忘录模式 原型模式核心意图 保存状态以备恢复时间维度 复制对象以创建新实例空间维度结构差异 Memento 是独立快照对象Originator 可读写它 Clone 对象是原型的副本各自独立演化关注点 封装——外部不应知道保存了什么 多态——无需知道具体类即可克隆典型场景 撤销/重做、事务回滚、游戏存档 对象创建成本高、需要大量相似对象逐步区分法如果需要把对象恢复到之前的状态 → 选备忘录如果需要基于现有对象创建独立的新对象 → 选原型如果既要保存又要新建 → 两者可以结合用原型 clone() 生成快照作为备忘录的内容简单记忆口诀备忘录问我过去是什么样时间回溯原型问给我一个一样的空间复制。推荐在 Java 中实现备忘录时创建 Memento 经常需要深拷贝 Originator 的状态——此时可以借用原型模式clone()辅助实现快照。两者不是互斥而是互补关系。练习题目计数器的撤销与重做题目描述小明正在设计一个计数器应用支持加 1Increment和减 1Decrement操作以及撤销Undo和重做Redo操作。请使用备忘录模式帮他实现。规则计数器初始值为 0每次执行 Increment 或 Decrement 前需保存当前状态快照到撤销栈并清空重做栈Undo 操作将当前状态保存到重做栈并从撤销栈恢复上一个状态Redo 操作将当前状态保存到撤销栈并从重做栈恢复下一个状态保证输入中 Undo 和 Redo 操作都是合法的撤销栈/重做栈不会为空输入描述输入包含若干行每行一个字符串表示操作Increment、Decrement、Undo、Redo。输出描述每行输出一个整数表示每次操作后计数器的当前值。输入示例IncrementIncrementDecrementUndoRedoUndoIncrementUndo输出示例12121232解题思路本题的核心是理解备忘录模式在撤销/重做中的作用——通过保存快照恢复快照实现状态回溯而不是通过执行逆操作。角色对应Originator发起人Counter持有 count 字段能创建和恢复自身的 MementoMemento备忘录Counter.Memento 内部类只保存一个 int 值Caretaker管理者CounterHistory同时维护撤销栈和重做栈核心逻辑Increment/Decrement 之前先 save同时清空重做栈——新操作让历史分叉Undo/Redo 都遵循同一个套路先把当前状态保存到对方栈再从当前栈弹出快照并恢复Memento 内部类的构造器和 getter 都是 privateCounterHistory 只能持有它但打不开import java.util.*;public class Main {public static void main(String[] args) {Scanner sc new Scanner(System.in);Counter counter new Counter(0);CounterHistory history new CounterHistory();while (sc.hasNext()) { String op sc.next(); switch (op) { case Increment: history.save(counter); counter.increment(); System.out.println(counter.getCount()); break; case Decrement: history.save(counter); counter.decrement(); System.out.println(counter.getCount()); break; case Undo: history.undo(counter); System.out.println(counter.getCount()); break; case Redo: history.redo(counter); System.out.println(counter.getCount()); break; } } }}// Originator计数器class Counter {private int count;public Counter(int count) { this.count count; } public void increment() { count; } public void decrement() { count--; } public int getCount() { return count; } public Memento save() { return new Memento(count); } public void restore(Memento memento) { this.count memento.getCount(); } // Memento备忘录内部类 static class Memento { private final int count; private Memento(int count) { // 私有构造只有 Counter 能创建 this.count count; } private int getCount() { // 私有 getter只有 Counter 能读取 return count; } }}// Caretaker历史管理器class CounterHistory {private DequeCounter.Memento undoStack new ArrayDeque();private DequeCounter.Memento redoStack new ArrayDeque();public void save(Counter counter) { undoStack.push(counter.save()); redoStack.clear(); // 新操作使重做历史失效 } public void undo(Counter counter) { redoStack.push(counter.save()); // 当前状态存入重做栈 counter.restore(undoStack.pop()); // 从撤销栈恢复上一个状态 } public void redo(Counter counter) { undoStack.push(counter.save()); // 当前状态存入撤销栈 counter.restore(redoStack.pop()); // 从重做栈恢复下一个状态 }}扩展实际项目中的备忘录模式javax.swing.undo 撤销框架JDK 的 javax.swing.undo 包是备忘录模式在 Java 标准库中最典型的应用UndoableEdit 承担快照角色UndoManager 充当 Caretaker管理器不关心每个编辑对象内部存了什么只负责按栈顺序调用它们的 undo 和 redo 方法。做过 Swing 富文本编辑器的开发者会立刻认出这个套路——所有 JTextArea、JEditorPane 的撤销功能底层都是它。严格来说UndoableEdit 是 Command 与 Memento 的混合体它同时存了 oldValue/newValue状态快照Memento 的职责又封装了 undo()/redo() 的操作行为Command 的职责undo 执行逆操作。这正是前文「备忘录 vs 命令」里推荐过的组合用法——命令内部持有备忘录undo 时用保存的旧状态恢复现场。所以不必纠结它到底算哪个模式它本就是两者的落地融合。UndoManager undoManager new UndoManager();class MyEdit extends AbstractUndoableEdit {private String oldValue, newValue;private JTextField target;public MyEdit(JTextField target, String oldValue, String newValue) { this.target target; this.oldValue oldValue; this.newValue newValue; } public void undo() throws CannotUndoException { super.undo(); target.setText(oldValue); // 恢复到 oldValue —— Memento 恢复 } public void redo() throws CannotRedoException { super.redo(); target.setText(newValue); }}undoManager.addEdit(new MyEdit(textField, oldValue, newValue));undoManager.undo(); // 委托给最近一个 Memento数据库事务保存点JDBC 的 Savepoint 本质上是备忘录模式在数据库层面的实现——事务执行到某一点时创建快照出错时可回滚到该点而非整个事务。Connection 是 Originator状态持有者Savepoint 是 Memento应用代码只能拿到 Savepoint 对象但完全无法窥探其内部内容——所有细节由数据库内部管理。银行转账、多步骤订单创建这类需要部分回滚的场景都靠 Savepoint 撑起。conn.setAutoCommit(false);// 执行第一批操作…Savepoint afterOrder conn.setSavepoint(“after_order”); // 创建快照// 执行第二批操作…if (库存不足) {conn.rollback(afterOrder); // 恢复到保存点}conn.commit();Hibernate 脏检查中的快照Hibernate 在加载实体时会保存一份原始状态快照事务提交时比较当前状态和快照判断哪些字段被修改脏检查从而只生成必要的 UPDATE 语句。这个原始状态快照借用了备忘录的状态捕获 封装机制——快照存在 Hibernate 内部应用代码完全感知不到它的存在。但要说明的是:标准备忘录的核心意图是把对象恢复回先前状态(方向向后),而 Hibernate 快照的用途是差异比较(方向向前:决定写哪些 UPDATE),并不把实体回滚成旧值。所以它更准确地说是备忘录模式的一个变体应用——共享了不破坏封装地捕获内部状态这一子原理,但目的不同,GoF 原书也并未覆盖这个场景。用过 Entity 的开发者其实每天都在享受这个机制带来的好处。// Hibernate 内部原理示意Object[] snapshot copyOf(entity.getPersistedState()); // 加载时创建快照// … 业务代码修改 entity …Object[] current entity.getPersistedState();for (int i 0; i current.length; i) {if (!Objects.equals(current[i], snapshot[i])) {// 该字段被修改加入 UPDATE 语句}}游戏存档系统游戏需要支持存档save和读档load每个存档记录游戏在某一时刻的完整状态——玩家等级、血量、装备、任务进度等。存档对象Memento对存档管理器完全封闭可以序列化到磁盘长期保存。Steam 云存档、单机游戏的 F5 快速存档、模拟器状态存档本质都是这套结构。class Game {private int level, health, score;public GameSave save() { return new GameSave(level, health, score); } public void load(GameSave save) { // 外部类 Game 可直接访问内部类 GameSave 的 private 字段——这是 Java 内部类的特权 this.level save.level; this.health save.health; this.score save.score; } // 内部类存档对象字段全 private final static class GameSave implements Serializable { private final int level, health, score; private GameSave(int level, int health, int score) { this.level level; this.health health; this.score score; } }}表单草稿保存与恢复Web 表单场景中用户填写多步表单时可暂存草稿稍后回来继续。草稿管理器只按用户 ID 存取草稿对象完全不知道表单里有哪些字段。加了字段、改了字段名管理器代码一行都不用改——这就是备忘录封装带来的可维护性红利。不管是招聘系统的简历填写、电商的收货地址、还是政务系统的申请表遇到稍后继续都能靠这个套路解决。class RegistrationForm {private String username, email, phone;public FormDraft saveDraft() { return new FormDraft(username, email, phone); } public void restoreDraft(FormDraft draft) { this.username draft.getUsername(); this.email draft.getEmail(); this.phone draft.getPhone(); } static class FormDraft { private final String username, email, phone; private FormDraft(String username, String email, String phone) { this.username username; this.email email; this.phone phone; } private String getUsername() { return username; } private String getEmail() { return email; } private String getPhone() { return phone; } }}现在可能还用不到这些但等到需要撤销、需要存档、需要事务回滚的时候会突然发现“这不就是备忘录模式吗”——那时候就真的懂了。多对象协调快照前面所有示例都是单个 Originator 的单对象快照。但实际项目里常需要多个对象在同一时刻做协调快照——比如下单场景要同时保存「订单 库存 支付」三者的状态形成一个统一的保存点。如果各存各的、恢复时机不一致就可能回滚到订单已扣、库存未扣这种中间不一致态。做法是把多个 Memento 打包成一个复合备忘录Composite MementoCaretaker 在同一时刻为每个 Originator 调用 save把结果一起封装恢复时按相同顺序全部 restore保证要么一起回到过去要么都不动。// 复合备忘录打包多个对象的快照class CompositeMemento {private final Order.Memento orderMemento;private final Inventory.Memento inventoryMemento;private final Payment.Memento paymentMemento;CompositeMemento(Order.Memento o, Inventory.Memento i, Payment.Memento p) { this.orderMemento o; this.inventoryMemento i; this.paymentMemento p; } // 各字段的 getter 省略 ...}// Caretaker协调多个 Originator 的统一保存点class TransactionCoordinator {private final Deque savepoints new ArrayDeque();// 同一时刻为三者一起打快照 public void save(Order order, Inventory inventory, Payment payment) { savepoints.push(new CompositeMemento( order.save(), inventory.save(), payment.save())); } // 恢复时按相同顺序全部 restore保证一致性 public void rollback(Order order, Inventory inventory, Payment payment) { if (!savepoints.isEmpty()) { CompositeMemento cm savepoints.pop(); order.restore(cm.getOrderMemento()); inventory.restore(cm.getInventoryMemento()); payment.restore(cm.getPaymentMemento()); } }}这其实就是数据库事务保存点在应用层的手写版——Savepoint 之所以能部分回滚一整批操作靠的正是同一时刻的一致性快照。多对象协调快照的难点不在模式本身而在恢复的原子性如果 restore 到一半失败就需要额外的补偿机制或干脆整体不可变、全部替换来避免恢复过程本身制造出新的中间态。