资讯动态

【设计模式精讲】23.备忘录模式(Memento)

发布时间:2026/9/7 21:08:55 来源:尧图企业网站定制
【设计模式精讲】23.备忘录模式Memento【摘要】命令模式靠「逆操作」撤销了绘图程序但文本编辑器是另一回事——用户随手打字删字、移动光标操作琐碎得没有形状。两条歪路摆在面前为存档把字段全 public封装陪葬或者整个对象拷一份句柄崩、「哪些字段属于状态」没人说得清。本文给出备忘录模式的 GoF 意图在不破坏封装的前提下捕获并外化对象内部状态——C 用嵌套类加 friend 精确表达「只有本体能拆信、看守者只管邮递」的宽窄接口现代版补上移动语义、快照与命令的成本曲线大型系统的混合方案以及值语义快照的取舍。文末对照std::exception_ptr、folly 的 exception_wrapper 与 AOSP 的状态保存协议。读完你能为「回到过去」选对那条路线。【关键词】备忘录、快照、撤销、封装、看守者、宽窄接口、事务回滚【代码基准】C171. 撤销的另一种姿势把「当时」存下来第 19 篇用命令模式解了绘图程序的撤销每个操作规整、可逆命令对象自带 undo。现在换一个主角——文本编辑器的Document用户打一个字、删一个字、光标跳来跳去操作琐碎到没有形状「为每个字符输入写一个命令类」荒谬得说不出口。需求还是那个 CtrlZ直觉给出两条路// 说明性片段// ❌ 路 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 图 结构说明创建宽接口恢复宽接口保管窄接口把快照交回原发器Document-text_ string-cursor_ intcreate() : Mementorestore(m) : void«不透明»Memento-text_ string-cursor_ intHistory-stack_ vectorsave(m) : voidpop() : Memento三个参与者GoF 命名原发器OriginatorDocument——创建快照把内部状态封进备忘录、恢复快照从备忘录写回是唯一能解释快照内容的角色备忘录Memento状态的黑盒封装——对看守者只暴露「存在」对原发器暴露全部看守者CaretakerHistory——保管快照的栈、队列或磁盘负责在正确的时机把快照原样递回原发器从头到尾不拆不看。一个贴切的意象备忘录是密封信。写信人将来还要读这封信同一个原发器恢复中间的邮差看守者只管投递保管——拆信越权抄送更不可能。这个意象也预告了工程上的全部要点信封要结实快照不可变、邮路要可靠生命周期对齐、信纸要够小快照只存恢复必需的状态。4. 传统 C 写法C11 之前C98 完整形态备忘录做成原发器的嵌套类构造私有、friend授信——「宽窄接口」从图上的两个视图变成编译器强制的事实// C98/03 写法#includecstdio#includestring#includevectorclassDocument{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::stringt,intc):text_(t),cursor_(c){}Memento(constMemento);// 禁复制Mementooperator(// 快照不可变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 i0;istack_.size();i)deletestack_[i];}voidsave(Document::Memento*m){stack_.push_back(m);}Document::Memento*pop(){if(stack_.empty())return0;Document::Memento*mstack_.back();stack_.pop_back();returnm;}private:std::vectorDocument::Memento*stack_;};intmain(){Document doc;History hist;hist.save(doc.create());// 存档 doc.type(a);doc.type(b);hist.save(doc.create());// 存档 abdoc.type(c);doc.print();// [abc|3]doc.restore(hist.pop());// 回到 abdoc.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 节略#includememory#includevectorclassHistory{public:voidsave(std::unique_ptrDocument::Mementom){stack_.push_back(std::move(m));}std::unique_ptrDocument::Mementopop(){if(stack_.empty())returnnullptr;automstd::move(stack_.back());stack_.pop_back();returnm;}private:std::vectorstd::unique_ptrDocument::Mementostack_;};// 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::vectorSnapshotundo_;// 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()恢复中间的代码只能保管传递读不出内容#includeexceptionstd::exception_ptr saved;try{risky();}catch(...){// 拍下「当时」的异常快照savedstd::current_exception();}// ……稍后甚至另一个线程try{std::rethrow_exception(saved);// 恢复}catch(conststd::exceptione){// 原异常原样归来类型信息无损}点评三个角色严丝合缝——原发器是抛异常的栈只有它知道异常的全部语义备忘录是exception_ptr看守者是跨线程搬运它的任务队列/ future。窄接口窄到极致除了一句「里面有个异常」外界一无所知。这正是 C11 把「异常」从「栈上的活动物」物化成「可保管对象」的方式。Follyexception_wrapper给邮差开一点信封。folly 对exception_ptr的工程化增强保留搬运与重抛能力之外允许有限地检视——get_exceptionE()、what()等只读查询// 说明性片段需包含 folly/ExceptionWrapper.hfolly::exception_wrapper ew;try{risky();}catch(conststd::system_errore){ewfolly::exception_wrapper(e);}if(ew.get_exceptionstd::system_error()){// 看得见类型才能就地分流处理}点评它站在「全黑盒」与「全公开」之间的工程折中点——纯搬运用exception_ptr搬运途中要按类型分流就得让邮差看一眼信封抬头。快照不透明到什么程度从来是设计决定而不是教条。AOSPonSaveInstanceState(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 一节。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价