Boost.Serialization

在設計 editor、game 或是實現軟體系統中的 heuristic algorithm 等情況下,常需要設計 save/load 或 undo 之類的還原功能。學過 design pattern 的人大都知道 memento 和 command 這兩個 pattern。通常比較簡單的東西靠 command pattern 就能順便做出 undo 功能了,但是在其它比較複雜的狀況下就需要靠 memento pattern 來幫忙,將物件的狀態儲存起來以便隨時復原。有些語言如 Java 提供了將物件本身序列化 (serialize) 的機制,所謂序列化就是把物件狀態變成一串資料儲存起來。這樣就可以做到和 memento 相同的功能,更進一步的話還能將序列化後的資料存在 disk 上或透過網路傳遞。C++ 語言本身沒有支援 serialization 的功能,所以 boost 以 library support 的形式做了一個類似的。內建的支援有 binary、text 和 xml 三種輸出格式。這篇只會出現 text,反正用法都沒差 (唯一要注意的就是 binary 並非 portable)。

稍微複習一下 memento pattern 的 class diagram:

以及 save/load state 的 sequence diagram:


雖然說這幾張圖連小學生都看得懂,不過還是稍微說明一下裡面出現的名詞好了。簡單說就是 Caretaker 想把 Originator 的狀態存成一個 Memento 物件,這個 Memento 物件會由 Caretaker 管理。根據 GoF 那本書的描述,理想上 Caretaker 不應該有權限使用 Memento 的存取介面,就單純只是保存著它而已;而 Memento 的介面也只允許創造它的 Originator 來進行存取。要完全達到這個效果必須使用 C++ 的 friend 使 Memento 的存取介面只被 Originator 使用,然後加上一些麻煩的驗證機制來 check 某個 Memento 物件是否由某個 Originator 物件建立的。不過這些存取限制完全不是這篇要講的重點,所以請自行想像。

有件事情要先聲明,就是這篇並非 Boost.Serialization 的基礎教學。因為官方的教學範例實在簡單到小學生看完都懂得怎麼用,所以當然要來點不一樣的東西。既然 GoF 的 design patterns 是講 OO,我們當然也要知道如何把 Boost.Serialization 應用在 OO 環境。

其實要做到跟圖上的要求一樣並非不可能。首先來做個 Memento class,這個幼稚園有畢業就能搞定了:

再來做出一個 Originator class,其實應該先做一個 abstract class 然後再繼承它。不過這不是寫正式軟體所以就偷懶一下:

19 - 20 行這兩個 methods 是 memento pattern 所要求的,而 23 - 24 行則是支援 OO 機制的重點。至於圖上那個叫 state 的 data member 在哪裡?這個不重要,沒有必要書上講什麼都通通乖乖照做。更何況那個還是 private data member,根本不會對整個 concept 有任何影響。每次 createMemento() 被呼叫的時候再臨時造一個丟出去就好了。

再來是實作檔中比較重要的幾個定義式:

根據前面的 sequence diagram,很顯然 save 動作的起始點在 Originator::createMemento()。有學過 Boost.Serialization 的人應該都知道第 16 行會發生什麼事。Boost.Serialization 在 boost::serialization 的 namespace 下有定義一個 function template,而第 16 行會把 oa 跟 *this 當成引數傳進去 (還有一個版本號碼,不過在這篇不是重點),再由該 function template 轉發到 *this 身上一個叫 serialize 的 member function。

官網的基礎教學大都會說放個 function template 接就可以了 (另外還有一個分離成 save/load 的方法):

這招其實可以,但你必須先把 createMemento() 和 setMemento() 變成 virtual。不過你會發現你需要在每個 derived class 裡都要重新定義它們一次 (每份實作程式碼都長得完全一樣),否則無法呼叫到正確的 serialize()。這是因為它並非 virtual function,所以如果我們不在每個 derived class 裡重定義一次,那麼要是有一個 ExtOriginator 繼承了 Originator,oa << *this 這條運算式最後呼叫到的還是 Originator::serialization()。可能會有天真的外行人說那就加個 virtual 上去好了,可惜 C++ 的 member function template 不支援 virtual。當初標準這樣訂是因為怕 compiler 難做,後來有人說其實並沒有那麼難所以可能在 C++0x 加入,但我在 TR1 裡好像沒看到這一條。

熟 C++ 的人馬上就會想到一個 workaround,就是放個不是 template 的同名 member function 進去:

只要 ?????? 這邊的 type 有辦法 match 到 call site 的 argument type,一切就會很順利。可是那到底該放什麼呢?查了 Boost.Serialization 的文件會發現到,基礎教學裡用的各種 archive classes 彼此之間都沒有繼承關係。
這是因為 Boost.Serialization 在後期開始崇尚效能走 generic programming 路線,所以比較鼓勵大家盡量少用 OO 機制。不過 archive classes 確實還是提供了支援 OO 機制的 polymorphic 版本,也就是前面 code 出現的 polymorphic_iarchive 和 polymorphic_oarchive。可惜的是這兩個 class 並沒有 common base class,所以我們要寫兩個 virtual member function 才行:

然而它們的 implement code 都是一樣的:

所以我又另外在 private 區段裡擺了一個 member function template 統一處理。它長的樣子跟基礎教學裡講的一模一樣,只不過在 Originator::serialization() 呼叫時 non-template 的版本會優先被使用,除非明確指定要呼叫 template 版本。

眼尖的人應該還會發現到,在這裡我並沒有像基礎教學文件一樣使用 file stream 來儲存狀態,而是改用 string stream。如此一來物件的狀態就可以保存在一個 string 中,不必非寫入 file 內不可。至於很多人都關心的 performance 問題,老實說速度會慢是難免的。如果 serialize 出來的 string 長度很長,用 setState() 把狀態交給 Memento 物件儲存時就會有一次 copy,而 getState() 的那次 copy 倒是可以省 (return const reference 即可)。GoF 那本書也坦言 Memento 可能增加 CPU 負擔,所以這個效能損失是無可避免的。不過倒也不需要那麼在意這種 performance,讀過 More Effective C++ 的人應該都知道要謹記 80/20 rule。除非你的效能分析器告訴你瓶頸出在這上面,否則你沒有必要這麼早就去考慮那麼多東西而放棄彈性。事實上應該也很少有軟體系統需要沒事就 save/load 一下,game 的話除非 player 開外掛去狂 save/load,否則手按的速度其實是還可以接受的。

故事到這邊當然還沒結束,既然是 OO 環境那就好歹要來個繼承:

幾個比較重要的定義式:

很顯然每個 derived class 都要寫一組長得完全一樣的 virtual function 了,當然不高興的話當然可以用 macro 搞定。不要因為 C++ 的爸爸討厭 macro 就跟著盲目的排斥 macro,何況這還是因為 C++ 語言沒支援 virtual 的 member function template 所致,既然受其所害也就沒有必要把那些話放在心上了。其實一個人不管多有名,跟他講出來的話認真就已經輸一半了,自己應該要有衡量和判斷狀況選用工具的能力。

有看過官方基礎教學的人應該會發現,這裡的 code 是直接呼叫 base class 的 serialize(),而不是 ar & base_object(*this);這種寫法。原因自己想一下就能明白了 (提示是 infinite indirect recursive)。

可能會有人好奇,為什麼往 base class 的呼叫並不是直接呼叫 template 版?這需要稍微回顧一下,我們因為 archive classes 沒有 common base class,所以被迫把 serialize() 分割成負責 iarchive 和 oarchive 的兩個版本,這正好和官方基礎教學文件裡提到的 save/load 分開處理法有異曲同工之妙。所以為了處理上的一致性我們還是 dispatch 給 non-template 版。至於為什麼需要分開處理就自己去看官方教學文件吧,這主要是為了應付 class 版本的更新 (因此在載入舊版 class 狀態時要特別處理)。

client code 其實很簡單:

至於那個 Caretaker 其實對 Memento 物件的動作大致上就只能這樣。只是平常存著各種 Memento 物件,然後把它當成參數丟到對應的 Originator 物件去,並不應該去使用任何 Memento 的物件,因此 Caretaker 的實作檔裡甚至可以只有 Memento 的 class 宣告式;至於 Caretaker 該怎麼寫並不在本篇的討論範圍內。

有辦法讀到這裡的人其實還會產生一個疑惑,就是這樣每個 derived class 定義一組完全一樣的 virtual function serialize(),跟每個 derived class 重新定義一份 createMemento() 及 setMemento() 有什麼差別?我選擇的方法主要是傾向於相信未來的標準 member function template 能支援 virtual,所以只要把那組 virtual function 定義成一個 macro,再放一個 macro 在 member function template 之前。這樣當新標準到來時只要改個 #define 就能搞定了:

相信有點程度的人應該都能猜得出這些 macro 裡面是怎麼定義的。