Java 设计模式心法之第22篇 - 备忘录 (Memento) - 捕获与恢复对象状态的“时光机”
在软件开发中,我们常常需要实现撤销(Undo)功能,或者在某个关键点保存对象的状态以便后续回滚或恢复(例如,游戏存档、配置快照、数据库事务回滚点)。直接暴露对象的内部状态让外部保存,会严重破坏对象的封装性,使得内部实现细节暴露无遗,难以维护。有没有一种方法,能在不违反封装原则的前提下,捕获一个对象的内部状态,并在对象之外保存这个状态,以便将来能将对象恢复到原先保存的状态呢?本文将带你深入理解行为型模式中的“状态时光机”——备忘录模式。我们将揭示它如何通过引入一个备忘录 (Memento) 对象来承载状态快照,并配合发起人 (Originator) 和负责人 (Caretaker) 角色,实现对象状态的安全捕获、外部存储与精确恢复,同时保持发起人内部状态的私有性。
一、问题的提出:当“时光倒流”遭遇“封装壁垒”
想象一下你正在开发一个图形编辑器:
- 用户可以在画布上绘制各种形状(线条、矩形、圆形)。
- 用户可能需要撤销上一步的操作,回到之前的画布状态。
或者一个文本编辑器:
- 用户输入、删除、修改文本。
- 用户需要能够撤销多次编辑操作,逐步回退到之前的文本内容。
或者一个游戏:
- 玩家的角色状态(生命值、魔法值、位置、物品栏)在不断变化。
- 玩家需要在某个安全点保存游戏进度(存档),以便下次能从这个状态继续,或者在失败后读档恢复。
要实现这些“时光倒流”或“状态恢复”的功能,核心在于需要在某个时间点捕获并保存相关对象(画布、文本内容、角色)的完整内部状态。
最简单粗暴的方式是什么?
- 让画布类、文本类、角色类提供
public
的 getter 方法,让外部(比如一个“历史记录管理器”)能够读取其所有内部状态变量的值,然后保存起来。 - 当需要恢复时,再让外部调用
public
的 setter 方法,将保存的状态值重新设置回去。
这种方式的问题显而易见:
- 严重破坏封装性 (Violates Encapsulation): 为了保存和恢复状态,我们被迫将对象的**所有内部实现细节(私有变量)**通过 getter/setter 暴露给外部。这使得对象的内部结构变得脆弱,任何外部代码都可以随意访问甚至修改其状态,失去了面向对象封装带来的保护和维护性。
- 外部保存者职责过重 (External Saver Overburdened): 保存状态的外部组件(如历史记录管理器)需要知道发起人对象的所有内部状态变量及其类型,并负责管理这些零散的数据。如果发起人内部状态结构发生变化,外部保存者也需要跟着修改。
我们需要一种机制,能够:
- 让发起人对象自己负责创建其状态的“快照”。
- 这个“快照”能够被外部安全地持有和存储,但外部无法(或不应该)直接访问快照内部的细节。
- 发起人对象能够使用这个“快照”将自己恢复到之前的状态。
- 整个过程不破坏发起人对象的封装性。
二、状态快照的艺术:备忘录模式的核心定义与意图
备忘录模式 (Memento Pattern) 提供了一种优雅的解决方案。它在不破坏封装性的前提下,捕获一个对象的内部状态,并在该对象之外保存这个状态。这样以后就可将该对象恢复到原先保存的状态。
GoF 的经典意图描述是:“在不破坏封装性的前提下,捕获一个对象的内部状态,并在该对象之外保存这个状态。这样以后就可将该对象恢复到原先保存的状态。”
其核心思想在于引入三个关键角色:
- 发起人 (Originator):
- 这是我们需要保存和恢复状态的那个对象(如图形编辑器画布、文本编辑器内容、游戏角色)。
- 它知道如何创建一个包含其当前内部状态的备忘录 (Memento) 对象。
- 它也知道如何使用一个备忘录对象来恢复其内部状态。
- 关键: 发起人可以访问备忘录的所有信息(因为是它创建的),但它不会将自己的
private
状态直接暴露给外部。
- 备忘录 (Memento):
- 负责存储发起人对象的内部状态快照。
- 它可以拥有两个接口(通常通过访问控制实现):
- 对发起人宽接口 (Wide Interface): 允许发起人访问备忘录内部存储的所有状态数据,以便发起人可以创建备忘录或从中恢复状态。这个接口通常只有发起人能够访问(例如,通过将 Memento 作为 Originator 的内部类,或者使用包级私有访问权限)。
- 对负责人窄接口 (Narrow Interface): 只暴露非常有限的方法(通常是空的或者只有一些标记方法),阻止负责人(或其他外部对象)直接访问备忘录内部存储的状态数据。负责人只能像保管一个“黑盒子”一样持有和传递备忘录对象。
- 关键: 备忘录对象本身不应包含任何业务逻辑,它只是一个状态数据的载体。
- 负责人 (Caretaker):
- 负责保存和管理备忘录对象。它可以存储一个或多个备忘录(例如,用栈来实现撤销历史)。
- 它只负责保管备忘录,不能对备忘录的内容进行操作或检查(因为它只能访问备忘录的窄接口)。
- 当需要恢复状态时,负责人将之前保存的备忘录对象传递回给发起人。
关键流程:
- 客户端请求发起人 (Originator) 创建一个备忘录 (Memento)。
- 发起人捕获其当前内部状态,创建一个 Memento 对象,并将状态存入其中。
- 发起人将 Memento 对象返回给客户端(或直接给负责人)。
- 负责人 (Caretaker) 接收并保存这个 Memento 对象。
- 当需要恢复状态时,负责人从其存储中取出相应的 Memento 对象。
- 负责人将 Memento 对象传递回给发起人。
- 发起人使用这个 Memento 对象中存储的状态来恢复自身的内部状态。
核心在于:状态的捕获和恢复逻辑由发起人自己控制,备忘录作为状态载体在外部流转但内部细节受保护,负责人只负责保管备忘录。
三、时光倒流的场景:备忘录模式的适用之地
备忘录模式非常适用于以下需要保存和恢复对象状态的场景:
- 需要保存一个对象在某一个时刻的状态,以便后续能恢复到该状态: 如实现撤销/重做功能、数据库或事务的回滚、游戏存档/读档、配置管理中的快照和回滚。
- 希望状态的保存和恢复过程不破坏对象的封装性: 不希望为了保存状态而暴露对象的内部实现细节。这是备忘录模式最核心的价值。
- 负责人(保存备忘录的对象)不关心状态的具体内容: 负责人只需要安全地保管备忘录即可。
四、状态存档的实现:备忘录模式的 Java 实践
我们以一个简单的文本编辑器(只关心文本内容)为例,实现撤销功能。
1. 定义备忘录类 (Memento):
(通常作为发起人的内部类,或放在同一包下,利用访问权限控制)
/*** 备忘录类:存储编辑器状态 (文本内容)* 对外部(Caretaker)提供窄接口 (通常是空的,或者只有标记作用)* 对发起人(Editor)提供宽接口 (通过 getter 获取状态)*/
// 方式一:将 Memento 作为 Originator 的静态内部类
// public class Editor {
// ...
// public static class EditorMemento { // 设为 public 或 包私有,以便 Caretaker 能持有
// private final String content; // 状态设为 final 保证不可变
//
// private EditorMemento(String content) { // 构造器设为 private 或 包私有
// this.content = content;
// }
//
// // 对 Originator 提供的 getter (包私有 或 private + 友元访问)
// private String getContent() {
// return content;
// }
// }
// ...
// }// 方式二:将 Memento 放在同一包下,使用包级私有访问权限
// (这种方式更常见,如果 Originator 和 Memento 需要分开文件)
package memento_pattern; // 假设在同一个包下class EditorMemento { // 类本身设为包级私有或 public// 状态字段设为包级私有,或提供包级私有的 getter// 使用 final 确保状态在备忘录创建后不可变final String content;// 构造器设为包级私有,只有同包的 Editor 能创建EditorMemento(String content) {System.out.println("创建备忘录,内容: \"" + content + "\"");this.content = content;}// 提供给 Editor 使用的 getter (包级私有)StringgetContent() {return content;}// 对 Caretaker 不提供任何获取内部状态的方法 (窄接口)
}
选择哪种方式取决于具体需求和代码组织。内部类能更好地体现 Memento 与 Originator 的紧密关系,但可能不便于 Caretaker 直接引用 Memento 类型。包级私有是 Java 中实现访问控制的常用手段。
2. 创建发起人类 (Originator):
package memento_pattern; // 确保在同一个包下,可以访问 Memento 的包私有成员/*** 发起人类:文本编辑器*/
class Editor {private String content = ""; // 编辑器的内部状态public void type(String words) {this.content += words;System.out.println("编辑器输入: \"" + words + "\", 当前内容: \"" + content + "\"");}public String getContent() {return content;}// 创建备忘录:捕获当前状态public EditorMemento save() {System.out.println("编辑器: 保存当前状态...");return new EditorMemento(this.content); // 创建备忘录并传入当前内容}// 从备忘录恢复状态public void restore(EditorMemento memento) {if (memento != null) {System.out.println("编辑器: 从备忘录恢复状态...");// 通过 Memento 的包私有 getter 获取状态this.content = memento.getContent();System.out.println("恢复后内容: \"" + this.content + "\"");} else {System.out.println("编辑器: 没有可恢复的备忘录。");}}
}
3. 创建负责人(保管者)类 (Caretaker):
package memento_pattern; // 确保在同一个包下,可以访问 Memento 类型import java.util.Stack;/*** 负责人(保管者)类:负责存储和提供备忘录 (实现撤销历史)*/
class History {// 使用栈来存储备忘录,实现后进先出 (LIFO) 的撤销private Stack<EditorMemento> history = new Stack<>();// 将备忘录压入栈顶public void push(EditorMemento memento) {System.out.println("历史记录: 添加一个备忘录到栈顶。");history.push(memento);}// 从栈顶弹出一个备忘录 (用于撤销)public EditorMemento pop() {if (!history.isEmpty()) {EditorMemento memento = history.pop();System.out.println("历史记录: 从栈顶取出一个备忘录。");return memento;} else {System.out.println("历史记录: 栈为空,无法撤销。");return null; // 或者返回一个特殊的 Null Memento}}
}
4. 客户端使用:
package memento_pattern;public class MementoClient {public static void main(String[] args) {// 1. 创建发起人 (编辑器) 和负责人 (历史记录)Editor editor = new Editor();History history = new History();System.out.println("=== 开始编辑 ===");// 2. 进行编辑操作,并在每次操作后保存状态editor.type("这是第一句话。");history.push(editor.save()); // 保存状态 1editor.type("这是第二句话。");history.push(editor.save()); // 保存状态 2editor.type("这是第三句话,但写错了!");history.push(editor.save()); // 保存状态 3 (错误状态)System.out.println("\n当前内容: \"" + editor.getContent() + "\"");System.out.println("\n=== 执行撤销操作 ===");// 3. 从负责人那里获取上一个备忘录并恢复editor.restore(history.pop()); // 撤销第 3 步操作,恢复到状态 2System.out.println("撤销一次后内容: \"" + editor.getContent() + "\"");System.out.println("\n=== 再次执行撤销操作 ===");editor.restore(history.pop()); // 撤销第 2 步操作,恢复到状态 1System.out.println("撤销两次后内容: \"" + editor.getContent() + "\"");System.out.println("\n=== 再次执行撤销操作 ===");editor.restore(history.pop()); // 撤销第 1 步操作,恢复到初始空状态System.out.println("撤销三次后内容: \"" + editor.getContent() + "\"");System.out.println("\n=== 尝试再次撤销 (栈已空) ===");editor.restore(history.pop()); // 无法撤销// 关键点:// - Editor 自己负责创建 Memento 和从 Memento 恢复状态。// - History (Caretaker) 只负责存储和取出 Memento,完全不关心 Memento 内部是什么。// - Editor 的内部状态 content 是 private 的,封装性得到了保护。}
}
代码解读:
EditorMemento
存储了Editor
在某个时刻的content
状态。其构造函数和getContent()
方法被设计为包级私有(或内部类私有),只允许同包的Editor
访问。对外部(如History
)来说,它几乎是一个“黑盒子”。Editor
(Originator) 有save()
方法创建EditorMemento
,以及restore()
方法接收EditorMemento
并用其内部状态恢复自己的content
。History
(Caretaker) 使用一个Stack
来存储EditorMemento
对象,实现了撤销功能。它只负责push
和pop
操作,不访问 Memento 的内部。- 客户端通过协调
Editor
和History
来实现编辑、保存状态和撤销操作。
五、模式的价值:备忘录带来的封装与恢复能力
备忘录模式的核心价值在于:
- 保护了发起人对象的封装性 (Preserves Encapsulation): 状态的捕获和恢复逻辑都在发起人内部完成,其内部实现细节(私有变量)无需暴露给外部。负责人只持有备忘录对象,无法修改其内容。
- 简化了发起人 (Simplifies Originator): 发起人不需要关心状态的存储管理(由负责人负责),它只需要知道如何创建备忘录和如何从备忘录恢复即可。
- 提供了状态恢复机制 (Provides State Restoration Mechanism): 为实现撤销、回滚、快照等功能提供了一种标准化的、结构清晰的实现方式。
- 负责人与备忘录解耦 (Decouples Caretaker and Memento): 负责人不依赖于备忘录的具体内容,使得备忘录的内部结构可以独立变化(只要对发起人的接口不变)。
六、权衡与考量:备忘录模式的成本与约束
使用备忘录模式也需要考虑:
- 资源消耗 (Resource Consumption): 如果发起人的状态非常庞大,或者需要频繁地创建备忘录(例如,每次按键都保存状态),可能会消耗大量的内存。需要考虑状态的大小和保存频率,或者采用增量式备忘录等优化策略。
- 备忘录的生命周期管理 (Memento Lifecycle Management): 负责人需要有效地管理备忘录对象的生命周期,避免存储过多无用的备忘录导致内存泄漏。例如,撤销栈的大小通常需要限制。
- 维护成本 (Maintenance Cost): 如果发起人的内部状态结构经常发生变化,那么创建备忘录 (
save
) 和从备忘录恢复 (restore
) 的逻辑也需要相应地修改。 - 窄接口与宽接口的实现: 在某些语言(如 C++)中可以通过友元(friend)机制精确实现窄接口和宽接口。在 Java 中,通常通过内部类或包级私有访问权限来模拟这种效果,但可能不如 C++ 那样严格。
七、心法归纳:封装状态,安全存取
备忘录模式的核心“心法”在于**“封装状态”与“安全存取”**:
- 封装状态 (Encapsulate State): 将对象的内部状态打包到一个独立的备忘录 (Memento) 对象中,这个对象成为状态的载体。
- 安全存取 (Safe Storage and Retrieval): 通过精心设计的访问控制(如内部类、包私有),确保只有发起人 (Originator) 能够访问备忘录的内部细节(用于创建和恢复),而负责人 (Caretaker) 只能像保管“保险箱”一样持有和传递备忘录,无法窥视或修改其内容,从而保护了发起人的封装性。
掌握备忘录模式,意味着你拥有了:
- 在不破坏封装的前提下实现对象状态快照和恢复的能力。
- 构建撤销/重做、事务回滚、游戏存档等功能的标准模式。
- 将状态管理逻辑与核心业务逻辑分离的手段。
当你需要为对象提供“时光倒流”或“状态存档”的功能,同时又极其珍视对象的封装性时,备忘录模式就是你设计工具箱中那台能够安全、精确地记录和恢复历史的“时光机”。它体现了面向对象设计中封装原则的重要性,并为状态管理提供了一种优雅而强大的解决方案。
下一章预告: 《Java 设计模式心法:状态 (State) - 让对象的行为随状态优雅切换》。当一个对象的行为会根据其内部状态的不同而发生显著变化时,如果使用大量的 if-else
来判断状态并执行相应行为,代码会变得非常复杂。状态模式将为我们展示如何将不同状态下的行为封装到独立的状态类中,让对象在状态转换时能够平滑地切换行为,如同“变身”一般。敬请期待!