Design-Pattern-Memento

备忘录模式Memento Pattern)是一种行为型设计模式,用于在不破坏封装的前提下,​捕获并外部化一个对象的内部状态,以便在需要时将对象恢复到此前保存的状态。它常被称为快照模式,典型用于实现撤销/重做(Undo/Redo)​、历史记录与回滚等能力。

Design-Pattern-Command中提到过,命令模式常和撤销操作一起实现。而撤销的一种实现方式,就是使用状态快照,也就是将需要恢复的状态,储存起来。本文详细讨论此状态快照对象如何获取、储存等。

场景

首先需要定义什么是状态。在日常应用中,状态通常指程序运行时对象的瞬时数据集合,这些数据共同定义了对象在某一时刻的具体表现。以常见的Word为例,状态体现为文本内容、段落格式(如字体、字号、颜色)、光标所在位置等信息;而在游戏中,状态则可能涵盖角色的坐标位置、生命值与攻击力等属性、背包中的道具清单等。尽管不同应用的场景差异显著,但从程序设计的角度看,状态本质上都是一组描述对象当前情况的数据,这些数据通常集中或分散存储在一个或多个对象(如文本缓冲区、角色实体类等)的属性中,共同构成了对象在特定时刻的完整快照。

要储存这些状态,最直接的想法,就是新建一个同类对象,把原对象中的属性复制到新对象中。在需要恢复的时候,再将新对象的值一一赋值给旧对象。如果这个备份和恢复的方法定义在其它类中,那么就需要此类对被备份类的所有属性有访问权限,例如将属性设为public,或者是提供所有字段的get、set方法。这显然不合适,破坏了原类的封装性。不仅如此,如果把所有字段都设置为public,还会引起额外的问题。一旦被备份类中新增了某个字段,所有负责复制的类都需要同步变动,也就是增加了其他类对原类的依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
class Status {
public String name;
public String age;
}

class Backup {
public Status backup(Status exist) {
Status backup = new Status();
backup.name = exist.name;
backup.age = exist.age;
return backup;
}
}

所以更合适的做法是将备份和恢复的方法定义在原类中。我们可以通过原型模式轻松地获取某个对象的复制。根据类的访问权限,同一个类的对象可以互相访问私有属性,因此也能轻松地恢复属性。

1
2
3
4
5
6
7
8
9
10
class Status {
private String name;
private String age;

public void recover(Status backup) {
// 直接访问备份对象的成员变量
this.name = backup.name;
this.age = backup.age;
}
}

但还是有不合适的地方。快照对象和原对象一样包含所有的方法,这些方法对于快照对象是完全不需要的。从单一职责原则考虑,快照不该有这些方法的执行权限。所以需要新建一个快照类,这个类拥有原类的所有需要备份的属性,但不包含原对象的方法,就像生成器模式中的生成器类。但新建的快照类和原对象类,又会产生属性访问权限的问题。快照对象需要被用来恢复原对象,这意味着原类有快照类的属性访问权限,或快照有原类的属性访问权限。

备忘录模式

总的来说,上述描述讨论了为了实现状态储存、恢复的几个问题:

1.状态存哪里?

2.产生备份、从备份中恢复的方法定义在哪里?

3.快照类、原类、其他类之间的互相访问权限如何?

对于第1点,从单一职责原则考虑,肯定需要新建一个快照类

第2和第3点是互相关联的,需要一起考虑。从上述例子中,可以看出定义在其它类中不太合适,因为其他类不该有快照类、原类的状态访问权限和依赖。而对于原类和快照类,二者通常为一对多关系。原对象生成多个快照,并由原对象主动选择快照恢复。从语义上看,备份/恢复操作的发起方应为原类,故建议将相关方法定义在原类中

备份/恢复方法体中需要完成原对象和快照对象的状态赋值。当方法定义在原类中,那么原类需要有对快照类状态的访问权限,同时不希望快照类的状态暴露给除原类外的其他类。这种访问权限的限制,在不同语言中有不同处理方法。

java中可以使用嵌套类的特性来实现上述要求,具体来说是将快照类定义为public static的嵌套类。public意味着其他类可以引用、储存快照类,static意味着快照对象不是必须引用原类对象。同时将快照类的状态设置为private,此时只有其外部类拥有其状态的访问权限,其他类不能访问。如果其他类不需要引用快照对象,而是由原类自行存储,那可以进一步把类的访问修饰改为private,实现更好的封装性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// Originator.java
public class Originator {
private String state;

public Memento save() {
return new Memento(state);
}

public void restore(Memento m) {
this.state = m.savedState;
}

public static class Memento {
private final String savedState;

private Memento(String state) {
this.savedState = state;
}
}
}

在一些不支持嵌套类的语言中,无法使用上面这个方案。备选的方案使用接口,其他类引用的是备忘录接口,来避免其他类对具体备忘录类的依赖。整体来说,这些使用接口的方案有点过度设计的感觉,不推荐也不过多介绍。

至此,整个备忘录模式已经介绍完毕了,规范下此模式中的角色术语:

  • 原发器Originator)类可以生成自身状态的快照,也可以在需要时通过快照恢复自身状态。
  • 备忘录Memento)是原发器状态快照的值对象。通常做法是将备忘录设为不可变的,并通过构造函数一次性传递数据。
  • 负责人Caretaker)仅知道“何时”和“为何”捕捉原发器的状态,以及何时恢复状态。负责人通过保存备忘录栈来记录原发器的历史状态。当原发器需要回溯历史状态时,负责人将从栈中获取最顶部的备忘录,并将其传递给原发器的恢复(restoration)方法。

撤销操作通常针对最近一次变更,也就是说,最后保存的状态快照往往是最先被使用的。这种后进先出的特性,与栈的数据结构天然契合。因此,在备忘录模式中,负责人通常会采用栈来管理历史快照:当进行状态备份时,将新的快照压入栈顶;当需要恢复状态时,则从栈顶弹出最近保存的快照。这种设计不仅逻辑直观,也符合用户对于撤销操作的普遍预期。

在一些场景下,原发器和负责人可以是同一个角色。例如在一个简单的文本编辑器工具中,该工具类既负责管理当前编辑的内容(即作为原发器,创建和恢复文本状态的快照),又负责维护编辑过程中的历史版本(即作为负责人,保存和管理这些快照)。由于功能相对单一、状态管理逻辑不复杂,将这两个角色合并到同一个类中,一定程度违背了单一职责原则,但能避免为少量状态管理单独拆分类所带来的额外复杂度。

总结

备忘录模式的动机,是在复制、恢复原发器的状态的同时,不破坏封装性。

备忘录模式使用备忘录类,储存原发器的状态。通过将其设置嵌套类,调整访问修饰符,来避免破坏封装性。

负责人通常使用栈保存备忘录对象。


Design-Pattern-Memento
http://dracoyus.github.io/2025/09/09/Design-Pattern-Memento/
作者
DracoYu
发布于
2025年9月9日
许可协议