計(jì)模式精講】23.備忘錄模式(Memento))
【設(shè)計(jì)模式精講】23.備忘錄模式Memento【摘要】命令模式靠「逆操作」撤銷了繪圖程序但文本編輯器是另一回事——用戶隨手打字刪字、移動(dòng)光標(biāo)操作瑣碎得沒(méi)有形狀。兩條歪路擺在面前為存檔把字段全 public封裝陪葬或者整個(gè)對(duì)象拷一份句柄崩、「哪些字段屬于狀態(tài)」沒(méi)人說(shuō)得清。本文給出備忘錄模式的 GoF 意圖在不破壞封裝的前提下捕獲并外化對(duì)象內(nèi)部狀態(tài)——C 用嵌套類加 friend 精確表達(dá)「只有本體能拆信、看守者只管郵遞」的寬窄接口現(xiàn)代版補(bǔ)上移動(dòng)語(yǔ)義、快照與命令的成本曲線大型系統(tǒng)的混合方案以及值語(yǔ)義快照的取舍。文末對(duì)照std::exception_ptr、folly 的 exception_wrapper 與 AOSP 的狀態(tài)保存協(xié)議。讀完你能為「回到過(guò)去」選對(duì)那條路線?!娟P(guān)鍵詞】備忘錄、快照、撤銷、封裝、看守者、寬窄接口、事務(wù)回滾【代碼基準(zhǔn)】C171. 撤銷的另一種姿勢(shì)把「當(dāng)時(shí)」存下來(lái)第 19 篇用命令模式解了繪圖程序的撤銷每個(gè)操作規(guī)整、可逆命令對(duì)象自帶 undo?,F(xiàn)在換一個(gè)主角——文本編輯器的Document用戶打一個(gè)字、刪一個(gè)字、光標(biāo)跳來(lái)跳去操作瑣碎到?jīng)]有形狀「為每個(gè)字符輸入寫一個(gè)命令類」荒謬得說(shuō)不出口。需求還是那個(gè) CtrlZ直覺(jué)給出兩條路// 說(shuō)明性片段// ? 路 A為存檔打開(kāi)封裝classDocument{public:std::string text;// 全部 publicintcursor;// 誰(shuí)都能改Selection sel;// 不變量沒(méi)人守};// 歷史棧直接搬字段// hist.push_back({doc.text, doc.cursor});// —— 漏存了選區(qū)恢復(fù)后選區(qū)懸垂// ? 路 B整個(gè)對(duì)象拷一份// hist2.push_back(doc); // 要求可拷貝// 內(nèi)部若有緩存/句柄/自指指針// 拷出來(lái)的就是一顆雷路 A 讓「不變量由類自己守」的封裝原則為撤銷功能陪葬——第 2 篇講過(guò)的邊界一旦打開(kāi)就關(guān)不上路 B 把「哪些字段屬于狀態(tài)」的定義權(quán)交給了拷貝構(gòu)造——多拷浪費(fèi)內(nèi)存少拷恢復(fù)不完整含資源的字段打開(kāi)的文件、注冊(cè)的回調(diào)根本拷不動(dòng)。兩條路背后是同一個(gè)矛盾命題「恢復(fù)過(guò)去」要求外部能拿到內(nèi)部狀態(tài)「封裝」要求外部拿不到。備忘錄模式的全部智慧就是解開(kāi)這一對(duì)矛盾——答案不是二選一而是把「狀態(tài)」打包成一件外部能保管、但不能拆開(kāi)的東西。2. 模式意圖與定義一句話定義在不破壞封裝性的前提下捕獲一個(gè)對(duì)象的內(nèi)部狀態(tài)并在該對(duì)象之外保存這個(gè)狀態(tài)從而可以將該對(duì)象恢復(fù)到原先保存的狀態(tài)。解決的問(wèn)題需要快照式回滾撤銷、事務(wù)、存檔、回溯而對(duì)象的內(nèi)部表示不能對(duì)外暴露。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 加一個(gè) copy」的分界線。Refactoring Guru 的表述備忘錄模式允許在不暴露所保存狀態(tài)實(shí)現(xiàn)細(xì)節(jié)的前提下保存與恢復(fù)對(duì)象先前的狀態(tài)它像是對(duì)象的「時(shí)刻存檔」外界可以傳遞存檔、保管存檔但看不懂存檔。三條定性保管權(quán)與解釋權(quán)分離??词卣逤aretaker只做三件事——存、傳、丟只有原發(fā)器Originator能寫快照、讀快照。快照對(duì)外界是不透明的黑盒「不透明」正是封裝沒(méi)有破裂的證明。寬接口與窄接口。GoF 用兩個(gè)視圖描述同一份快照原發(fā)器看到寬接口能讀寫全部狀態(tài)看守者看到窄接口只有「這是一份快照」的存在性。C 有一把恰好合手的刀——friend把寬接口精確地只授予原發(fā)器第 4 節(jié)落地。與命令模式的分工。第 19 篇記「做了什么」操作 逆操作本篇記「當(dāng)時(shí)什么樣」?fàn)顟B(tài)快照——這是撤銷的兩條路線成本曲線完全不同第 5 節(jié)正面比較。3. UML 圖 結(jié)構(gòu)說(shuō)明創(chuàng)建寬接口恢復(fù)寬接口保管窄接口把快照交回原發(fā)器Document-text_ string-cursor_ intcreate() : Mementorestore(m) : void?不透明?Memento-text_ string-cursor_ intHistory-stack_ vectorsave(m) : voidpop() : Memento三個(gè)參與者GoF 命名原發(fā)器OriginatorDocument——?jiǎng)?chuàng)建快照把內(nèi)部狀態(tài)封進(jìn)備忘錄、恢復(fù)快照從備忘錄寫回是唯一能解釋快照內(nèi)容的角色備忘錄Memento狀態(tài)的黑盒封裝——對(duì)看守者只暴露「存在」對(duì)原發(fā)器暴露全部看守者CaretakerHistory——保管快照的棧、隊(duì)列或磁盤負(fù)責(zé)在正確的時(shí)機(jī)把快照原樣遞回原發(fā)器從頭到尾不拆不看。一個(gè)貼切的意象備忘錄是密封信。寫信人將來(lái)還要讀這封信同一個(gè)原發(fā)器恢復(fù)中間的郵差看守者只管投遞保管——拆信越權(quán)抄送更不可能。這個(gè)意象也預(yù)告了工程上的全部要點(diǎn)信封要結(jié)實(shí)快照不可變、郵路要可靠生命周期對(duì)齊、信紙要夠小快照只存恢復(fù)必需的狀態(tài)。4. 傳統(tǒng) C 寫法C11 之前C98 完整形態(tài)備忘錄做成原發(fā)器的嵌套類構(gòu)造私有、friend授信——「寬窄接口」從圖上的兩個(gè)視圖變成編譯器強(qiáng)制的事實(shí)// 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_);}// ---- 備忘錄密封的狀態(tài)快照 ----classMemento{public:~Memento(){}private:friendclassDocument;// 只有本體// 能拆信Memento(conststd::stringt,intc):text_(t),cursor_(c){}Memento(constMemento);// 禁復(fù)制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面對(duì)的是什么一個(gè)只能 new 出來(lái)、delete 掉、拿來(lái)拿去的指針類型——它想把text_讀出來(lái)編譯器都不答應(yīng)。第 1 節(jié)路 A 的「封裝陪葬」與路 B 的「拷貝語(yǔ)義含混」同時(shí)避開(kāi)哪些字段屬于狀態(tài)只有Document::create一個(gè)地方說(shuō)了算。三條傳統(tǒng)寫法的鐵律寬接口只授給原發(fā)器——friend class Document一行就是寬窄接口的全部實(shí)現(xiàn)其他語(yǔ)言要用雙接口/包可見(jiàn)性繞的圈C 一詞到位快照不可變——構(gòu)造之后無(wú) setter禁拷貝禁賦值現(xiàn)代版可放寬為可移動(dòng)「郵差篡改歷史」在類型層面消失看守者零解釋權(quán)——History的全部代碼里沒(méi)有出現(xiàn)任何狀態(tài)字段的名字這行檢查標(biāo)準(zhǔn)值得寫進(jìn)評(píng)審清單。5. 現(xiàn)代 C 進(jìn)階寫法升級(jí)零unique_ptr管快照移動(dòng)語(yǔ)義流轉(zhuǎn)。裸指針的創(chuàng)建/刪除分工交給類型快照成為移動(dòng)專用對(duì)象// 節(jié)選Document 定義同第 4 節(jié)略#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其余紀(jì)律原樣成立。改進(jìn)一快照還是命令三條撤銷路線的成本曲線。第 19 篇與本篇合起來(lái)才是撤銷的完整地圖命令路線記操作內(nèi)存省每步只存參數(shù)與逆操作但要求操作規(guī)整可逆——適合繪圖這類「操作有形狀」的領(lǐng)域快照路線記狀態(tài)實(shí)現(xiàn)簡(jiǎn)單、絕對(duì)正確不依賴「逆操作」的正確性但狀態(tài)大、步數(shù)深時(shí)內(nèi)存爆炸——適合文檔這類「自由編輯」的小狀態(tài)對(duì)象混合路線工業(yè)標(biāo)配每 N 步落一個(gè)快照中間步驟用命令回放——撤銷 M 步 回滾到最近快照再正向重放剩下的N?M步。數(shù)據(jù)庫(kù) WAL、游戲 checkpoint、編輯器的分檔 undo 全是這條路線的變體快照定錨點(diǎn)命令補(bǔ)細(xì)粒度。選型只看兩個(gè)變量狀態(tài)大小 × 操作規(guī)整度——與第 12、13 篇「結(jié)構(gòu)選型看變化軸」一脈相承。改進(jìn)二值語(yǔ)義快照——封裝邊界的取舍。當(dāng)狀態(tài)全部是可拷貝的值無(wú)句柄、無(wú)自指、無(wú)緩存?zhèn)渫浛梢源蠓降赝嘶癁橐粋€(gè)普通值對(duì)象structSnapshot{std::string text;intcursor;};std::vectorSnapshotundo_;// undo_.push_back({doc.text(), doc.cursor()});friend、嵌套類、黑盒全部省掉——代價(jià)是「不透明」也沒(méi)了任何拿到Snapshot的代碼都能讀能改。取舍的界碑是模塊邊界同一個(gè).cpp內(nèi)部的輕量撤銷值快照最劃算跨模塊、要長(zhǎng)期保管、要序列化落盤的存檔回到密封信形態(tài)——黑盒不僅保護(hù)封裝也給了狀態(tài)格式單獨(dú)演化的自由改了Document內(nèi)部布局舊存檔的解析只動(dòng)原發(fā)器一個(gè)文件。展望快照內(nèi)存問(wèn)題還有第三條路——持久化數(shù)據(jù)結(jié)構(gòu)persistent data structure不可變結(jié)構(gòu)在「修改」時(shí)共享未變的子樹(shù)每份歷史快照 O(1)~O(log n) 就地生成immer 庫(kù)是 C 代表。它與第 15 篇享元同宗——共享不可變省下的是「時(shí)間維度的重復(fù)」。6. 優(yōu)缺點(diǎn)與適用場(chǎng)景? 優(yōu)點(diǎn)GoF 后果清單封裝毫發(fā)無(wú)損——快照的讀寫全在原發(fā)器內(nèi)窄接口由編譯器背書(shū)狀態(tài)恢復(fù)邏輯集中——「什么是可恢復(fù)的狀態(tài)」只有一處定義字段增減不驚動(dòng)看守者快照是可搬運(yùn)的對(duì)象——進(jìn)棧、跨線程、落盤、過(guò)網(wǎng)絡(luò)皆可游戲存檔的骨架。? 缺點(diǎn)全量快照內(nèi)存大——大對(duì)象深歷史下不可持續(xù)必須轉(zhuǎn)向增量或混合路線快照與恢復(fù)各有一次拷貝成本——高頻快照在熱路徑上要掂量含資源句柄的狀態(tài)快照語(yǔ)義要逐字段定義——打開(kāi)的文件、注冊(cè)的回調(diào)、自指指針「恢復(fù)」到底是重開(kāi)、置空還是拒絕備忘錄不管你得自己立約看守者與快照的生命周期要對(duì)齊泄漏與懸垂都會(huì)以「偶現(xiàn)崩潰」現(xiàn)身。 適用場(chǎng)景自由編輯型撤銷文本、表格單元格、事務(wù)提交前的舊值保留、游戲存檔與 checkpoint、試探性計(jì)算的回溯N 皇后、搜索剪枝、「草稿/發(fā)布」兩態(tài)切換。〔辨析〕備忘錄 vs 命令第 19 篇撤銷的兩條路線——記狀態(tài)與記操作狀態(tài)小而操作雜用備忘錄操作規(guī)整可逆用命令大型系統(tǒng)用「快照錨點(diǎn) 命令回放」的混合命令的undo()內(nèi)部持一份「操作前小快照」也是常見(jiàn)的合體形態(tài)。備忘錄 vs 直接值拷貝push_back(*this)是語(yǔ)言能力備忘錄是協(xié)議——選擇性快照哪些字段屬于狀態(tài)由本體說(shuō)了算 不透明保管看守者無(wú)法越權(quán) 封裝不破值拷貝把這三樣全數(shù)交出去。備忘錄 vs 數(shù)據(jù)庫(kù)事務(wù)undo log 與 WAL 是「?jìng)渫? 命令」的工業(yè)化合體本篇改進(jìn)一的三條路線在數(shù)據(jù)庫(kù)內(nèi)核里一個(gè)不缺。7. 開(kāi)源項(xiàng)目中的身影標(biāo)準(zhǔn)庫(kù)std::exception_ptr是異常的備忘錄?!赴旬?dāng)時(shí)發(fā)生的異常原樣保存、稍后恢復(fù)」——current_exception()拍快照rethrow_exception()恢復(fù)中間的代碼只能保管傳遞讀不出內(nèi)容#includeexceptionstd::exception_ptr saved;try{risky();}catch(...){// 拍下「當(dāng)時(shí)」的異常快照savedstd::current_exception();}// ……稍后甚至另一個(gè)線程try{std::rethrow_exception(saved);// 恢復(fù)}catch(conststd::exceptione){// 原異常原樣歸來(lái)類型信息無(wú)損}點(diǎn)評(píng)三個(gè)角色嚴(yán)絲合縫——原發(fā)器是拋異常的棧只有它知道異常的全部語(yǔ)義備忘錄是exception_ptr看守者是跨線程搬運(yùn)它的任務(wù)隊(duì)列/ future。窄接口窄到極致除了一句「里面有個(gè)異常」外界一無(wú)所知。這正是 C11 把「異?!箯摹笚I系幕顒?dòng)物」物化成「可保管對(duì)象」的方式。Follyexception_wrapper給郵差開(kāi)一點(diǎn)信封。folly 對(duì)exception_ptr的工程化增強(qiáng)保留搬運(yùn)與重拋能力之外允許有限地檢視——get_exceptionE()、what()等只讀查詢// 說(shuō)明性片段需包含 folly/ExceptionWrapper.hfolly::exception_wrapper ew;try{risky();}catch(conststd::system_errore){ewfolly::exception_wrapper(e);}if(ew.get_exceptionstd::system_error()){// 看得見(jiàn)類型才能就地分流處理}點(diǎn)評(píng)它站在「全黑盒」與「全公開(kāi)」之間的工程折中點(diǎn)——純搬運(yùn)用exception_ptr搬運(yùn)途中要按類型分流就得讓郵差看一眼信封抬頭。快照不透明到什么程度從來(lái)是設(shè)計(jì)決定而不是教條。AOSPonSaveInstanceState(Bundle)系統(tǒng)當(dāng)看守者。Android 的 Activity 隨時(shí)可能被系統(tǒng)回收回收前框架調(diào)用onSaveInstanceState讓應(yīng)用把 UI 狀態(tài)裝進(jìn)Bundle重建時(shí)原樣交回由應(yīng)用自行恢復(fù)Java 側(cè)協(xié)議意圖與 C 同構(gòu)// 說(shuō)明性片段Android 經(jīng)典協(xié)議OverrideprotectedvoidonSaveInstanceState(BundleoutState){super.onSaveInstanceState(outState);outState.putString(draft,draft);outState.putInt(cursor,pos);}// 進(jìn)程被殺、界面重建Bundle 原樣奉還點(diǎn)評(píng)角色分配值得細(xì)品——原發(fā)器是 Activity「哪些狀態(tài)值得救」只有它知道注意它沒(méi)存視圖內(nèi)部的一切看守者是系統(tǒng)跨進(jìn)程、跨生死周期保管 Bundle從不解釋內(nèi)容寬窄接口落在「系統(tǒng)只用 Bundle 的序列化協(xié)議不讀字段語(yǔ)義」。這也是快照路線的通用勸告存「恢復(fù)必需的最小狀態(tài)」不是整個(gè)界面的復(fù)寫。三份代碼合看異??煺瞻押诤凶龅綐O致、folly 按需開(kāi)縫、Android 讓系統(tǒng)當(dāng)郵差——「保管與解釋分離」這一個(gè)核心動(dòng)作從類圖一路延伸到了跨進(jìn)程協(xié)議。本篇小結(jié)「回到過(guò)去」不必以封裝為代價(jià)把內(nèi)部狀態(tài)封進(jìn)一封只有本體能拆的密封信交給只會(huì)投遞的看守者——寬窄接口在 C 里就是嵌套類加一行friend編譯器替你守住越權(quán)的手??煺罩淮婊謴?fù)必需的最小狀態(tài)、構(gòu)造后不可變、看守者零解釋權(quán)是三條不可讓的紀(jì)律狀態(tài)大或歷史深時(shí)與命令模式合成「快照錨點(diǎn) 命令回放」的工業(yè)路線。值快照是模塊內(nèi)部的合理捷徑跨邊界與要落盤的存檔請(qǐng)回到黑盒。下一篇觀察者模式——對(duì)象的歷史存好了「變化的消息」如何一對(duì)多地廣播出去23 種模式里知名度最高的那一個(gè)。本文模式定義與角色劃分參考了 Refactoring Guru《設(shè)計(jì)模式》中文版「?jìng)渫洝挂徽乱鈭D譯文、寬窄接口與兩步增量快照的討論參考了 GoF《Design Patterns》第 5 章 Memento 一節(jié)。