备忘录模式(Memento Pattern)
一句话记忆口诀:备忘录在不破坏封装的前提下保存对象状态快照,支持撤销/回滚;Git 和数据库日志可用于比较其思想,但不完全等同 GoF 备忘录模式。
1. 🏠 生活类比
游戏存档:玩家在关键节点保存进度(创建备忘录),失败后读取存档恢复到之前的状态。存档可以类比备忘录,但实际存档通常还涉及文件格式、版本兼容与状态重建,不一定是对象在内存中的完整快照。
Ctrl+Z 撤销:编辑器在操作前保存状态,撤销时恢复到对应的历史状态。实际编辑器也可能采用命令的逆操作、增量记录等机制,不一定是每次操作都保存完整快照。
2. 💩 烂代码:保存状态破坏封装
// ❌ 反例:直接暴露对象内部状态来实现撤销
public class TextEditor {
public String content = ""; // 公开字段!
public int cursorPos = 0; // 公开字段!
public List<String> history = new ArrayList<>(); // 公开字段!
public void save() {
// 保存状态 —— 但外部可以直接修改这些字段!
history.add(content);
}
public void undo() {
if (!history.isEmpty()) {
content = history.remove(history.size() - 1);
}
}
}
// 外部代码可以直接修改 editor.content,破坏状态一致性!
editor.content = "被篡改了";问题根因:撤销逻辑如果直接读取和修改编辑器的内部字段,调用方就会依赖编辑器的实现细节;后续新增状态字段或调整内部结构时,撤销逻辑也得跟着改。而且上面的 save() 只保存了 content、没有保存 cursorPos,即使补全历史记录也无法完整恢复编辑器状态。
3. ✨ 备忘录模式方案
三个角色:
- 发起人(Originator):创建备忘录,并负责从备忘录恢复自身状态
- 备忘录(Memento):保存发起人的状态快照;其内部状态不应由管理者直接读取或修改
- 管理者(Caretaker):保存、排序和淘汰备忘录,不负责解释或修改快照内容
这里区分两个容易混淆的概念:不可变性指快照创建后不能被修改,封装边界指管理者可以保存、传递快照,却不能读取其内部状态。类图中的
getState()与下文 Java 示例的公开 getter 都是教学简化,只满足不可变性,不满足严格的封装边界;严格实现时应把备忘录做成发起人的私有嵌套类,让管理者持有一个不暴露内部状态的类型。
4. 💻 完整代码实现
以下代码块为便于阅读合并展示,实际编译时请按类拆分:每个
public顶层类对应一个同名.java文件(EditorMemento.java、TextEditor.java、History.java、Main.java)。
EditorMemento同时保存文本内容与光标位置,是因为二者都是撤销时需要恢复的状态;timestamp只用于显示保存时间,不参与恢复。备忘录不必复制发起人的全部字段,只需覆盖恢复目标依赖的状态。
import java.time.LocalDateTime;
import java.util.*;
// ===== 备忘录:保存状态快照(不可变)=====
public class EditorMemento {
private final String content; // 文本内容
private final int cursorPosition; // 光标位置
private final LocalDateTime timestamp; // 保存时间
public EditorMemento(String content, int cursorPosition) {
this.content = Objects.requireNonNull(content, "content 不能为 null");
if (cursorPosition < 0 || cursorPosition > content.length()) {
throw new IllegalArgumentException("光标位置超出文本范围");
}
this.cursorPosition = cursorPosition;
this.timestamp = LocalDateTime.now();
}
// 字段为 final 且引用类型均为不可变类型,创建后不能修改快照内容
public String getContent() { return content; }
public int getCursorPosition() { return cursorPosition; }
public LocalDateTime getTimestamp() { return timestamp; }
}
// ===== 发起人:文本编辑器 =====
public class TextEditor {
private String content = "";
private int cursorPosition = 0;
public void type(String text) {
Objects.requireNonNull(text, "text 不能为 null");
// 在光标位置插入文本
content = content.substring(0, cursorPosition) + text + content.substring(cursorPosition);
cursorPosition += text.length();
}
public void delete(int count) {
if (count < 0) {
throw new IllegalArgumentException("count 不能为负");
}
int start = Math.max(0, cursorPosition - count);
content = content.substring(0, start) + content.substring(cursorPosition);
cursorPosition = start;
}
public void moveCursor(int position) {
cursorPosition = Math.max(0, Math.min(position, content.length()));
}
// 创建备忘录(保存当前状态)
public EditorMemento save() {
return new EditorMemento(content, cursorPosition);
}
// 从备忘录恢复状态
public void restore(EditorMemento memento) {
Objects.requireNonNull(memento, "memento 不能为 null");
this.content = memento.getContent();
this.cursorPosition = memento.getCursorPosition();
}
@Override
public String toString() {
return "内容: \"" + content + "\",光标位置: " + cursorPosition;
}
}
// ===== 管理者:历史记录管理 =====
public class History {
private final Deque<EditorMemento> snapshots = new ArrayDeque<>();
private final int maxSize;
public History(int maxSize) {
if (maxSize <= 0) {
throw new IllegalArgumentException("maxSize 必须大于 0");
}
this.maxSize = maxSize;
}
public void push(EditorMemento memento) {
Objects.requireNonNull(memento, "memento 不能为 null");
if (snapshots.size() >= maxSize) {
snapshots.removeLast(); // 移除最旧的快照
}
snapshots.push(memento);
}
public EditorMemento pop() {
return snapshots.isEmpty() ? null : snapshots.pop();
}
public boolean canUndo() {
return !snapshots.isEmpty();
}
}
// ===== 使用示例 =====
public class Main {
public static void main(String[] args) {
TextEditor editor = new TextEditor();
History history = new History(10);
// 在每次操作前保存状态,撤销时恢复到上一个历史状态
history.push(editor.save()); // 保存初始空状态
editor.type("Hello");
System.out.println("1: " + editor);
history.push(editor.save());
editor.type(" World");
System.out.println("2: " + editor);
history.push(editor.save());
editor.type("!");
System.out.println("3: " + editor);
// 撤销两次(canUndo 保护,避免空历史)
if (history.canUndo()) {
editor.restore(history.pop());
}
System.out.println("撤销1次: " + editor); // Hello World
if (history.canUndo()) {
editor.restore(history.pop());
}
System.out.println("撤销2次: " + editor); // Hello
}
}封装说明:本例
EditorMemento用公开 getter 简化教学,管理者History按约定只保存和传递快照、不读取内容,是有意放弃严格封装边界(概念见 §3)。严格实现时,仅把EditorMemento改为TextEditor的私有嵌套类还不够,History还需要一种不暴露内部状态的持有类型(如不透明标记接口),否则它无法在TextEditor外部声明并管理快照。
撤销时机说明:示例采用「操作前保存状态」的策略,出栈得到的正是上一个历史状态;这不是备忘录模式的硬性要求,只要明确每个快照对应哪个历史状态,「操作后保存」同样能实现撤销。完整的编辑器历史还需处理连续撤销、撤销后重新编辑与重做分支,本篇不展开。
4.1 补充示例:用内存快照演示回滚
下面的示例独立演示全量快照恢复,不是前面
TextEditor的扩展,也不模拟完整的数据库事务实现;它按单层事务的约定工作,commit()清除当前事务使用的快照,连续begin()后commit()会丢失外层回滚点(代码本身并不拒绝第二次begin()),这是有意保留的简化。
// ===== 场景:用内存 Map 演示快照与回滚(类比数据库 Undo Log 的思想)=====
public class DatabaseTransaction {
private final Map<String, String> data = new HashMap<>();
private final Deque<Memento> undoLog = new ArrayDeque<>();
// 保存当前状态快照
public void begin() {
undoLog.push(new Memento(new HashMap<>(data)));
System.out.println("事务开始,保存快照");
}
public void put(String key, String value) {
data.put(key, value);
System.out.println("写入: " + key + "=" + value);
}
public String get(String key) {
return data.get(key);
}
// 提交:清除快照(仅支持单层事务,不支持嵌套)
public void commit() {
undoLog.clear();
System.out.println("事务提交");
}
// 回滚:恢复到最近的快照
public void rollback() {
if (undoLog.isEmpty()) {
throw new IllegalStateException("没有可回滚的快照");
}
Memento snapshot = undoLog.pop();
data.clear();
data.putAll(snapshot.getState());
System.out.println("事务回滚,恢复到快照状态");
}
// 备忘录
private static class Memento {
private final Map<String, String> state;
Memento(Map<String, String> state) { this.state = state; }
Map<String, String> getState() { return state; }
}
}
// 使用示例(放在 main 方法中调用)
DatabaseTransaction tx = new DatabaseTransaction();
tx.begin();
tx.put("user:1", "Alice");
tx.put("user:2", "Bob");
tx.rollback(); // 回滚,user:1 和 user:2 的修改被撤销
new HashMap<>(data)是浅拷贝:本例键和值均为String(不可变),足以隔离快照;若值换成可变对象,复制 Map 并不能保证对象内部状态被完整隔离,需要深拷贝。这里演示的是应用层内存数据的全量快照恢复,仅类比数据库 Undo Log 的思想,并非数据库引擎的真实实现。
5. 🔧 相关机制与区别
典型应用
- 编辑器历史记录:IDE 的 Ctrl+Z,备忘录模式的直接体现。
思想相近但不等同
- 数据库 Undo Log:通过记录撤销修改所需的信息支持事务回滚,与备忘录有思想相似性,但通常不是整个对象状态的全量快照。
- Git 版本控制:每次 commit 记录代码快照,但版本管理不等于 GoF 备忘录对象。
相关机制
- Spring
@Transactional回滚:管理事务资源的提交与回滚,不会自动恢复任意 Java 对象字段。 Serializable:提供对象状态的序列化与还原能力,不负责历史记录的管理。Prototype模式:通过 clone 复制对象状态,仅当副本用于保存与恢复历史状态时才与备忘录相近。
6. ⚠️ 适用场景
适合:
- 需要保存对象的历史状态以支持撤销/回滚
- 不想暴露对象的内部实现细节
- 典型场景:文本编辑器、绘图软件、应用层对象状态回滚(事务回滚的思想类比)、游戏存档
不适合:
- 每次保存都复制大量状态时,全量快照可能带来较高的内存和复制成本
- 状态变化频率极高(如每帧都变)→ 可结合状态大小、操作频率与恢复成本,选择增量记录、命令及其逆操作,或定期全量快照加增量记录
6.1 内存优化技巧
- 限制历史数量:设置最大快照数,淘汰较旧的记录;固定数量不一定能限制内存总量,因为不同快照大小可能相差很大。
- 增量记录:只保存相邻状态之间的变化,恢复时按正确顺序应用或逆转变化,并处理基准快照。
- 压缩旧记录:适合历史数据占用大、访问频率低的场景,但要权衡压缩与解压的 CPU 成本。
一句话记忆口诀:备忘录保存「操作前」的历史快照,管理者只管存和淘汰、不能读内容;不可变性不等于封装边界,Git 和 Undo Log 是思想类比而非全量快照。