【设计模式精讲】23.备忘录模式(Memento)
【摘要】:命令模式靠「逆操作」撤销了绘图程序,但文本编辑器是另一回事——用户随手打字删字、移动光标,操作琐碎得没有形状。两条歪路摆在面前:为存档把字段全 public,封装陪葬;或者整个对象拷一份,句柄崩、「哪些字段属于状态」没人说得清。本文给出备忘录模式的 GoF 意图:在不破坏封装的前提下捕获并外化对象内部状态——C++ 用嵌套类加 friend 精确表达「只有本体能拆信、看守者只管邮递」的宽窄接口;现代版补上移动语义、快照与命令的成本曲线(大型系统的混合方案),以及值语义快照的取舍。文末对照
std::exception_ptr、folly 的 exception_wrapper 与 AOSP 的状态保存协议。读完你能为「回到过去」选对那条路线。
【关键词】:备忘录、快照、撤销、封装、看守者、宽窄接口、事务回滚
【代码基准】:C++17
1. 撤销的另一种姿势:把「当时」存下来
第 19 篇用命令模式解了绘图程序的撤销:每个操作规整、可逆,命令对象自带 undo。现在换一个主角——文本编辑器的Document:用户打一个字、删一个字、光标跳来跳去,操作琐碎到没有形状,「为每个字符输入写一个命令类」荒谬得说不出口。需求还是那个 Ctrl+Z,直觉给出两条路:
// 说明性片段// ❌ 路 A:为存档打开封装classDocument{public:std::string text;// 全部 publicintcursor;// 谁都能改,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 图 + 结构说明
三个参与者(GoF 命名):
- 原发器(Originator):
Document——创建快照(把内部状态封进备忘录)、恢复快照(从备忘录写回),是唯一能解释快照内容的角色; - 备忘录(Memento):状态的黑盒封装——对看守者只暴露「存在」,对原发器暴露全部;
- 看守者(Caretaker):
History——保管快照的栈、队列或磁盘,负责在正确的时机把快照原样递回原发器,从头到尾不拆不看。
一个贴切的意象:备忘录是密封信。写信人将来还要读这封信(同一个原发器恢复),中间的邮差(看守者)只管投递保管——拆信越权,抄送更不可能。这个意象也预告了工程上的全部要点:信封要结实(快照不可变)、邮路要可靠(生命周期对齐)、信纸要够小(快照只存恢复必需的状态)。
4. 传统 C++ 写法(C++11 之前)
C++98 完整形态:备忘录做成原发器的嵌套类,构造私有、friend授信——「宽窄接口」从图上的两个视图变成编译器强制的事实:
// C++98/03 写法#include<cstdio>#include<string>#include<vector>classDocument{public:Document():cursor_(0){}voidtype(charc){text_.insert(cursor_,1,c);++cursor_;}voidbackspace(){if(cursor_==0)return;text_.erase(cursor_-1,1);--cursor_;}voidprint()const{printf("[%s|%d]\n",text_.c_str(),cursor_);}// ---- 备忘录:密封的状态快照 ----classMemento{public:~Memento(){}private:friendclassDocument;// 只有本体// 能拆信Memento(conststd::string&t,intc):text_(t),cursor_(c){}Memento(constMemento&);// 禁复制:Memento&operator=(// 快照不可变constMemento&);std::string text_;intcursor_;};Memento*create()const{returnnewMemento(text_,cursor_);}voidrestore(constMemento*m){text_=m->text_;// 宽接口:cursor_=m->cursor_;// 只有这里能读}private:std::string text_;intcursor_;};// ---- 看守者:只存、只传、不拆 ----classHistory{public:~History(){for(size_t i=0;i<stack_.size();++i)deletestack_[i];}voidsave(Document::Memento*m){stack_.push_back(m);}Document::Memento*pop(){if(stack_.empty())return0;Document::Memento*m=stack_.back();stack_.pop_back();returnm;}private:std::vector<Document::Memento*>stack_;};intmain(){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]return0;}History面对的是什么?一个只能 new 出来、delete 掉、拿来拿去的指针类型——它想把text_读出来编译器都不答应。第 1 节路 A 的「封装陪葬」与路 B 的「拷贝语义含混」同时避开:哪些字段属于状态,只有Document::create一个地方说了算。
三条传统写法的铁律:宽接口只授给原发器——friend class Document一行就是宽窄接口的全部实现,其他语言要用双接口/包可见性绕的圈,C++ 一词到位;快照不可变——构造之后无 setter,禁拷贝禁赋值(现代版可放宽为可移动),「邮差篡改历史」在类型层面消失;看守者零解释权——History的全部代码里没有出现任何状态字段的名字,这行检查标准值得写进评审清单。
5. 现代 C++ 进阶写法
升级零:unique_ptr管快照,移动语义流转。裸指针的创建/删除分工交给类型,快照成为移动专用对象:
// 节选(Document 定义同第 4 节,略)#include<memory>#include<vector>classHistory{public:voidsave(std::unique_ptr<Document::Memento>m){stack_.push_back(std::move(m));}std::unique_ptr<Document::Memento>pop(){if(stack_.empty())returnnullptr;autom=std::move(stack_.back());stack_.pop_back();returnm;}private:std::vector<std::unique_ptr<Document::Memento>>stack_;};// doc.restore(hist.pop().get());// 或让 restore 直接接收 unique_ptrMemento的私有禁拷贝 hack 换成= delete,其余纪律原样成立。
改进一:快照还是命令?三条撤销路线的成本曲线。第 19 篇与本篇合起来才是撤销的完整地图:
- 命令路线(记操作):内存省(每步只存参数与逆操作),但要求操作规整可逆——适合绘图这类「操作有形状」的领域;
- 快照路线(记状态):实现简单、绝对正确(不依赖「逆操作」的正确性),但状态大、步数深时内存爆炸——适合文档这类「自由编辑」的小状态对象;
- 混合路线(工业标配):每 N 步落一个快照,中间步骤用命令回放——撤销 M 步 = 回滚到最近快照再正向重放剩下的(N−M)步。数据库 WAL、游戏 checkpoint、编辑器的分档 undo 全是这条路线的变体:快照定锚点,命令补细粒度。
选型只看两个变量:状态大小 × 操作规整度——与第 12、13 篇「结构选型看变化轴」一脉相承。
改进二:值语义快照——封装边界的取舍。当状态全部是可拷贝的值(无句柄、无自指、无缓存),备忘录可以大方地退化为一个普通值对象:
structSnapshot{std::string text;intcursor;};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()恢复,中间的代码只能保管传递,读不出内容:
#include<exception>std::exception_ptr saved;try{risky();}catch(...){// 拍下「当时」的异常快照saved=std::current_exception();}// ……稍后,甚至另一个线程try{std::rethrow_exception(saved);// 恢复}catch(conststd::exception&e){// 原异常原样归来,类型信息无损}点评:三个角色严丝合缝——原发器是抛异常的栈(只有它知道异常的全部语义),备忘录是exception_ptr,看守者是跨线程搬运它的任务队列/ future。窄接口窄到极致:除了一句「里面有个异常」,外界一无所知。这正是 C++11 把「异常」从「栈上的活动物」物化成「可保管对象」的方式。
Folly:exception_wrapper,给邮差开一点信封。folly 对exception_ptr的工程化增强:保留搬运与重抛能力之外,允许有限地检视——get_exception<E>()、what()等只读查询:
// 说明性片段(需包含 folly/ExceptionWrapper.h)folly::exception_wrapper ew;try{risky();}catch(conststd::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++ 同构):
// 说明性片段(Android 经典协议)@OverrideprotectedvoidonSaveInstanceState(BundleoutState){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 一节。