备忘录模式

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

../../_images/memento.png

问题

假如你正在开发一款文字编辑器应用程序。 除了简单的文字编辑功能外, 编辑器中还要有设置文本格式和插入内嵌图片等功能。

后来, 你决定让用户能撤销施加在文本上的任何操作。 这项功能在过去几年里变得十分普遍, 因此用户期待任何程序都有这项功能。 你选择采用直接的方式来实现该功能: 程序在执行任何操作前会记录所有的对象状态, 并将其保存下来。 当用户此后需要撤销某个操作时,程序将从历史记录中获取最近的快照, 然后使用它来恢复所有对象的状态。

../../_images/memo_problem1.png

让我们来思考一下这些状态快照。 首先, 到底该如何生成一个快照呢? 很可能你会需要遍历对象的所有成员变量并将其数值复制保存。 但只有当对象对其内容没有严格访问权限限制的情况下, 你才能使用该方式。 不过很遗憾, 绝大部分对象会使用私有成员变量来存储重要数据, 这样别人就无法轻易查看其中的内容。

现在我们暂时忽略这个问题, 假设对象都像嬉皮士一样: 喜欢开放式的关系并会公开其所有状态。 尽管这种方式能够解决当前问题, 让你可随时生成对象的状态快照, 但这种方式仍存在一些严重问题。 未来你可能会添加或删除一些成员变量。 这听上去很简单, 但需要对负责复制受影响对象状态的类进行更改。

../../_images/memo_problem2.png

还有更多问题。 让我们来考虑编辑器 (Editor) 状态的实际 “快照”, 它需要包含哪些数据?至少必须包含实际的文本、 光标坐标和当前滚动条位置等。 你需要收集这些数据并将其放入特定容器中, 才能生成快照。

你很可能会将大量的容器对象存储在历史记录列表中。 这样一来, 容器最终大概率会成为同一个类的对象。 这个类中几乎没有任何方法, 但有许多与编辑器状态一一对应的成员变量。为了让其他对象能保存或读取快照, 你很可能需要将快照的成员变量设为公有。 无论这些状态是否私有, 其都将暴露一切编辑器状态。 其他类会对快照类的每个小改动产生依赖, 除非这些改动仅存在于私有成员变量或方法中, 而不会影响外部类。

我们似乎走进了一条死胡同: 要么会暴露类的所有内部细节而使其过于脆弱; 要么会限制对其状态的访问权限而无法生成快照。 那么, 我们还有其他方式来实现 “撤销” 功能吗?

解决方案

我们刚才遇到的所有问题都是封装 “破损” 造成的。 一些对象试图超出其职责范围的工作。由于在执行某些行为时需要获取数据, 所以它们侵入了其他对象的私有空间, 而不是让这些对象来完成实际的工作。

备忘录模式将创建状态快照 (Snapshot) 的工作委派给实际状态的拥有者原发器(Originator) 对象。 这样其他对象就不再需要从 “外部” 复制编辑器状态了, 编辑器类拥有其状态的完全访问权, 因此可以自行生成快照。

模式建议将对象状态的副本存储在一个名为备忘录 (Memento) 的特殊对象中。 除了创建备忘录的对象外, 任何对象都不能访问备忘录的内容。 其他对象必须使用受限接口与备忘录进行交互, 它们可以获取快照的元数据 (创建时间和操作名称等), 但不能获取快照中原始对象的状态。

../../_images/memo_solution.png

这种限制策略允许你将备忘录保存在通常被称为负责人 (Caretakers) 的对象中。 由于负责人仅通过受限接口与备忘录互动, 故其无法修改存储在备忘录内部的状态。 同时, 原发器拥有对备忘录所有成员的访问权限, 从而能随时恢复其以前的状态。

在文字编辑器的示例中, 我们可以创建一个独立的历史 (History) 类作为负责人。 编辑器每次执行操作前, 存储在负责人中的备忘录栈都会生长。 你甚至可以在应用的 UI 中渲染该栈,为用户显示之前的操作历史。

当用户触发撤销操作时, 历史类将从栈中取回最近的备忘录, 并将其传递给编辑器以请求进行回滚。 由于编辑器拥有对备忘录的完全访问权限, 因此它可以使用从备忘录中获取的数值来替换自身的状态。

备忘录模式结构

../../_images/memo_structure1.png

  • 原发器 (Originator) 类可以生成自身状态的快照, 也可以在需要时通过快照恢复自身状态。
  • 备忘录 (Memento) 是原发器状态快照的值对象(value object)。 通常做法是将备忘录设为不可变的, 并通过构造函数一次性传递数据。
  • 负责人 (Caretaker) 仅知道 “何时” 和 “为何”捕捉原发器的状态, 以及何时恢复状态。
    • 负责人通过保存备忘录栈来记录原发器的历史状态。当原发器需要回溯历史状态时, 负责人将从栈中获取最顶部的备忘录, 并将其传递给原发器的恢复(restoration) 方法。
  • 在该实现方法中, 备忘录类将被嵌套在原发器中。 这样原发器就可访问备忘录的成员变量和方法, 即使这些方法被声明为私有。 另一方面, 负责人对于备忘录的成员变量和方法的访问权限非常有限: 它们只能在栈中保存备忘录, 而不能修改其状态。

基于中间接口的实现

另外一种实现方法适用于不支持嵌套类的编程语言:

../../_images/memo_structure2.png

  • 在没有嵌套类的情况下, 你可以规定负责人仅可通过明确声明的中间接口与备忘录互动,该接口仅声明与备忘录元数据相关的方法,限制其对备忘录成员变量的直接访问权限。
  • 另一方面, 原发器可以直接与备忘录对象进行交互, 访问备忘录类中声明的成员变量和方法。 这种方式的缺点在于你需要将备忘录的所有成员变量声明为公有。

封装更加严格的实现

../../_images/memo_structure3.png

  • 这种实现方式允许存在多种不同类型的原发器和备忘录。 每种原发器都和其相应的备忘录类进行交互。 原发器和备忘录都不会将其状态暴露给其他类。
  • 负责人此时被明确禁止修改存储在备忘录中的状态。 但负责人类将独立于原发器, 因为此时恢复方法被定义在了备忘录类中。
  • 每个备忘录将与创建了自身的原发器连接。 原发器会将自己及状态传递给备忘录的构造函数。 由于这些类之间的紧密联系, 只要原发器定义了合适的设置器 (setter),备忘录就能恢复其状态。

代码示例

//@ 日期时间
class DateTime
{
public:
	DateTime(std::string dt) : dateTime_(dt) {}

	void setDateTime(std::string dt) {
		dateTime_ = dt;
	}

	std::string getDateTime() {
		return dateTime_;
	}

private:
	std::string dateTime_;
};

//@ 一生
class Life
{
public:
	void setDateTime(std::string dt) {
		std::cout << "Set date time to " << dt << std::endl;
		dateTime_ = dt;
	}

	//@ 仅用于打印
	std::string getDateTime() {
		return dateTime_;
	}

	//@ 恢复日期时间
	void setMemento(std::shared_ptr<DateTime> dt) {
		dateTime_ = dt->getDateTime();
	}

	//@ 创建日期时间
	std::shared_ptr<DateTime> createMemento() {
		return std::make_shared<DateTime>(dateTime_);
	}

private:
	std::string dateTime_;
};

//@ 月光宝盒
class PandoraBox
{
public:
	PandoraBox(std::shared_ptr<Life> life)
		: pLife_(life) {}

	//@ 保存备份
	void save() {
		std::cout << "Save ..." << std::endl;;
		history_.push_back(pLife_->createMemento());
	}

	//@ 穿越至上一时刻
	void undo() {
		std::cout << "Undo ..." << std::endl;;
		pLife_->setMemento(history_.back());
		history_.pop_back();
	}

private:
	std::shared_ptr<Life> pLife_;
	std::vector<std::shared_ptr<DateTime>> history_;
};

int main()
{
	std::shared_ptr<Life> life(new Life());
	std::shared_ptr<PandoraBox> box(new PandoraBox(life));

	//@ 设置并保存一个历史时间
	life->setDateTime("2000/10/01 00:00:00");
	box->save();

	//@ 设置并保存一个历史时间
	life->setDateTime("2010/10/01 00:00:00");
	box->save();

	//@ 设置一个历史时间
	life->setDateTime("2018/10/01 00:00:00");

	//@ 穿越
	box->undo();
	std::cout << "Actual date time is " << life->getDateTime() << std::endl;

	//@ 再次穿越
	box->undo();
	std::cout << "Actual date time is " << life->getDateTime() << std::endl;

	return 0;
}

备忘录模式总结

实现方式

  • 确定担任原发器角色的类。 重要的是明确程序使用的一个原发器中心对象, 还是多个较小的对象。
  • 创建备忘录类。 逐一声明对应每个原发器成员变量的备忘录成员变量。
  • 将备忘录类设为不可变。 备忘录只能通过构造函数一次性接收数据。 该类中不能包含设置器。
  • 如果你所使用的编程语言支持嵌套类, 则可将备忘录嵌套在原发器中; 如果不支持, 那么你可从备忘录类中抽取一个空接口, 然后让其他所有对象通过接口来引用备忘录。 你可在该接口中添加一些元数据操作, 但不能暴露原发器的状态。
  • 在原发器中添加一个创建备忘录的方法。 原发器必须通过备忘录构造函数的一个或多个实际参数来将自身状态传递给备忘录。
    • 该方法返回结果的类型必须是你在上一步中抽取的接口 (如果你已经抽取了)。 实际上,创建备忘录的方法必须直接与备忘录类进行交互。
  • 在原发器类中添加一个用于恢复自身状态的方法。 该方法接受备忘录对象作为参数。 如果你在之前的步骤中抽取了接口, 那么可将接口作为参数的类型。 在这种情况下, 你需要将输入对象强制转换为备忘录, 因为原发器需要拥有对该对象的完全访问权限。
  • 无论负责人是命令对象、 历史记录或其他完全不同的东西, 它都必须要知道何时向原发器请求新的备忘录、 如何存储备忘录以及何时使用特定备忘录来对原发器进行恢复。
  • 负责人与原发器之间的连接可以移动到备忘录类中。 在本例中, 每个备忘录都必须与创建自己的原发器相连接。 恢复方法也可以移动到备忘录类中, 但只有当备忘录类嵌套在原发器中, 或者原发器类提供了足够多的设置器并可对其状态进行重写时, 这种方式才能实现。

优点

  • 可以在不破坏对象封装情况的前提下创建对象状态快照。
  • 可以通过让负责人维护原发器状态历史记录来简化原发器代码。

缺点

  • 如果客户端过于频繁地创建备忘录, 程序将消耗大量内存。
  • 负责人必须完整跟踪原发器的生命周期,这样才能销毁弃用的备忘录。
  • 绝大部分动态编程语言 (例如 PHP、Python 和 JavaScript) 不能确保备忘录中的状态不被修改。

适用场景

  • 保存一个对象在某一个时刻的全部状态或部分状态,这样以后需要时它能够恢复到先前的状态,实现撤销操作。
  • 防止外界对象破坏一个对象历史状态的封装性,避免将对象历史状态的实现细节暴露给外界对象。