首页
/ CS-Notes 设计模式精讲:备忘录(Memento)模式——不破坏封装的状态备份与恢复实战

CS-Notes 设计模式精讲:备忘录(Memento)模式——不破坏封装的状态备份与恢复实战

2026-09-06 18:55:08作者:柏廷章Berta

备忘录(Memento)模式是 GoF 行为型设计模式之一,其核心价值在于在不暴露对象内部结构的前提下,把对象的内部状态"快照"保存下来,并在需要时准确还原。本文以 CS-Notes 仓库中 设计模式 - 备忘录.md 为主干,完整剖析模式的角色划分、双重接口设计,并结合一个可运行的计算器实例,逐行讲解状态备份(backup)、状态破坏、状态恢复(restore)的完整调用链,最后给出其在 JDK 中的代表实现与典型应用场景。读完本文,你将能独立实现一套兼顾封装性与可回滚性的状态快照机制。

备忘录模式在 CS-Notes 中被归入行为型模式,与命令模式、迭代器等并列,位于设计模式目录。本文对应的详细代码与类图均整理在 备忘录模式文档 中。

一、模式意图(Intent)

按照 CS-Notes 中的定义,备忘录模式的意图是:

在不违反封装的情况下获得对象的内部状态,从而在需要时可以将对象恢复到最初状态。

这两句话各自对应一个关键约束:

  1. 获得内部状态但"不违反封装":不能为了让外部能保存状态,就把 Originator(原始对象)的内部字段全部改成 public 或提供一堆 getter/setter。状态仍由 Originator 独占访问,外部只拿到一个"不透明"的快照对象。
  2. 支持恢复:系统运行中可能发生异常输入、误操作、流程回退等,需要把对象"拉回"到此前某个时刻的状态。

一句话概括:备忘录负责保存状态的"复印本",但真正读写这份状态的钥匙只掌握在状态的主人(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:

备忘录(Memento)模式类图:Originator、Memento、Caretaker 三者关系

图中值得注意的关联关系是:Originator 与 Memento 之间存在单向双向使用的依赖(创建 + 恢复),而 Caretaker 对 Memento 只是聚合持有,Caretaker 一侧没有任何读状态的方法入口——这正是"窄接口"在结构上的体现。

三、完整实例:可撤销的计算器(Implementation)

3.1 场景说明

CS-Notes 采用一个经典的计算器示例来演示备忘录模式(思路参考 oodesign.com 的 Calculator 示例,文档中保留了原引用)。该程序可以输入两个数值并求它们的和;通过备忘录模式,可以把这两个加数"存储"起来,然后在某个时刻用存储的状态进行恢复——模拟用户按 CTRL + Z 撤销误操作。

整体调用关系是:

  1. Originator(接口) Calculator:定义备份与恢复的对外操作;
  2. Originator(实现) CalculatorImp:持有两个加数的真实状态;
  3. Memento 的宽接口 PreviousCalculationToOriginator:仅供 Originator 读取已存状态;
  4. Memento 的窄接口 PreviousCalculationToCareTaker:仅供 Caretaker 传递,无任何操作;
  5. Memento 实现 PreviousCalculationImp:同时实现宽、窄两个接口;
  6. 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——firstNumbersecondNumber 是它的私有内部状态:

/**
 * 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 的设计模式总览文档与设计模式目录。

六、小结

备忘录模式用一个"窄接口保管、宽接口还原"的双接口备忘录对象,同时解决了三个问题:

  1. 状态快照:Originator 把自己的私有状态完整拷贝进备忘录;
  2. 封装保护:Caretaker 只能持有与传递备忘录,无法读取、篡改其中数据;
  3. 可靠恢复:状态可以被原样还原到任意一次备份时刻。

CS-Notes 中备忘录文档所附的计算器实例,用不足百行代码就完整演示了"正确计算 → 保存快照 → 误操作 → 一键恢复"的经典链路,是理解状态回滚类需求的入门范本;配合 JDK 中 java.io.Serializable 的序列化快照机制,可以进一步将这种"模式级思想"落地为 Java 工程中真实可用的状态持久化方案。

登录后查看全文
热门项目推荐
相关项目推荐