“设计模式”这四个字,几乎每个做过技术面试的人都绕不过去。而“备忘录模式”通常是被讲得最潦草的一个,因为教科书里翻来覆去就是游戏存档、文本编辑器撤销,看起来简单到有点无聊。我以前也这么觉得,直到有一次在业务系统里处理一个复杂的订单模型需要做版本回滚,才真正意识到备忘录模式的价值不在于“能拍快照”,而在于“怎么在不破坏封装的前提下,把状态安全地交出去”。这篇就围绕备忘录模式,从思想拆解、完整代码、常见坑点到面试答题方向,一次讲透。
这篇内容适合几类人:正在准备设计模式面试的人、要交设计模式大作业的学生、在写编辑器/工作流/游戏状态之类的需要状态回滚功能的开发者。我会尽量把“为什么这么设计”讲清楚,而不是停留在“三个角色、两个方法”的背诵层面。
1. 为什么需要备忘录模式:被低估的状态管理价值
1.1 没有备忘录时,状态回滚是怎么被玩坏的
先想象一个很常见的场景:用户在表单里填了二十几个字段,中途点了一下“恢复上一步”,结果整个页面重置成初始值。这种体验很糟糕,但代码层更糟糕——如果把所有状态字段直接暴露成 public,让调用方想怎么改就怎么改,那确实可以实现“覆盖保存”,但代价是对象内部结构完全暴露在外部,任何人改状态都不经过校验,封装等于直接被拆穿。
另一类做法是给每个字段写一堆 getter/setter,然后外部手工保存若干字段值,需要回滚时再按字段逐一赋值。这类代码在字段少的时候能用,一但字段超过十个、还有嵌套对象和列表结构,代码就会被 setter 调用淹没,而且每次新增一个字段就要同步修改保存/恢复逻辑,漏改一个就出 bug。更麻烦的是,外部根本不知道该在什么时机调用这些方法,状态一致性完全靠调用方自觉。
真实项目里,我还见过用 JSON 序列化整个对象来“拷贝状态”的方案。有效是有效,但 JSON 里塞了字段别名、冗余信息、循环引用,序列化成本高不说,反序列化还可能因为类结构变化直接挂掉。这类问题不是小概率事件,而是只要你做长期维护,就一定会撞上。
1.2 备忘录模式的核心思想:窄接口 + 状态托管
备忘录模式要解决的问题,就是“你有一个对象,状态需要备份和恢复,但你又不想让人随便碰内部数据”。它的核心是三个角色的分工:发起人(Originator)负责生成状态快照和恢复状态,备忘录(Memento)负责保存快照数据,管理者(Caretaker)只负责保管备忘录,不改里面的内容。
这里的关键词是“窄接口”。Caretaker 拿到 Memento 之后,只能把它当一个“黑盒”存起来,不能读取或修改内部状态。真正能访问 Memento 内部数据的只有 Originator。这样一来,外部看不到状态细节,内部又能随意存取,封装性和可恢复性同时保住。
打个比方:Originator 是家里主人,Memento 是一个密封保险箱,Caretaker 是帮你保管保险箱的仓库管理员。管理员只负责收箱子、发箱子,他没有钥匙,也打不开箱子。主人自己存箱子时往里面放进自己的文件,拿到箱子时用钥匙开箱取回文件。整个过程,仓库管理员完全不知道文件内容,但保管动作本身很清晰,这就是备忘录模式的结构。
这个思想很容易被低估,因为从“能跑”的角度看,用 public 字段加保存函数也可以跑。但一旦项目变成多人协作,有人不小心直接更改了扩展对象的内部字段,或业务逻辑要求状态只在特定时机生效,简陋方案会迅速变成维护噩梦。备忘录模式的核心价值,就是用一个小结构把“备份/恢复”这个横切逻辑隔离出来,让后续维护有清晰边界。
2. 备忘录模式的结构拆解与编码要点
2.1 三个角色要分清:Originator / Memento / Caretaker
老规矩,先看角色的职责边界。很多人一开始把三个角色搞混,甚至把 Memento 和 Caretaker 合成一个类,这样后续扩展就很别扭。我整理过一个简单表格,方便记忆:
| 角色 | 职责 | 关键点 |
|---|---|---|
| Originator(发起人) | 负责创建 Memento 保存当前状态,也负责接收 Memento 恢复状态 | 知道状态细节,拥有状态校验逻辑 |
| Memento(备忘录) | 保存 Originator 某一时刻的状态快照 | 对外部尽可能封闭,只对 Originator 开放访问 |
| Caretaker(管理者) | 持有 Memento,负责触发保存/恢复 | 不知道 Memento 内容,只负责存取和生命周期管理 |
Java 里最常见的实现方式,是把 Memento 作为 Originator 的内部类。内部类天然能访问外部类的私有字段,而且可以在类内部把 Memento 的字段设为 private,只允许同文件或同包访问。这样外部连 getState() 都摸不到,只能把它当对象传递。
我见过不少教程把 Memento 单独抽成一个顶层类,Memento 里存一份 public 的 state 字段,然后 Caretaker 也能直接读。这种写法方便是方便,但模式的意义就少了一半。真的要体现封装,尽量用嵌套类加私有访问修饰符。C++ 里则可以用 friend 声明,Java 里没有 friend 关键字,用嵌套类是最接近的方案。
2.2 宽接口与窄接口的博弈,理解“封装保护”是模式灵魂
备忘录模式有一个不太容易从代码里看出来的设计哲学:接口的“宽”和“窄”是相对的。对 Originator 而言,Memento 的接口应该是宽的,也就是可以读取全部状态;对 Caretaker 而言,Memento 的接口应该是窄的,只能“持有”,不能“查看”。
Java 中实现这一点有几条路线。一是把 Memento 写成 Originator 的 private 静态内部类,Memento 字段全部 private,构造方法只允许 Originator 调用。二是给 Memento 开“包私有”的 getter/setter,把 Originator、Caretaker、Memento 放进同一个 package,Caretaker 不在这个包里,自然碰不到内部方法。三是用接口遮罩,比如定义一个空的 MementoIFace 接口,Caretaker 只持有这个接口类型,Originator 内部使用具体类。
第三种方案在跨包或者需要序列化的场景更实用。比如 Android 里要跨进程传快照,或者你用 JSON 序列化备忘录,空接口实现起来反而更灵活。不过要记住,接口遮罩在 Java 里是编译期约束,运行期如果 Caretaker 把接口强转成具体类,还是能突破封装。真正的安全性靠约定,不是靠语法。
这里也解释一下为什么大部分教科书首选嵌套类:因为它把“谁可以访问”直接写进了语言机制,约束力最强。最开始的方案用嵌套类,后面要扩展跨包传输时再引入接口遮罩,是一条很自然的演进路径。
3. 实操:用一个可逆文本编辑器打通全流程
3.1 第一版:单次撤销的最简实现
我先从最经典的编辑器例子讲起。假设我们有一个 TextEditor 类,保存了当前文本内容和光标位置,现在需要支持一步撤销。最基础的实现是这样的:
public class TextEditor { private String text; private int cursorPos; public TextEditor(String text, int cursorPos) { this.text = text; this.cursorPos = cursorPos; } // 创建备忘录:保存当前状态 public Memento save() { return new Memento(text, cursorPos); } // 从备忘录恢复状态 public void restore(Memento memento) { this.text = memento.text; this.cursorPos = memento.cursorPos; } public void write(String words) { this.text = this.text + words; this.cursorPos = this.text.length(); } public void setCursor(int pos) { this.cursorPos = pos; } @Override public String toString() { return "TextEditor{text='" + text + "', cursorPos=" + cursorPos + '}'; } // 嵌套类作为备忘录 public static class Memento { private final String text; private final int cursorPos; private Memento(String text, int cursorPos) { this.text = text; this.cursorPos = cursorPos; } } }Caretaker 这一侧非常简单,只需要一个 Memento 变量用于保存和取回:
public class EditorCaretaker { private TextEditor.Memento backup; public void backup(TextEditor editor) { backup = editor.save(); } public void undo(TextEditor editor) { if (backup != null) { editor.restore(backup); } } }如果只是单次撤销,这段代码已经够了。但日常应用很少只回退一步,所以大多数时候我们要把“单快照”换成“历史栈”。
3.2 第二版:无限历史 + 容量限制
编辑器要支持多步撤销,最简单的方式是把 Memento 存到栈里。每次 save 时 push,undo 时 pop。栈天然是先进后出,正好符合用户“撤回刚才几步”的直觉。
import java.util.ArrayDeque; import java.util.Deque; public class HistoryCaretaker { private Deque<TextEditor.Memento> history = new ArrayDeque<>(); private static final int MAX_HISTORY = 50; public void backup(TextEditor editor) { history.push(editor.save()); // 超出容量时移除最老的记录 if (history.size() > MAX_HISTORY) { history.removeLast(); } } public boolean canUndo() { return !history.isEmpty(); } public void undo(TextEditor editor) { if (!history.isEmpty()) { editor.restore(history.pop()); } } }这段代码里我加了一个 MAX_HISTORY 限制,因为真实编辑器不可能无限保存快照。内存是有限资源,特别是对象结构复杂时,一个快照可能占几 MB。限制历史深度之外,还可以按快照大小动态决定最多保存几条;早期版本我用固定条数,后来发现某个业务对象快照特别大,50 条就把内存吃满了,改成“总大小上限”更合理。
这块容量限制是很多教程不会讲的点,但恰恰是生产环境最实用的增强。面试时主动提出来,效果会比单纯背定义好很多。
3.3 第三版:增量备份,避免大对象内存爆炸
前面提到的快照是“全量快照”,每次 save 都把整个 text 和 cursorPos 复制一遍。如果对象很大,比如一个文档包含几百个段落和复杂格式,还要保存所有图片索引,全量快照的成本就很可观。
那有没有办法只保存变化的部分?有,两个方向。第一个方向是保存“操作命令”而不是保存“状态结果”,比如你输入了字符“a”,撤销时只要删除末尾一个字符即可。这就是命令模式跟备忘录模式结合的玩法:不记录完整快照,记录一个逆操作,重放时反向执行。第二个方向是保存“差异数据”,对比当前状态和上一次状态的差量,恢复时回放差量。
我实际处理过一个大型文档模型,最终方案是折中状态:每次操作都把“操作类型、涉及区域、操作前后的少量关键数据”封装成 Delta,历史栈里只存 Delta。节点替换时,只有当 Delta 累积到一定阈值,才生成一次全量快照作为 checkpoint。这样内存占用从 O(n) 降到接近 O(变更量),恢复速度也有保证。下面是简化版的 Delta 设计思路:
public class TextDelta { enum OpType { INSERT, DELETE, REPLACE } private OpType type; private int start; private String deletedText; private String insertedText; // 恢复逻辑:根据操作类型逆向执行 public void applyBackward(TextEditor editor) { switch (type) { case INSERT: editor.deleteRange(start, insertedText.length()); break; case DELETE: editor.insert(start, deletedText); break; case REPLACE: editor.replace(start, deletedText); break; } } }增量备份的核心思路是“不存完整快照,存变化轨迹”。它的前提是操作类型足够明确,并且每一步都有办法反推。如果要回滚到很远的某个版本,Delta 链太长会导致恢复很慢,所以必须配合定期 checkpoint。这是我建议生产级项目采用的方案,也是把备忘录模式用活的进阶姿势。
4. 备忘录模式的高频坑点与排查手册
4.1 深拷贝与浅拷贝,状态泄漏是怎么发生的
备忘录最常见的一个坑,就是只拷了引用,没拷内容。Java 的 String 是不可变对象,赋值给 Memento 没有安全问题;但如果状态里有 List、Map、自定义对象,直接赋值就等于让 Memento 和 Originator 共享同一块堆内存。保存快照后,Originator 继续修改列表内容,Memento 里存的“快照”也跟着变了。等到恢复时发现,所谓的快照根本不是当时的状态。
这个问题在画图类软件里尤其致命:你画了一条线,然后继续画第二条,结果撤销时发现两条线还在。排查了半天,发现 Memento 里保存的 Line 对象和当前画布的 Line 是同一个引用。
解决办法很简单:在创建快照时做防御性拷贝。List 要新建 ArrayList 再 addAll,Map 要新建 HashMap 再 putAll,嵌套对象要实现克隆或者手写拷贝构造函数。这里不推荐用 List.copyOf 之类的不可变集合,因为如果内部元素本身是可变对象,列表复制了但元素还是同一个引用,深层修改照样影响快照。要彻底解决,必须递归深拷贝,或者干脆对 Memento 做序列化拷贝。
4.2 序列化方案的陷阱
用 Java 原生序列化(实现 Serializable)来生成 Memento,是很多项目会选的路。它上手快,深拷贝也顺手。但这里有两个常见坑。
第一个坑是类结构升级。给类加一个字段、删一个字段,老版本序列化的数据反序列化时可能抛 InvalidClassException,因为它依赖 serialVersionUID。不显式声明 serialVersionUID,编译器会自动生成,一旦类结构变化,这个值就会变,反序列化直接失败。所以序列化备忘录一定要显式声明 serialVersionUID,并做好兼容迁移。
第二个坑是安全边界。序列化会把对象图整个铺开,如果对象里有临时文件句柄、数据库连接、线程池,这些字段不能序列化,需要标记为 transient。否则轻则序列化失败,重则把连接信息泄露到磁盘。我有一次排查线上问题,发现 Memento 文件里居然包含了数据库连接串,就是忘了加 transient。
4.3 命令模式配合与撤销重做(redo)实现
很多文档把备忘录模式和命令模式当作两个独立模式讲,但在真正的编辑器/IDE 里,它们通常是搭配出现的。命令模式处理“操作意图”,备忘录模式处理“状态捕获”,两者各管一段。
如果要实现 redo,有一个很简单的套路:维护 undo 栈和 redo 栈。undo 时把被弹出的 Memento 推到 redo 栈,redo 时再从 redo 栈弹回 undo 栈。
public class UndoRedoManager { private Deque<TextEditor.Memento> undoStack = new ArrayDeque<>(); private Deque<TextEditor.Memento> redoStack = new ArrayDeque<>(); public void commit(TextEditor editor) { undoStack.push(editor.save()); redoStack.clear(); // 新操作会清空 redo 历史 } 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()); } } }这里有个小细节注意:新操作产生后,redo 栈必须清空,否则会出现“顺序错乱”的迷惑行为。很多刚入门的开发者忘记这一步,导致用户撤销后再做新编辑,点重做居然恢复到几秒前的状态,很容易被当成 bug 提上来。
5. 从面试题到大型框架:备忘录模式的答题与扩展方向
5.1 面试中怎么把“背概念”变成“讲设计”
这里分享一点我的面试经验。面试官问“讲讲备忘录模式”时,如果只答“三个角色,Originator 创建备忘录,Caretaker 保存它”,基本只能拿及格分。拉开差距的,是你能讲到下面几个层次:
- 第一层:能说出三个角色和基本代码流程。
- 第二层:能说清楚为什么要用窄接口,封装保护的意义是什么。
- 第三层:能结合真实项目,说明全量快照和增量 Delta 的取舍、容量限制策略、深拷贝深浅坑点。
- 第四层:能把手头的业务问题抽象成“状态需要被安全地保存和恢复”,然后自然引出模式,而不是为了用模式而硬套。
我现在面试别人时,最反感听到的就是“这个模式用在编辑器里”。编辑器只是例子,模式的生命力在于它解决了一类问题:“如何在保持封装的前提下保存和恢复对象状态”。你工作中写个配置中心、做游戏背包系统、实现动态表单草稿箱,本质上都是这个模式的应用场景。
5.2 一点对现代应用场景的观察:状态快照思想正在走出传统业务
最近大家讨论多 agent 系统时经常提到主从模式和 subagent 调度,很多人会把 subagent 看作一种可替代的工具调用。这跟备忘录模式有什么关系?其实关系在于:一旦系统里出现了可回退、可恢复的需求——比如一次多步骤工作流执行到一半要回滚之前某一步的状态,那么每个步骤的“状态快照”就成了必需品。这里用到的底层思维,和备忘录模式完全一致:把状态从执行逻辑中剥离出来,独立保存、独立恢复。
我最近在一个自动化工作流引擎里,就用类似的思路给每个任务节点设计了“执行快照”。任务失败时,引擎能从快照恢复环境变量和上下文,而不是整个流程从头再来。它没有照搬 Memento 的类名,但核心思想是一脉相承的。我在实际使用中发现,真正成熟的方案往往不是原样套用某个模式代码,而是把模式背后的思想拆出来,融入自己的系统设计,这才是设计模式在大型项目里最有价值的打开方式。
最后分享一个小技巧:写备忘录模式时,别急着写 Caretaker,先把 Originator 的状态字段理清楚,把所有需要保存的状态集中到一个方法里创建快照。这么做的好处是,以后新增状态字段时,只需要改一个 createMemento 方法,不会漏。多次踩过坑后,我越来越觉得,设计模式的代码写得好不好,就看边界清不清晰、状态逻辑集中不集中,这两点做到了,模式自然就落地了。