备忘录模式完全指南:从撤销重做到游戏存档与配置回滚

备忘录模式听起来就和"撤销"密切相关 —— 它的核心目标是在不破坏对象封装性的前提下,保存对象内部状态,以便日后恢复。文本编辑器的 undo、游戏存档、数据库事务、配置回滚,背后都是它。这篇文章从最朴素的"复制对象"讲起,讲清楚备忘录、命令模式和原型模式的关系,以及在内存敏感场景下的优化技巧。

问题:复制对象会破坏封装

看一个最朴素的尝试 —— 保存编辑器状态:

class Editor {
    private String content;
    private int cursorPosition;
    private List<String> clipboardHistory;
    private Map<String, Object> userPrefs;
    // ...更多内部字段
}

// 客户端想做 undo:
EditorBackup backup = new EditorBackup(
    editor.content,                  // 错!访问了私有字段
    editor.cursorPosition,
    editor.clipboardHistory,
    editor.userPrefs
);

问题:

  • 客户端拿到了 Editor 的私有字段,破坏封装。
  • Editor 加新字段,所有"备份"代码都要改。
  • 客户端必须暴露所有可恢复状态的 getter,导致内部状态全部暴露给外部。

备忘录模式说:让对象自己生产一个"快照对象"(Memento),只有它自己知道怎么解读;外部只能拿着这个 Memento 让对象自己恢复

备忘录的标准结构

// 1. Originator(发起者):需要保存状态的对象
class Editor {
    private String content;
    private int cursorPosition;

    public Editor(String content) { this.content = content; }

    // 业务方法
    public void type(String text) {
        content = content.substring(0, cursorPosition) + text + content.substring(cursorPosition);
        cursorPosition += text.length();
    }

    // 创建快照
    public Memento save() {
        return new Memento(content, cursorPosition);
    }

    // 从快照恢复(只有 Editor 自己能解读 Memento 内部)
    public void restore(Memento m) {
        this.content = m.content;
        this.cursorPosition = m.cursorPosition;
    }

    // 2. Memento(备忘录):内嵌类,只有 Editor 能访问字段
    public static class Memento {
        private final String content;
        private final int cursorPosition;

        private Memento(String content, int pos) {
            this.content = content; this.cursorPosition = pos;
        }
    }
}

// 3. Caretaker(管理者):只持有 Memento,不知道里面是什么
class History {
    private final Deque<Editor.Memento> undoStack = new ArrayDeque<>();
    private final Deque<Editor.Memento> redoStack = new ArrayDeque<>();

    public void save(Editor editor) {
        undoStack.push(editor.save());
        redoStack.clear();
    }

    public void undo(Editor editor) {
        if (undoStack.isEmpty()) return;
        redoStack.push(editor.save());          // 把当前状态进 redo
        editor.restore(undoStack.pop());        // 恢复到上次
    }

    public void redo(Editor editor) {
        if (redoStack.isEmpty()) return;
        undoStack.push(editor.save());
        editor.restore(redoStack.pop());
    }
}

三个角色各司其职:

  • Originator(Editor):自己控制"保存什么、怎么恢复"。
  • Memento:不可变快照对象,只有 Originator 能创建和解读。
  • Caretaker(History):只负责"保管和按顺序提供" Memento,不打开它看内容。

实战 1:文本编辑器的多级撤销

Editor editor = new Editor("Hello ");
History history = new History();

history.save(editor);
editor.type("World");          // "Hello World"

history.save(editor);
editor.type(" again");         // "Hello World again"

history.undo(editor);          // "Hello World"
history.undo(editor);          // "Hello "
history.redo(editor);          // "Hello World"

用户每次有意义的操作前,Caretaker 保存 Memento,操作再发生。Undo 取出上一个 Memento 恢复。这是 Word / VSCode / Photoshop 的撤销机制最基础的实现思路。

实战 2:游戏存档

class GameWorld {
    Player player;
    List<Enemy> enemies;
    Map<String, Boolean> questFlags;
    int currentLevel;

    public Memento save() {
        // 深拷贝所有可变状态
        return new Memento(
            player.deepCopy(),
            enemies.stream().map(Enemy::deepCopy).collect(toList()),
            new HashMap<>(questFlags),
            currentLevel
        );
    }

    public void load(Memento m) {
        this.player = m.player.deepCopy();
        this.enemies = m.enemies.stream().map(Enemy::deepCopy).collect(toList());
        this.questFlags = new HashMap<>(m.questFlags);
        this.currentLevel = m.currentLevel;
    }

    static class Memento {
        final Player player;
        final List<Enemy> enemies;
        final Map<String, Boolean> questFlags;
        final int currentLevel;
        // ...
    }
}

// 存档机制
class SaveSlot {
    String name;
    GameWorld.Memento memento;
    Date savedAt;
}

List<SaveSlot> saves = new ArrayList<>();
saves.add(new SaveSlot("自动存档 1", world.save(), new Date()));

存档槽就是 Memento 的容器。玩家选择某个存档槽 → 取出对应 Memento → 调 world.load 恢复。

实战 3:配置回滚

class ConfigService {
    private Map<String, Object> current = new HashMap<>();
    private Deque<Memento> history = new ArrayDeque<>();

    public void update(Map<String, Object> changes) {
        history.push(new Memento(new HashMap<>(current)));  // 备份
        if (history.size() > 10) history.pollLast();          // 限制深度
        current.putAll(changes);
    }

    public void rollback() {
        if (history.isEmpty()) return;
        current = history.pop().snapshot;
    }

    private static class Memento {
        final Map<String, Object> snapshot;
        Memento(Map<String, Object> s) { this.snapshot = s; }
    }
}

config.update(Map.of("featureX", true));     // 改了
config.update(Map.of("rateLimitQps", 100));  // 又改了
config.rollback();                            // 退回上一版
config.rollback();                            // 再退一版

线上配置出问题时的快速回滚机制。结合监控触发器(指标异常 → 自动 rollback),能避免一次错误配置毁掉整个系统。

备忘录的内存优化

1. 增量快照

每次都全量复制内存太大。改进:Memento 只存差异(diff)而非全量。

// 不存全量 content,只存"差异操作"
class IncrementalMemento {
    enum OpType { INSERT, DELETE, REPLACE }
    OpType type;
    int position;
    String oldText;     // 用于 undo
    String newText;     // 用于 redo
}

// Undo:反向执行差异
public void restore(IncrementalMemento m) {
    switch (m.type) {
        case INSERT: content = content.substring(0, m.position) + content.substring(m.position + m.newText.length()); break;
        case DELETE: content = content.substring(0, m.position) + m.oldText + content.substring(m.position); break;
    }
}

这其实和命令模式融合了 —— 每个增量 Memento 本质就是一个可逆命令。Git 的 commit 就是这种风格。

2. 持久化到磁盘

大对象 Memento 留在内存不现实。游戏存档通常落盘:

void saveToFile(GameWorld.Memento m, File file) {
    try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream(file))) {
        oos.writeObject(m);
    }
}

序列化方式选择:JSON 可读但慢且体积大;二进制(Java 原生、Kryo)快但跨版本兼容差;Protobuf 兼顾速度和兼容性。

3. 数据结构层面的不可变共享

用持久化数据结构(immutable.js / Clojure persistent data structure):每次"修改"生成新版本,但新旧版本共享大部分内部结构。复制成本从 O(n) 降到 O(log n)。

备忘录 + 命令组合

两个常常一起出现:

// Command 存的不仅是"做什么",还有"做之前的状态"(Memento)
class Command {
    Editor.Memento before;
    Editor editor;
    Runnable action;

    public void execute() {
        before = editor.save();
        action.run();
    }
    public void undo() {
        editor.restore(before);
    }
}

这种组合既有"动作可序列化"(Command)又有"状态可恢复"(Memento)的好处。文档型应用、CAD 工具、画板软件都常用这种结构。

分布式系统里的备忘录:Saga 模式

微服务里的长事务,无法用数据库 ACID 跨服务保证一致性。Saga 模式让每个步骤都保留"反向操作"(本质是 Memento + Command):

步骤 1:扣库存       反向:补回库存
步骤 2:扣余额       反向:补回余额
步骤 3:创建订单     反向:删除订单

任一步失败 -> 从失败点反向执行,补偿之前的步骤

每个反向操作的依据,就是之前步骤的"状态快照"。这种思想在分布式事务、长流程业务里至关重要。

备忘录的常见坑

坑 1:Memento 引用了可变对象

public Memento save() {
    return new Memento(this.list);     // 错!list 是引用,后续 list 修改会污染 Memento
}

// 对:深拷贝
public Memento save() {
    return new Memento(new ArrayList<>(this.list));
}

规则:Memento 里的所有可变字段必须是独立副本,不和 Originator 共享。否则你以为存了快照,实际只存了引用,毫无意义。

坑 2:Memento 暴露字段

Memento 的字段如果有 public getter,外部代码可以读,封装就破了。规范:

  • Memento 作为 Originator 的内部类(Java 内嵌类)或同包私有,只让 Originator 访问。
  • 不对外暴露 getter,只让 Originator 的 restore 解读。

坑 3:撤销历史无限增长

每次操作存一份 Memento,工作几小时积累几千个 Memento,内存涨爆。规则:

  • 限制历史栈深度(Word 默认 100 步)。
  • 定期合并:连续输入字符可以合并成一个"输入序列" Memento,而不是每个字符一个。
  • 大对象用增量快照。

坑 4:Originator 的字段加了,但 Memento 没同步加

Editor 加了一个 fontSize 字段,但 Memento 没存它,undo 后 fontSize 不变 —— 出现"部分撤销"bug。这种 bug 很难测试发现。改进:用反射或工具自动生成 Memento;或让 Editor 字段集中在一个内部 State 对象,Memento 存这个 State 的副本就行。

各语言里的备忘录

Python:dataclasses + copy.deepcopy

from dataclasses import dataclass, replace
import copy

@dataclass
class EditorState:
    content: str = ''
    cursor: int = 0

class Editor:
    def __init__(self):
        self.state = EditorState()

    def save(self):
        return copy.deepcopy(self.state)     # 整个 state 作 Memento

    def restore(self, m):
        self.state = m

# Caretaker
history = [editor.save()]
editor.state.content = 'new content'
history.append(editor.save())

JavaScript:用 structuredClone

class Editor {
    constructor() { this.state = { content: '', cursor: 0 }; }
    save() { return structuredClone(this.state); }
    restore(m) { this.state = structuredClone(m); }
}

const editor = new Editor();
const history = [editor.save()];
editor.state.content = 'hello';
history.push(editor.save());

备忘录 vs 原型 vs 命令

  • 原型:克隆任何对象产生独立副本。重点是"造一个新对象"。
  • 备忘录:特别针对"保存内部状态用于恢复"。重点是"可恢复"。
  • 命令:把"操作"对象化,可逆命令通常带备份状态(本质就是 Memento)。

很多场景三者融合:命令 execute 时用原型克隆当前状态作为 Memento,undo 时用 Memento 恢复。Photoshop 的"历史记录"面板就是这套组合的实现。

写在最后

备忘录模式的核心是"把可恢复性做成对象的内置能力"。它解决了一个看似简单实则微妙的问题:在不破坏封装的前提下保存状态。一旦掌握,撤销/重做、版本回滚、长事务补偿、游戏存档这些"复杂"功能其实都是 Memento 的不同表现形式。

给一个工程心得:设计任何长期演化的对象时,先想清楚"它的可恢复点是什么"。把这些可恢复点封装成 Memento,把"何时存、何时恢复"交给 Caretaker。这种"把恢复能力从一开始就架构进系统"的思路,远比"等需要 undo 时再补"省心 —— 后者通常意味着大量重构,因为对象的状态已经四散在代码各处。备忘录早做晚做都要做,早做的成本低得多。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

中介者模式完全指南:从表单联动地狱到消息总线的解耦利器

2026-5-15 15:29:20

技术教程

状态模式完全指南:从 if 地狱到订单状态机的优雅演化

2026-5-15 15:35:28

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索