意图
备忘录模式是一种行为设计模式,允许在不暴露对象实现细节的情况下保存和恢复对象之前的状态。

问题
假如你正在开发一款文字编辑器应用程序。除了简单的文字编辑功能外,编辑器中还要有设置文本格式和插入内嵌图片等功能。
后来,你决定让用户能撤销施加在文本上的任何操作。这项功能在过去几年里变得十分普遍,因此用户期待任何程序都有这项功能。你选择采用直接的方式来实现该功能:程序在执行任何操作前会记录所有的对象状态,并将其保存下来。当用户此后需要撤销某个操作时,程序将会从历史记录中获取最近的快照,然后使用它来恢复所有对象的状态。

让我们来思考一下这些状态快照。首先,到底该如何生成一个快照呢?很可能你会需要遍历对象的所有成员变量并将其数值复制保存。但只有当对象对其内容没有严格访问权限限制的情况下,你才能使用该方式。不过很遗憾,绝大部分对象会使用私有成员变量来存储重要数据,这样别人就无法轻易查看其中的内容。
现在我们暂时忽略这个问题,假设对象都想嬉皮士一样:喜欢开放式的关系并会公开其所有状态。尽管这种方式能够解决当前问题,让你可随时生成对象的状态快照,但这种方式仍存在一些严重问题。未来你可能会添加或删除一些成员变量。这听上去很简单,但需要对负责复制受影响对象状态的类进行更改。

还有更多问题。让我们来考虑编辑器状态的实际”快照“,它需要包含哪些数据?至少必须包含实际的文本、光标坐标和当前滚动条位置等。你需要收集这些数据并将其放入特定容器中,才能生成快照。
你很可能会将大量的容器对象存储在历史纪录列表中。这样一来,容器最终大概率会成为同一个类的对象。这个类中几乎没有任何方法,但有许多与编辑器状态一一对用的成员变量。为了让其他对象能保存或读取快照,你很可能需要将快照的成员变量设为共有。无论这些状态是否私有,其都将暴露一切编辑器状态。其他类会对快照类的每一个小改动产生依赖,除非这些改动仅存在于私有成员变量或方法中,而不会影响外部类。
我们似乎走进了一条死胡同:要么会暴露类的所有内部细节而使其过于脆弱;要么会限制对其状态的访问权限而无法生成快照。那么,我们还有其他方式来实现”撤销“功能吗?
解决方案
我们刚才遇到的所有问题都是封装“破损”造成的。一些对象试图超出其职责范围的工作。由于在执行某些行为时需要获取数据,所以它们侵入了其他对象的私有空间,而不是让这些对象来完成实际的工作。
备忘录模式将创建状态快照(Snapshot)的工作委派给实际状态的拥有者原发器(Originator)对象。这样其他对象就不再需要从”外部“复制编辑器状态了,编辑器类拥有其状态的完全访问权,因此可以自行生成快照。
模式建议将对象状态的副本存储在一个名为备忘录(Memento)的特殊对象中。除了创建备忘录的对象外,任何对象都不能访问备忘录的内容。其他对象必须使用受限接口与备忘录进行交互,它们可以获取快照的元数据(创建时间和操作名称等),但不能获取快照中原始对象的状态。

这种限制策略允许你将备忘录保存在通常被负责人(Caretakers)的对象中。由于负责人仅通过受限接口与备忘录互动,故其无法修改存储在备忘录内部的状态。同时,原发器拥有对备忘录所有成员的访问权限,从而能随时恢复其以前的状态。
在文字编辑器的示例中,我们可以创建一个独立的历史(History)类作为负责人。编辑器每次执行操作前,存储在负责人中的备忘录栈都会生长。你甚至可以在应用的UI中渲染该栈,为用户显示之前的操作历史。
当用户触发撤销操作时,历史类将从栈中取回最近的备忘录,并将其传递给编辑器以请求进行回滚。由于编辑器拥有对备忘录的完全访问权限,因此它可以使用从备忘录中获取的数值来替换自身的状态。
备忘录模式结构
基于嵌套类的实现
该模式的经典实现方式依赖于许多流行编程语言(例如C++、C#和Java)所支持的嵌套类。

基于中间接口的实现
另外一种给实现方式适用于不支持嵌套类的编程语言(PHP)。

封装更加严格的实现
如果你不想让其他类有任何机会通过备忘录来访问原发器的状态,那么还有另一种可用的实现方式。

备忘录模式适合应用场景
当你需要创建对象状态快照来恢复其之前的状态时,可以使用备忘录模式。
备忘录模式允许你复制对象中的全部状态(包括私有成员变量),并将其独立于对象进行保存。尽管大部分人因为”撤销“这个用例才记得该模式,但其实它在处理事务的过程中也必不可少。
当直接访问对象的成员变量、获取器或设置器将导致封装被突破时,可以使用该模式。
备忘录让对象自行负责创建其状态的快照。任何其他对象都不能读取快照,这有效地保障了数据的安全性。
实现方式
-
确定担任原发器角色的类。 重要的是明确程序使用的一个原发器中心对象, 还是多个较小的对象。
-
创建备忘录类。 逐一声明对应每个原发器成员变量的备忘录成员变量。
-
将备忘录类设为不可变。 备忘录只能通过构造函数一次性接收数据。 该类中不能包含设置器。
-
如果你所使用的编程语言支持嵌套类, 则可将备忘录嵌套在原发器中; 如果不支持, 那么你可从备忘录类中抽取一个空接口, 然后让其他所有对象通过接口来引用备忘录。 你可在该接口中添加一些元数据操作, 但不能暴露原发器的状态。
-
在原发器中添加一个创建备忘录的方法。 原发器必须通过备忘录构造函数的一个或多个实际参数来将自身状态传递给备忘录。
该方法返回结果的类型必须是你在上一步中抽取的接口 (如果你已经抽取了)。 实际上, 创建备忘录的方法必须直接与备忘录类进行交互。
-
在原发器类中添加一个用于恢复自身状态的方法。 该方法接受备忘录对象作为参数。 如果你在之前的步骤中抽取了接口, 那么可将接口作为参数的类型。 在这种情况下, 你需要将输入对象强制转换为备忘录, 因为原发器需要拥有对该对象的完全访问权限。
-
无论负责人是命令对象、 历史记录或其他完全不同的东西, 它都必须要知道何时向原发器请求新的备忘录、 如何存储备忘录以及何时使用特定备忘录来对原发器进行恢复。
-
负责人与原发器之间的连接可以移动到备忘录类中。 在本例中, 每个备忘录都必须与创建自己的原发器相连接。 恢复方法也可以移动到备忘录类中, 但只有当备忘录类嵌套在原发器中, 或者原发器类提供了足够多的设置器并可对其状态进行重写时, 这种方式才能实现。
备忘录模式优缺点
优点:
- 你可以在不破坏对象封装情况的前提下创建对象状态快照。
- 你可以通过让负责人维护原发器状态历史记录来简化原发器代码。
缺点:
- 如果客户端过于频繁地创建备忘录,程序将消耗大量内存。
- 负责人必须完整跟踪原发器的生命周期,这样才能销毁弃用的备忘录。
- 绝大部分动态语言(例如PHP、Python和JavaScript)不能确保备忘录中的状态不被修改。
与其他模式的关系
- 你可以同时使用命令模式和备忘录模式来实现 “撤销”。 在这种情况下, 命令用于对目标对象执行各种不同的操作, 备忘录用来保存一条命令执行前该对象的状态。
- 你可以同时使用备忘录和迭代器模式来获取当前迭代器的状态, 并且在需要的时候进行回滚。
- 有时候原型模式可以作为备忘录的一个简化版本, 其条件是你需要在历史记录中存储的对象的状态比较简单, 不需要链接其他外部资源, 或者链接可以方便地重建。
代码示例
原发器和备忘录:
@Data
@AllArgsConstructor
public class Chess {
private int x;
private int y;
public ChessMemento save(){
return new ChessMemento(x, y);
}
public void restore(ChessMemento memento){
this.x = memento.x;
this.y = memento.y;
}
@Data
@AllArgsConstructor
public class ChessMemento{
private int x;
private int y;
}
}
负责人:
public class ChessCaretaker {
private Chess chess;
private Stack<Chess.ChessMemento> history = new Stack<>();
public void doChess(Chess chess){
this.chess = chess;
Chess.ChessMemento save = chess.save();
history.push(save);
System.out.println("落子在(" + chess.getX() + "," + chess.getY() + ")");
}
public void undo(){
history.pop();
Chess.ChessMemento peek = history.peek();
chess.restore(peek);
System.out.println("悔棋,棋子在(" + chess.getX() + "," + chess.getY() + ")");
}
}
客户端:
public class Demo {
public static void main(String[] args) {
ChessCaretaker caretaker = new ChessCaretaker();
caretaker.doChess(new Chess(1, 2));
caretaker.doChess(new Chess(2, 2));
caretaker.doChess(new Chess(5, 8));
caretaker.undo();
caretaker.undo();
}
}
//落子在(1,2)
//落子在(2,2)
//落子在(5,8)
//悔棋,棋子在(2,2)
//悔棋,棋子在(1,2)