CS-Notes 设计模式精讲:备忘录(Memento)模式——不破坏封装的状态备份与恢复实战
备忘录(Memento)模式是 GoF 行为型设计模式之一,其核心价值在于在不暴露对象内部结构的前提下,把对象的内部状态"快照"保存下来,并在需要时准确还原。本文以 CS-Notes 仓库中 设计模式 - 备忘录.md 为主干,完整剖析模式的角色划分、双重接口设计,并结合一个可运行的计算器实例,逐行讲解状态备份(backup)、状态破坏、状态恢复(restore)的完整调用链,最后给出其在 JDK 中的代表实现与典型应用场景。读完本文,你将能独立实现一套兼顾封装性与可回滚性的状态快照机制。
备忘录模式在 CS-Notes 中被归入行为型模式,与命令模式、迭代器等并列,位于设计模式目录。本文对应的详细代码与类图均整理在 备忘录模式文档 中。
一、模式意图(Intent)
按照 CS-Notes 中的定义,备忘录模式的意图是:
在不违反封装的情况下获得对象的内部状态,从而在需要时可以将对象恢复到最初状态。
这两句话各自对应一个关键约束:
- 获得内部状态但"不违反封装":不能为了让外部能保存状态,就把 Originator(原始对象)的内部字段全部改成 public 或提供一堆 getter/setter。状态仍由 Originator 独占访问,外部只拿到一个"不透明"的快照对象。
- 支持恢复:系统运行中可能发生异常输入、误操作、流程回退等,需要把对象"拉回"到此前某个时刻的状态。
一句话概括:备忘录负责保存状态的"复印本",但真正读写这份状态的钥匙只掌握在状态的主人(Originator)手里。
二、角色划分与类图(Class Diagram)
2.1 三个核心角色
CS-Notes 将备忘录模式的参与者拆成三类:
| 角色 | 说明 | 职责 |
|---|---|---|
| Originator | 原始对象(原发器) | 持有真正的内部状态;负责创建备忘录与从备忘录恢复状态 |
| Caretaker | 管理者(负责人) | 负责保存好备忘录,需要回滚时把备忘录交还给 Originator;它不读、也不改备忘录内容 |
| Memento | 备忘录 | 存储原始对象的状态快照,是被保存与传递的载体 |
2.2 备忘录的双重接口(窄接口 / 宽接口)
备忘录(Memento)是整张类图中最精巧的设计。CS-Notes 明确指出,备忘录实际上对外暴露了两个接口:
- 提供给 Caretaker 的窄接口(Narrow Interface):Caretaker 只能持有备忘录并把备忘录传递给其它对象,不能访问其中的任何状态数据。典型形态就是一个空接口(标记接口),如代码中的
PreviousCalculationToCareTaker; - 提供给 Originator 的宽接口(Wide Interface):Originator 可以通过它访问到先前状态所需的所有数据,如代码中的
PreviousCalculationToOriginator暴露getFirstNumber()/getSecondNumber()。
理想情况下,只允许 Originator 访问备忘录的内部状态——窄接口在编译期就约束了 Caretaker 的行为边界,使"管理者能存能传但摸不到内容"成为类型系统强制保证的规则,而不是靠口头约定。
2.3 类图
备忘录模式的类图如下,Originator 通过 createMemento() 生成备忘录、通过 setMemento() 恢复状态;Caretaker 仅负责聚合保存 Memento:
图中值得注意的关联关系是:Originator 与 Memento 之间存在单向双向使用的依赖(创建 + 恢复),而 Caretaker 对 Memento 只是聚合持有,Caretaker 一侧没有任何读状态的方法入口——这正是"窄接口"在结构上的体现。
三、完整实例:可撤销的计算器(Implementation)
3.1 场景说明
CS-Notes 采用一个经典的计算器示例来演示备忘录模式(思路参考 oodesign.com 的 Calculator 示例,文档中保留了原引用)。该程序可以输入两个数值并求它们的和;通过备忘录模式,可以把这两个加数"存储"起来,然后在某个时刻用存储的状态进行恢复——模拟用户按 CTRL + Z 撤销误操作。
整体调用关系是:
- Originator(接口)
Calculator:定义备份与恢复的对外操作; - Originator(实现)
CalculatorImp:持有两个加数的真实状态; - Memento 的宽接口
PreviousCalculationToOriginator:仅供 Originator 读取已存状态; - Memento 的窄接口
PreviousCalculationToCareTaker:仅供 Caretaker 传递,无任何操作; - Memento 实现
PreviousCalculationImp:同时实现宽、窄两个接口; - Caretaker
Client:保存备忘录并在出错时交还恢复。
3.2 Originator 接口与实现
① Originator 接口 Calculator——它定义了原发器对外的两个核心动作:backupLastCalculation() 创建并返回一份备忘录;restorePreviousCalculation() 接收备忘录并据此恢复状态:
/**
* Originator Interface
*/
public interface Calculator {
// Create Memento
PreviousCalculationToCareTaker backupLastCalculation();
// setMemento
void restorePreviousCalculation(PreviousCalculationToCareTaker memento);
int getCalculationResult();
void setFirstNumber(int firstNumber);
void setSecondNumber(int secondNumber);
}
注意一个细节:从接口签名上,backupLastCalculation() 返回给调用方的类型是窄接口 PreviousCalculationToCareTaker,也就是调用方(Caretaker)拿到的永远是一把"只能传、不能读"的锁。真正能读取数据的方法在宽接口 PreviousCalculationToOriginator 中,而这需要向下转型才能访问——这个转型动作只会发生在 Originator 自己的恢复逻辑里。
② Originator 实现 CalculatorImp——firstNumber 与 secondNumber 是它的私有内部状态:
/**
* Originator Implementation
*/
public class CalculatorImp implements Calculator {
private int firstNumber;
private int secondNumber;
@Override
public PreviousCalculationToCareTaker backupLastCalculation() {
// create a memento object used for restoring two numbers
return new PreviousCalculationImp(firstNumber, secondNumber);
}
@Override
public void restorePreviousCalculation(PreviousCalculationToCareTaker memento) {
this.firstNumber = ((PreviousCalculationToOriginator) memento).getFirstNumber();
this.secondNumber = ((PreviousCalculationToOriginator) memento).getSecondNumber();
}
@Override
public int getCalculationResult() {
// result is adding two numbers
return firstNumber + secondNumber;
}
@Override
public void setFirstNumber(int firstNumber) {
this.firstNumber = firstNumber;
}
@Override
public void setSecondNumber(int secondNumber) {
this.secondNumber = secondNumber;
}
}
这段代码是"封装不破坏"的核心证据:
- 备份:
backupLastCalculation()把当前私有字段的值拷入一个新PreviousCalculationImp返回——拷贝而非引用共享,后续即使继续修改firstNumber/secondNumber,备忘录里存的仍是旧值; - 恢复:
restorePreviousCalculation()先把参数从窄接口向下转型为宽接口PreviousCalculationToOriginator,再读取快照值回填私有字段。宽接口的存在让"只有 Originator 能读取备忘录内部数据"成为可能。
3.3 备忘录的双接口与实现类
① 提供给 Originator 的宽接口:
/**
* Memento Interface to Originator
*
* This interface allows the originator to restore its state
*/
public interface PreviousCalculationToOriginator {
int getFirstNumber();
int getSecondNumber();
}
② 提供给 Caretaker 的窄接口——注意它是一个空接口,一个方法都没有:
/**
* Memento interface to CalculatorOperator (Caretaker)
*/
public interface PreviousCalculationToCareTaker {
// no operations permitted for the caretaker
}
空接口本身就是一种"设计约束声明":Caretaker 对备忘录的操作权限被限制为零,它只能把备忘录当作一个不透明的传阅物件。
③ 备忘录实现类——同时实现宽、窄两个接口:
/**
* Memento Object Implementation
* <p>
* Note that this object implements both interfaces to Originator and CareTaker
*/
public class PreviousCalculationImp implements PreviousCalculationToCareTaker,
PreviousCalculationToOriginator {
private int firstNumber;
private int secondNumber;
public PreviousCalculationImp(int firstNumber, int secondNumber) {
this.firstNumber = firstNumber;
this.secondNumber = secondNumber;
}
@Override
public int getFirstNumber() {
return firstNumber;
}
@Override
public int getSecondNumber() {
return secondNumber;
}
}
PreviousCalculationImp 中真正存放状态快照的字段仍然是 private 的,读取动作必须经由宽接口完成;而它同时 implements 两个接口,意味着同一对象在不同的调用方视角下呈现出不同的能力面——传给 Caretaker 时是"无法访问"的窄接口,只有交回到 Originator 手里、转型为宽接口后才能读到数据。
3.4 Caretaker(管理者)客户端
Client 在这里扮演 Caretaker:它驱动整个流程——输入数据、计算结果、在算出错误结果前保存备份、再次输入错误数据触发"事故"、最后用备份执行撤销:
/**
* CareTaker object
*/
public class Client {
public static void main(String[] args) {
// program starts
Calculator calculator = new CalculatorImp();
// assume user enters two numbers
calculator.setFirstNumber(10);
calculator.setSecondNumber(100);
// find result
System.out.println(calculator.getCalculationResult());
// Store result of this calculation in case of error
PreviousCalculationToCareTaker memento = calculator.backupLastCalculation();
// user enters a number
calculator.setFirstNumber(17);
// user enters a wrong second number and calculates result
calculator.setSecondNumber(-290);
// calculate result
System.out.println(calculator.getCalculationResult());
// user hits CTRL + Z to undo last operation and see last result
calculator.restorePreviousCalculation(memento);
// result restored
System.out.println(calculator.getCalculationResult());
}
}
3.5 运行结果与逐步推演
程序三段输出的预期结果如下:
110
-273
110
对应的时间线推演:
| 步骤 | 状态(first, second) | 结果 | 说明 |
|---|---|---|---|
| 输入 10、100 | (10, 100) | 110 |
第一次正常求和 |
调用 backupLastCalculation() |
— | — | 快照 (10, 100) 被存入备忘录,交予 Caretaker 保管 |
| 输入 17 | (17, 100) | — | 覆盖第一个加数 |
| 输入错误的 -290 | (17, -290) | -273 |
计算出错误结果,触发撤销需求 |
调用 restorePreviousCalculation(memento) |
(10, 100) | — | 从备忘录恢复旧状态 |
| 再次求和 | (10, 100) | 110 |
撤销成功,回到最初状态 |
从类型流转看,Caretaker 全程只持有 PreviousCalculationToCareTaker(窄接口)引用,执行恢复时把它原样交回给 Calculator,由 CalculatorImp 内部完成向下转型与数据读取——管理者"经手"了快照却从未看到内容,这就是备忘录模式用类型系统兑现封装承诺的完整闭环。
四、备忘录模式的 JDK 体现
CS-Notes 在备忘录一节末尾给出了它在 JDK 中的代表:java.io.Serializable。
java.io.Serializable 是一个典型的标记接口,它的工作方式与备忘录思想高度同构:
- 一个对象实现
Serializable后,其整个内部对象图状态可以被序列化机制完整捕获(即"打快照"),随后可以反序列化还原出一份状态一致的新对象——这与备忘录"保存状态快照、随后恢复"的语义一致; - 序列化/反序列化本质上允许对象在"保存状态"与"恢复状态"之间往返,是 Java 生态中实现跨进程、跨时间状态备份的基础设施;
- 其中标注为
transient的字段不会进入快照,这与备忘录只保存"需要恢复的那部分状态"的思路同理——状态快照的内容是可以按需裁剪的。
需要说明的是,与经典备忘录模式相比,Serializable 走的是"整图自动快照"路线,且反序列化通常得到新对象而非原地恢复既有对象;但它为"如何完整保存对象内部状态而不破坏封装"提供了 JDK 层面的标准答案,因此在仓库笔记中被作为备忘录模式的 JDK 例证列出。
五、适用场景与设计要点
5.1 典型应用场景
备忘录模式天然适用于以下需求(均由"可撤销、可回滚、可恢复"这一共性驱动):
- 编辑器 / IDE 的撤销(Undo)与重做(Redo):如示例注释中的
CTRL + Z,每次操作前保存一份快照到历史栈中,栈顶出栈即可回退; - 表单向导 / 多步流程的回退:用户在"上一步 / 下一步"之间切换时,需要保留每步填写的中间状态;
- 事务与长任务的中断恢复:处理到一半失败时,把参与者恢复到任务开始(或最近检查点)的状态;
- 游戏存档 / 应用检查点(Checkpoint):定期把进度状态落盘,崩溃后可从此前检查点继续。
5.2 需要警惕的代价
- 快照成本:如果 Originator 的状态很大,每次备份都是整份拷贝,内存与时间开销会线性增长。实践中常结合"只保存差异(delta)"或限制历史栈深度来缓解;
- "两个接口"是实现细节而非标准:GoF 语义上强调宽 / 窄接口的分离,具体落地时既可以用示例中的双接口 + 向下转型,也可以用包级私有类、内部类等方式在语言层面收紧访问控制,目标都是同一件事——Caretaker 摸不到、Originator 独享读写。
5.3 与命令模式的关系
备忘录模式与仓库中另一个行为型模式命令(Command)模式常常搭配出现:命令模式将"请求/操作"封装为对象从而天然支持排队与撤销;而撤销动作的具体状态记忆,往往要由备忘录负责。若需要在一份文档里横向对比行为型模式各自的意图与结构,可查阅 CS-Notes 的设计模式总览文档与设计模式目录。
六、小结
备忘录模式用一个"窄接口保管、宽接口还原"的双接口备忘录对象,同时解决了三个问题:
- 状态快照:Originator 把自己的私有状态完整拷贝进备忘录;
- 封装保护:Caretaker 只能持有与传递备忘录,无法读取、篡改其中数据;
- 可靠恢复:状态可以被原样还原到任意一次备份时刻。
CS-Notes 中备忘录文档所附的计算器实例,用不足百行代码就完整演示了"正确计算 → 保存快照 → 误操作 → 一键恢复"的经典链路,是理解状态回滚类需求的入门范本;配合 JDK 中 java.io.Serializable 的序列化快照机制,可以进一步将这种"模式级思想"落地为 Java 工程中真实可用的状态持久化方案。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0627
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
