【设计模式精讲】23.备忘录模式(Memento)
【摘要】:命令模式靠「逆操作」撤销了绘图程序,但文本编辑器是另一回事------用户随手打字删字、移动光标,操作琐碎得没有形状。两条歪路摆在面前:为存档把字段全 public,封装陪葬;或者整个对象拷一份,句柄崩、「哪些字段属于状态」没人说得清。本文给出备忘录模式的 GoF 意图:在不破坏封装的前提下捕获并外化对象内部状态------C++ 用嵌套类加 friend 精确表达「只有本体能拆信、看守者只管邮递」的宽窄接口;现代版补上移动语义、快照与命令的成本曲线(大型系统的混合方案),以及值语义快照的取舍。文末对照
std::exception_ptr、folly 的 exception_wrapper 与 AOSP 的状态保存协议。读完你能为「回到过去」选对那条路线。【关键词】:备忘录、快照、撤销、封装、看守者、宽窄接口、事务回滚
【代码基准】:C++17
1. 撤销的另一种姿势:把「当时」存下来
第 19 篇用命令模式解了绘图程序的撤销:每个操作规整、可逆,命令对象自带 undo。现在换一个主角------文本编辑器的 Document:用户打一个字、删一个字、光标跳来跳去,操作琐碎到没有形状,「为每个字符输入写一个命令类」荒谬得说不出口。需求还是那个 Ctrl+Z,直觉给出两条路:
cpp
// 说明性片段
// ❌ 路 A:为存档打开封装
class Document {
public:
std::string text; // 全部 public
int cursor; // 谁都能改,
Selection sel; // 不变量没人守
};
// 历史栈直接搬字段:
// hist.push_back({doc.text, doc.cursor});
// ------ 漏存了选区,恢复后选区悬垂
// ❌ 路 B:整个对象拷一份
// hist2.push_back(doc); // 要求可拷贝;
// 内部若有缓存/句柄/自指指针,
// 拷出来的就是一颗雷
路 A 让「不变量由类自己守」的封装原则为撤销功能陪葬------第 2 篇讲过的边界一旦打开就关不上;路 B 把「哪些字段属于状态」的定义权交给了拷贝构造------多拷浪费内存,少拷恢复不完整,含资源的字段(打开的文件、注册的回调)根本拷不动。
两条路背后是同一个矛盾命题:「恢复过去」要求外部能拿到内部状态,「封装」要求外部拿不到 。备忘录模式的全部智慧就是解开这一对矛盾------答案不是二选一,而是把「状态」打包成一件外部能保管、但不能拆开的东西。
2. 模式意图与定义
- 一句话定义 :在不破坏封装性的前提下,捕获一个对象的内部状态,并在该对象之外保存这个状态,从而可以将该对象恢复到原先保存的状态。
解决的问题:需要快照式回滚(撤销、事务、存档、回溯),而对象的内部表示不能对外暴露。 - GoF 原文意图 :Without violating encapsulation, capture and externalize an object's internal state so that the object can be restored to this state later. 注意打头的 Without violating encapsulation------这不是附带的好处,是模式的立身之本,也是它与「字段全 public 加一个 copy」的分界线。
- Refactoring Guru 的表述:备忘录模式允许在不暴露所保存状态实现细节的前提下,保存与恢复对象先前的状态;它像是对象的「时刻存档」,外界可以传递存档、保管存档,但看不懂存档。
三条定性:
- 保管权与解释权分离 。看守者(Caretaker)只做三件事------存、传、丢;只有原发器(Originator)能写快照、读快照。快照对外界是不透明的黑盒,「不透明」正是封装没有破裂的证明。
- 宽接口与窄接口 。GoF 用两个视图描述同一份快照:原发器看到宽接口(能读写全部状态),看守者看到窄接口(只有「这是一份快照」的存在性)。C++ 有一把恰好合手的刀------
friend:把宽接口精确地只授予原发器,第 4 节落地。 - 与命令模式的分工。第 19 篇记「做了什么」(操作 + 逆操作),本篇记「当时什么样」(状态快照)------这是撤销的两条路线,成本曲线完全不同,第 5 节正面比较。
3. UML 图 + 结构说明
#mermaid-svg-cSN6hYuFnBXE4P4Y{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-cSN6hYuFnBXE4P4Y .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-cSN6hYuFnBXE4P4Y .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-cSN6hYuFnBXE4P4Y .error-icon{fill:#552222;}#mermaid-svg-cSN6hYuFnBXE4P4Y .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-cSN6hYuFnBXE4P4Y .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-cSN6hYuFnBXE4P4Y .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-cSN6hYuFnBXE4P4Y .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-cSN6hYuFnBXE4P4Y .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-cSN6hYuFnBXE4P4Y .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-cSN6hYuFnBXE4P4Y .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-cSN6hYuFnBXE4P4Y .marker{fill:#333333;stroke:#333333;}#mermaid-svg-cSN6hYuFnBXE4P4Y .marker.cross{stroke:#333333;}#mermaid-svg-cSN6hYuFnBXE4P4Y svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-cSN6hYuFnBXE4P4Y p{margin:0;}#mermaid-svg-cSN6hYuFnBXE4P4Y g.classGroup text{fill:#9370DB;stroke:none;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:10px;}#mermaid-svg-cSN6hYuFnBXE4P4Y g.classGroup text .title{font-weight:bolder;}#mermaid-svg-cSN6hYuFnBXE4P4Y .cluster-label text{fill:#333;}#mermaid-svg-cSN6hYuFnBXE4P4Y .cluster-label span{color:#333;}#mermaid-svg-cSN6hYuFnBXE4P4Y .cluster-label span p{background-color:transparent;}#mermaid-svg-cSN6hYuFnBXE4P4Y .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-cSN6hYuFnBXE4P4Y .cluster text{fill:#333;}#mermaid-svg-cSN6hYuFnBXE4P4Y .cluster span{color:#333;}#mermaid-svg-cSN6hYuFnBXE4P4Y .nodeLabel,#mermaid-svg-cSN6hYuFnBXE4P4Y .edgeLabel{color:#131300;}#mermaid-svg-cSN6hYuFnBXE4P4Y .edgeLabel .label rect{fill:#ECECFF;}#mermaid-svg-cSN6hYuFnBXE4P4Y .label text{fill:#131300;}#mermaid-svg-cSN6hYuFnBXE4P4Y .labelBkg{background:#ECECFF;}#mermaid-svg-cSN6hYuFnBXE4P4Y .edgeLabel .label span{background:#ECECFF;}#mermaid-svg-cSN6hYuFnBXE4P4Y .classTitle{font-weight:bolder;}#mermaid-svg-cSN6hYuFnBXE4P4Y .node rect,#mermaid-svg-cSN6hYuFnBXE4P4Y .node circle,#mermaid-svg-cSN6hYuFnBXE4P4Y .node ellipse,#mermaid-svg-cSN6hYuFnBXE4P4Y .node polygon,#mermaid-svg-cSN6hYuFnBXE4P4Y .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-cSN6hYuFnBXE4P4Y .divider{stroke:#9370DB;stroke-width:1;}#mermaid-svg-cSN6hYuFnBXE4P4Y g.clickable{cursor:pointer;}#mermaid-svg-cSN6hYuFnBXE4P4Y g.classGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-cSN6hYuFnBXE4P4Y g.classGroup line{stroke:#9370DB;stroke-width:1;}#mermaid-svg-cSN6hYuFnBXE4P4Y .classLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-svg-cSN6hYuFnBXE4P4Y .classLabel .label{fill:#9370DB;font-size:10px;}#mermaid-svg-cSN6hYuFnBXE4P4Y .relation{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-cSN6hYuFnBXE4P4Y .dashed-line{stroke-dasharray:3;}#mermaid-svg-cSN6hYuFnBXE4P4Y .dotted-line{stroke-dasharray:1 2;}#mermaid-svg-cSN6hYuFnBXE4P4Y #compositionStart,#mermaid-svg-cSN6hYuFnBXE4P4Y .composition{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-cSN6hYuFnBXE4P4Y #compositionEnd,#mermaid-svg-cSN6hYuFnBXE4P4Y .composition{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-cSN6hYuFnBXE4P4Y #dependencyStart,#mermaid-svg-cSN6hYuFnBXE4P4Y .dependency{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-cSN6hYuFnBXE4P4Y #dependencyStart,#mermaid-svg-cSN6hYuFnBXE4P4Y .dependency{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-cSN6hYuFnBXE4P4Y #extensionStart,#mermaid-svg-cSN6hYuFnBXE4P4Y .extension{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-cSN6hYuFnBXE4P4Y #extensionEnd,#mermaid-svg-cSN6hYuFnBXE4P4Y .extension{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-cSN6hYuFnBXE4P4Y #aggregationStart,#mermaid-svg-cSN6hYuFnBXE4P4Y .aggregation{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-cSN6hYuFnBXE4P4Y #aggregationEnd,#mermaid-svg-cSN6hYuFnBXE4P4Y .aggregation{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-cSN6hYuFnBXE4P4Y #lollipopStart,#mermaid-svg-cSN6hYuFnBXE4P4Y .lollipop{fill:#ECECFF!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-cSN6hYuFnBXE4P4Y #lollipopEnd,#mermaid-svg-cSN6hYuFnBXE4P4Y .lollipop{fill:#ECECFF!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-cSN6hYuFnBXE4P4Y .edgeTerminals{font-size:11px;line-height:initial;}#mermaid-svg-cSN6hYuFnBXE4P4Y .classTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-cSN6hYuFnBXE4P4Y .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-cSN6hYuFnBXE4P4Y .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-cSN6hYuFnBXE4P4Y :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 创建(宽接口)
恢复(宽接口)
保管(窄接口)
把快照交回原发器
Document
-text_ string
-cursor_ int
+create() : Memento
+restore(m) : void
<<不透明>>
Memento
-text_ string
-cursor_ int
History
-stack_ vector
+save(m) : void
+pop() : Memento
三个参与者(GoF 命名):
- 原发器(Originator) :
Document------创建快照(把内部状态封进备忘录)、恢复快照(从备忘录写回),是唯一能解释快照内容的角色; - 备忘录(Memento):状态的黑盒封装------对看守者只暴露「存在」,对原发器暴露全部;
- 看守者(Caretaker) :
History------保管快照的栈、队列或磁盘,负责在正确的时机把快照原样递回原发器,从头到尾不拆不看。
一个贴切的意象:备忘录是密封信。写信人将来还要读这封信(同一个原发器恢复),中间的邮差(看守者)只管投递保管------拆信越权,抄送更不可能。这个意象也预告了工程上的全部要点:信封要结实(快照不可变)、邮路要可靠(生命周期对齐)、信纸要够小(快照只存恢复必需的状态)。
4. 传统 C++ 写法(C++11 之前)
C++98 完整形态:备忘录做成原发器的嵌套类,构造私有、friend 授信------「宽窄接口」从图上的两个视图变成编译器强制的事实:
cpp
// C++98/03 写法
#include <cstdio>
#include <string>
#include <vector>
class Document {
public:
Document() : cursor_(0) {}
void type(char c) {
text_.insert(cursor_, 1, c);
++cursor_;
}
void backspace() {
if (cursor_ == 0)
return;
text_.erase(cursor_ - 1, 1);
--cursor_;
}
void print() const {
printf("[%s|%d]\n", text_.c_str(),
cursor_);
}
// ---- 备忘录:密封的状态快照 ----
class Memento {
public:
~Memento() {}
private:
friend class Document; // 只有本体
// 能拆信
Memento(const std::string& t, int c)
: text_(t), cursor_(c) {}
Memento(const Memento&); // 禁复制:
Memento& operator=( // 快照不可变
const Memento&);
std::string text_;
int cursor_;
};
Memento* create() const {
return new Memento(text_, cursor_);
}
void restore(const Memento* m) {
text_ = m->text_; // 宽接口:
cursor_ = m->cursor_; // 只有这里能读
}
private:
std::string text_;
int cursor_;
};
// ---- 看守者:只存、只传、不拆 ----
class History {
public:
~History() {
for (size_t i = 0; i < stack_.size();
++i)
delete stack_[i];
}
void save(Document::Memento* m) {
stack_.push_back(m);
}
Document::Memento* pop() {
if (stack_.empty())
return 0;
Document::Memento* m =
stack_.back();
stack_.pop_back();
return m;
}
private:
std::vector<Document::Memento*> stack_;
};
int main() {
Document doc;
History hist;
hist.save(doc.create()); // 存档 ""
doc.type('a');
doc.type('b');
hist.save(doc.create()); // 存档 "ab"
doc.type('c');
doc.print(); // [abc|3]
doc.restore(hist.pop()); // 回到 "ab"
doc.print(); // [ab|2]
return 0;
}
History 面对的是什么?一个只能 new 出来、delete 掉、拿来拿去的指针类型 ------它想把 text_ 读出来编译器都不答应。第 1 节路 A 的「封装陪葬」与路 B 的「拷贝语义含混」同时避开:哪些字段属于状态,只有 Document::create 一个地方说了算。
三条传统写法的铁律:宽接口只授给原发器 ------friend class Document 一行就是宽窄接口的全部实现,其他语言要用双接口/包可见性绕的圈,C++ 一词到位;快照不可变 ------构造之后无 setter,禁拷贝禁赋值(现代版可放宽为可移动),「邮差篡改历史」在类型层面消失;看守者零解释权 ------History 的全部代码里没有出现任何状态字段的名字,这行检查标准值得写进评审清单。
5. 现代 C++ 进阶写法
升级零:unique_ptr 管快照,移动语义流转。裸指针的创建/删除分工交给类型,快照成为移动专用对象:
cpp
// 节选(Document 定义同第 4 节,略)
#include <memory>
#include <vector>
class History {
public:
void save(
std::unique_ptr<Document::Memento> m) {
stack_.push_back(std::move(m));
}
std::unique_ptr<Document::Memento>
pop() {
if (stack_.empty())
return nullptr;
auto m = std::move(stack_.back());
stack_.pop_back();
return m;
}
private:
std::vector<
std::unique_ptr<Document::Memento>>
stack_;
};
// doc.restore(hist.pop().get());
// 或让 restore 直接接收 unique_ptr
Memento 的私有禁拷贝 hack 换成 = delete,其余纪律原样成立。
改进一:快照还是命令?三条撤销路线的成本曲线。第 19 篇与本篇合起来才是撤销的完整地图:
- 命令路线(记操作):内存省(每步只存参数与逆操作),但要求操作规整可逆------适合绘图这类「操作有形状」的领域;
- 快照路线(记状态):实现简单、绝对正确(不依赖「逆操作」的正确性),但状态大、步数深时内存爆炸------适合文档这类「自由编辑」的小状态对象;
- 混合路线 (工业标配):每 N 步落一个快照,中间步骤用命令回放------撤销 M 步 = 回滚到最近快照再正向重放剩下的(N−M)步。数据库 WAL、游戏 checkpoint、编辑器的分档 undo 全是这条路线的变体:快照定锚点,命令补细粒度。
选型只看两个变量:状态大小 × 操作规整度------与第 12、13 篇「结构选型看变化轴」一脉相承。
改进二:值语义快照------封装边界的取舍。当状态全部是可拷贝的值(无句柄、无自指、无缓存),备忘录可以大方地退化为一个普通值对象:
cpp
struct Snapshot {
std::string text;
int cursor;
};
std::vector<Snapshot> undo_;
// undo_.push_back({doc.text(), doc.cursor()});
friend、嵌套类、黑盒全部省掉------代价是「不透明」也没了:任何拿到 Snapshot 的代码都能读能改。取舍的界碑是模块边界 :同一个 .cpp 内部的轻量撤销,值快照最划算;跨模块、要长期保管、要序列化落盘的存档,回到密封信形态------黑盒不仅保护封装,也给了状态格式单独演化 的自由(改了 Document 内部布局,旧存档的解析只动原发器一个文件)。
展望 :快照内存问题还有第三条路------持久化数据结构(persistent data structure):不可变结构在「修改」时共享未变的子树,每份历史快照 O(1)~O(log n) 就地生成(immer 库是 C++ 代表)。它与第 15 篇享元同宗------共享不可变,省下的是「时间维度的重复」。
6. 优缺点与适用场景
- ✅ 优点(GoF 后果清单):封装毫发无损 ------快照的读写全在原发器内,窄接口由编译器背书;状态恢复逻辑集中 ------「什么是可恢复的状态」只有一处定义,字段增减不惊动看守者;快照是可搬运的对象------进栈、跨线程、落盘、过网络皆可(游戏存档的骨架)。
- ❌ 缺点:全量快照内存大 ------大对象深历史下不可持续,必须转向增量或混合路线;快照与恢复各有一次拷贝成本 ------高频快照在热路径上要掂量;含资源句柄的状态快照语义要逐字段定义 ------打开的文件、注册的回调、自指指针,「恢复」到底是重开、置空还是拒绝,备忘录不管,你得自己立约;看守者与快照的生命周期要对齐,泄漏与悬垂都会以「偶现崩溃」现身。
- 🎯 适用场景:自由编辑型撤销(文本、表格单元格)、事务提交前的旧值保留、游戏存档与 checkpoint、试探性计算的回溯(N 皇后、搜索剪枝)、「草稿/发布」两态切换。
〔辨析〕备忘录 vs 命令(第 19 篇):撤销的两条路线------记状态与记操作;状态小而操作杂用备忘录,操作规整可逆用命令,大型系统用「快照锚点 + 命令回放」的混合;命令的 undo() 内部持一份「操作前小快照」也是常见的合体形态。备忘录 vs 直接值拷贝:push_back(*this) 是语言能力,备忘录是协议------选择性快照(哪些字段属于状态由本体说了算)+ 不透明保管(看守者无法越权)+ 封装不破;值拷贝把这三样全数交出去。备忘录 vs 数据库事务:undo log 与 WAL 是「备忘录 + 命令」的工业化合体,本篇改进一的三条路线在数据库内核里一个不缺。
7. 开源项目中的身影
标准库:std::exception_ptr 是异常的备忘录 。「把当时发生的异常原样保存、稍后恢复」------current_exception() 拍快照,rethrow_exception() 恢复,中间的代码只能保管传递,读不出内容:
cpp
#include <exception>
std::exception_ptr saved;
try {
risky();
} catch (...) {
// 拍下「当时」的异常快照
saved = std::current_exception();
}
// ......稍后,甚至另一个线程
try {
std::rethrow_exception(saved); // 恢复
} catch (const std::exception& e) {
// 原异常原样归来,类型信息无损
}
点评:三个角色严丝合缝------原发器是抛异常的栈(只有它知道异常的全部语义),备忘录是 exception_ptr,看守者是跨线程搬运它的任务队列/ future。窄接口窄到极致:除了一句「里面有个异常」,外界一无所知。这正是 C++11 把「异常」从「栈上的活动物」物化成「可保管对象」的方式。
Folly:exception_wrapper,给邮差开一点信封 。folly 对 exception_ptr 的工程化增强:保留搬运与重抛能力之外,允许有限地检视 ------get_exception<E>()、what() 等只读查询:
cpp
// 说明性片段(需包含 folly/ExceptionWrapper.h)
folly::exception_wrapper ew;
try {
risky();
} catch (const std::system_error& e) {
ew = folly::exception_wrapper(e);
}
if (ew.get_exception<std::system_error>()) {
// 看得见类型,才能就地分流处理
}
点评:它站在「全黑盒」与「全公开」之间的工程折中点------纯搬运用 exception_ptr,搬运途中要按类型分流就得让邮差看一眼信封抬头。快照不透明到什么程度,从来是设计决定而不是教条。
AOSP:onSaveInstanceState(Bundle),系统当看守者 。Android 的 Activity 随时可能被系统回收,回收前框架调用 onSaveInstanceState 让应用把 UI 状态装进 Bundle;重建时原样交回由应用自行恢复(Java 侧协议,意图与 C++ 同构):
java
// 说明性片段(Android 经典协议)
@Override
protected void onSaveInstanceState(
Bundle outState) {
super.onSaveInstanceState(outState);
outState.putString("draft", draft);
outState.putInt("cursor", pos);
}
// 进程被杀、界面重建,Bundle 原样奉还
点评:角色分配值得细品------原发器是 Activity(「哪些状态值得救」只有它知道,注意它没 存视图内部的一切),看守者是系统(跨进程、跨生死周期保管 Bundle,从不解释内容),宽窄接口落在「系统只用 Bundle 的序列化协议,不读字段语义」。这也是快照路线的通用劝告:存「恢复必需的最小状态」,不是整个界面的复写。
三份代码合看:异常快照把黑盒做到极致、folly 按需开缝、Android 让系统当邮差------「保管与解释分离」这一个核心动作,从类图一路延伸到了跨进程协议。
本篇小结
「回到过去」不必以封装为代价:把内部状态封进一封只有本体能拆的密封信,交给只会投递的看守者------宽窄接口在 C++ 里就是嵌套类加一行 friend,编译器替你守住越权的手。快照只存恢复必需的最小状态、构造后不可变、看守者零解释权,是三条不可让的纪律;状态大或历史深时,与命令模式合成「快照锚点 + 命令回放」的工业路线。值快照是模块内部的合理捷径,跨边界与要落盘的存档请回到黑盒。下一篇观察者模式------对象的历史存好了,「变化的消息」如何一对多地广播出去:23 种模式里知名度最高的那一个。
本文模式定义与角色划分参考了 Refactoring Guru《设计模式》中文版「备忘录」一章,意图译文、宽窄接口与两步增量快照的讨论参考了 GoF《Design Patterns》第 5 章 Memento 一节。