Try  HackMD Logo HackMD

C++ 基於原則設計方法
(Policy-Based Class Design for C++)

Reference

Andrei Alexandrescu. 2001. Modern C++ design: generic programming and design patterns applied. Addison-Wesley Longman Publishing Co., Inc., USA.

Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

前言

因為工作上需要用到 C++,最近開始覺得說去把比較進階的技能點起來,才比較能應付未來開發專案時,針對彈性 (Flexibility) 及可擴展性 (Scalability) 的需求。稍微找了一些網路資源,好像都是推薦這本書 (或者說聖經來著),於是乎就打算來拜讀一波。

整本書一共有十一個章節,主要是討論在 C++ 當中如何使用泛型編程 (generic programming),在 C++ 裡面那個叫做模板 (template) 的酷東西,去創造可擴充的設計方法:從設計到程式碼的無縫遷移,讓程式碼達成以下幾件事情:

  1. 清晰
  2. 靈活
  3. 可重複利用

以及優雅、等等一些你覺得很讚的形容詞。

因為是一邊讀一邊寫心得做筆記,所以有一些想法在閱讀過程中大概也會有所改變,如果念得完的話,也可以來看看當初對這本書的想像跟實際上會不會有很大的出入。

這次紀錄的是第一章:Policy-Based Class Design (基於原則設計方法),這也是這本書與作者被廣為人知的一個重要章節。在這次筆記中我們試著透過一些例子去理解他的想法,有些也是書中提出來的,有時間的話建議要打開編輯器實際寫寫看,才能有比較深刻的體會。

原則 (Policy)

一個靈活的函式庫,意味著他提供了豐富的功能、高度可客製化的功能選擇,重點是使用者用起來要簡單、可靠,同時程式碼看起來也簡潔漂亮。

因此,函式庫在設計階段就需要考量到各種方案的組合性,以確保說使用者可以在更動少量程式碼的前提下完成需求,同時函式庫也 (相對來說比較) 不需要為此提供額外的程式碼更動,可喜可賀。舉個例子來說,我有一個類別可以生成漢堡,那我會需要提供接口給使用者去決定:

  1. 要使用哪一種肉類 (豬肉 or 牛肉 or 雞肉等等的)
  2. 要如何烹調肉類 (煎煮炒炸之類的)

在現實情況還有很多可以調整的項目,但我們今天先單純考慮肉類這個項目就好。

除了函式庫本身可能已經提供了上述選項,可能也需要處理一些客製化項目,或者是自身業務擴展,比如說今天帶了鴨胸跟烤箱來,然後說要用烤的 (?),此時對程式碼需要更動的幅度,就能夠展現出函式庫對於可擴展性的支援程度。

在上面的例子中,不同的肉類代表了不同的類別 (Class),如何烹調這件事情則代表了操作原則 (Policy)。由此可知,原則 (Policy) 所體現的是一個行為或者策略,而且這個行為需要是單純的,這有利於後續我們可以透過不同原則的組合去組裝出一個具有複雜行為的功能:

template <class MeatType, class CookPolicy, class FlavorPolicy> class BurgerCreator; class Duck; class Roasted; class BBQSause; auto burger = BurgerCreator<Duck, Roasted, BBQSause>::create(); /** ^^^^ ^^^^^^^ ^^^^^^^^ * | | | * Data | | * Policy A | * Policy B */

而不是把所有東西通通寫進一坨很巨大的 BurgerCreator 類別當中。

範例

接下來我們開始使用書中的例子來解釋 Policy-Based Class Design. 書中的例子是我們想要使用以下三種方法 (a.k.a. 原則 Policy) 去生成一個在動態記憶體上的物件:

  1. C++ new operator
  2. C-style malloc
  3. Prototype cloning

因為我們不知道物件是誰,所以我們很理所當然的需要模板來幫忙:

#include <cstdlib> // 1. C++ new operator template <class T> struct OpNewCreator { static T* create() { return new T; } protected: ~OpNewCreator() {}; // See #1 }; // 2. C-style malloc template <class T> struct MallocCreator { static T* create() { void *buf = std::malloc(sizeof(T)); return buf ? new(buf) T : 0; } protected: ~MallocCreator() {}; // See #1 } // 3. Prototype cloning template <class T> struct PrototypeCreator { PrototypeCreator(T* prototype = 0): prototype(prototype) {} T* create() { return prototype ? new T(*prototype) : 0; } PrototypeCreator& set_prototype(T* prototype) { this->prototype = prototype; return *this; } virtual ~PrototypeCreator() {} private: T* prototype; }

在上面三個例子中,他們的介面雖然有一點點不一樣 (PrototypeCreatorset_prototype(...) 但其他兩個人沒有),但同樣都是使用 create 這個函數名稱,用各自的方法,去創造新物件。

接下來我們生一個類別給他創造,我們叫他 Widget

#include <iostream> struct Widget { Widget(std::string name = "None") : name(name) {} virtual void print() { std::cout << "Hello I'm a widget (" << name << ")."; } virtual ~Widget() {} protected: std::string name; };

然後我們可以創造出一個適用於 Widget 的生成管理員 WidgetManager

template <class CreationPolicy> class WidgetManager : public CreationPolicy {};

最後在使用者層面,我們就可以透過以下方式生成 Widget,並且叫他做一點事:

int main() { // 1. C++ new operator auto x = WidgetManager<OpNewCreator<Widget>>::create(); if (x) x->print(); // 2. C-style malloc auto y = WidgetManager<MallocCreator<Widget>>::create(); if (y) y->print(); // 3. Prototype cloning auto z = WidgetManager<PrototypeCreator<Widget>>().set_prototype(x).create(); if (z) z->print(); if (x) delete x; if (y) delete y; if (z) delete z; return 0; }

他會輸出

Hello I'm a widget (None).
Hello I'm a widget (None).
Hello I'm a widget (None).

我們會稱使用了一種以上 Policy 的類別稱為 hosts 或者 host classes (宿主類別). 在這個例子當中就是 WidgetManager.

#1 避免解建構純靜態類別

當一個類別可以被物件初始化,就應該實作解建構子 (如果要讓其他人繼承他,就需要是 virtual destructor), 否則會遇到不預期的行為 (undefined behavior). 但是在純靜態類別當中新增 virtual destructor 會造成不必要的記憶體成本,因此針對這情形的最佳做法是定義 protected destructor, 這樣在外部就不能對他解建構,但仍然允許繼承他的類別去對他解建構。

更好的範例:使用 Template Template Parameters

稍微仔細一點看上面例子的話,會發現 WidgetManager 這個命名其實是虛有其表,原因是實際決定生成類別的地方其實是在 CreationPolicy 當中所餵入的類別。為了解決這個問題,我們可以使用所謂的 Template Template Parameters (模板作為另一個模板的參數?),去告訴 WidgetManager 說:他所使用的模板參數 CreationPolicy 其實也是一個模板,然後進階來說,WidgetManager 可以事先決定 CreationPolicy 裡面要放什麼類別進去:

template <template <class Created> class CreationPolicy> class WidgetManager : public CreationPolicy<Widget> {};

透過上述定義,我們在實際使用時就不需要指定 Widget 這個類別:

int main() { // 1. C++ new operator auto x = WidgetManager<OpNewCreator>::create(); if (x) x->print(); // 2. C-style malloc auto y = WidgetManager<MallocCreator>::create(); if (y) y->print(); // 3. Prototype cloning auto z = WidgetManager<PrototypeCreator>().set_prototype(x).create(); if (z) z->print(); if (x) delete x; if (y) delete y; if (z) delete z; return 0; }

這使得程式碼看起來更直覺舒服了,也避免了不必要的潛在風險。與此同時,在 WidgetManager 當中也保留了 CreationPolicy 這個模板類別,如果有需要的話,WidgetManager 也可以拿他去搭配其他的類別,以實現其他額外功能。

另外補充說明,上述例子中的 Created 是可以省略的,所以最終寫起來會像是這個樣子:

template <template <class> class CreationPolicy> class WidgetManager : public CreationPolicy<Widget> {};

然後,你也可以給定一個預設原則:

template <template <class> class CreationPolicy = OpNewCreator> class WidgetManager : public CreationPolicy<Widget> {};

透過不完整實體化 (Incomplete Instantiation) 實現非強制性功能 (Optional Functionality)

光從標題看起來就是一個酷東西。

在 C++ 編譯器欲實體化模板類別的時候,如果有成員函數沒有被使用的話,該函數就不會被實體化到該類別,編譯器會對他做的事情頂多就是檢查一下語法。

這是一個很重要的性質,原因是他允許我們只針對部分 Policy 實現一些非強制性的功能函數,儘管他對於其他 Policy 來說並不適用 (或者說根本跑不起來),但程式碼仍然能正常運作──只要你不要錯誤呼叫到那些函數就好。

就算不小心呼叫到了也不是什麼大問題,因為在編譯階段就會告訴你寫錯了:記住,編譯時期錯誤永遠比執行時期錯誤還要好處理太多了!

我們拿 PrototypeCreator 為例,我們可以事先設定一些物件當作是預設原型,然後把他們實作在 WidgetManager

Widget default_widget("Default Prototype"); template <template <class> class CreationPolicy> struct WidgetManager : public CreationPolicy<Widget> { WidgetManager& use_default_prototype() { CreationPolicy<Widget>& creator = *this; creator.set_prototype(&default_widget); return *this; } };

很明顯地,我們知道當 CreationPolicy 不等於 PrototypeCreator 時,這兩個函數肯定是會有問題,不過我們只要在那時候不要呼叫到他們就好,也就是說:

int main() { // 1. C++ new operator auto x = WidgetManager<OpNewCreator>::create(); if (x) x->print(); // 2. C-style malloc auto y = WidgetManager<MallocCreator>::create(); if (y) y->print(); // 3. Prototype cloning (w/ optional functionality) auto z = WidgetManager<PrototypeCreator>().use_A_prototype().create(); if (z) z->print(); if (x) delete x; if (y) delete y; if (z) delete z; return 0; }

他仍然是可以運作的,且輸出結果如下:

Hello I'm a widget (None).
Hello I'm a widget (None).
Hello I'm a widget (Default Prototype).

這意味著:

  • 如果使用者用了一個支援原型 (prototype) 的 Creator,那麼他就可以使用 use_default_prototype.
  • 如果使用者用了一個不支援原型 (prototype) 的 Creator,同時呼叫了 use_default_prototype,那麼會產生編譯時期錯誤。
  • 如果使用者用了一個不支援原型 (prototype) 的 Creator,但沒有呼叫 use_default_prototype,那麼程式仍然可以正常運作。

這給宿主類別的設計帶來了非凡的自由度,你可以隨意地新增額外功能,並且允許他優雅降級 (Graceful degradation).

組合原則類別

上面的範例部分展示了使用原則的優勢,不過當一個宿主類別使用了複數個原則時,最大優勢才能夠被顯現出來。

我們以設計一個 generic smart pointer 為例 (我們先看結構,不看實作──書本第七章會提及細節):

template < class T, template <class> class CheckingPolicy, template <class> class ThreadingModel > class SmartPtr;

我們可以看到,實體化 SmartPtr 需要一個指標型態 T 以及兩個原則 CheckingPolicyThreadingModel. 其中

  • CheckingPolicy 決定了在 dereference 時候的檢查機制
  • ThreadingModel 決定了線程模式 (考量讀寫問題)

針對 CheckingPolicy 我們可以假設有以下兩種原則:

  • NoChecking: 不檢查
  • EnforceNotNull: 強制指標為非空,否則跳錯
  • EnsureNotNull: 強制指標為非空,否則給他一個預設值

針對 ThreadingModel 就單純一點,分成單線程 SingleThreaded 跟多線程 MultipleThreaded 兩種。

那麼我們可以根據使用情境創造出我們想要的指標型態,舉例來說:

#ifdef DEBUG_MODE using WidgetPtr = SmartPtr<Widget, EnforceNotNull, SingleThreaded>; #else using WidgetPtr = SmartPtr<Widget, NoChecking, SingleThreaded>; #endif

這裡所代表的意思是說:

  • 如果在偵錯模式下,我們會強制檢查說有沒有空指標,有的話就讓他跳錯。
  • 如果在發行模式下,我們就不檢查指標,降低運算量。

而這些行為只需要更動一行程式碼,就能夠對整個 program 生效,在上述例子中,我們甚至連程式碼都不需要改,只要使用不同編譯參數即可。與此同時,當我某一天需要讓程式碼支援多線程執行的時候,同樣也只需要把 SingleThreaded 改成 MultipleThreaded 即可。

意思是說,我們可以透過不同的組合帶來豐富的效果,但並不需要對函式庫進行大幅改動 (或者大幅新增程式碼),而且他的組合性是乘法疊加的:舉例來說,如果我有

nCheckingPolicy 以及
m
ThreadingModel,那函式庫就能提供
n×m
種不同屬性的指標,同時如果函式庫在未來新增了一個新的 CheckingPolicy,那麼也只需要實作一個類別,就能夠連帶產生
m
種新的不同屬性的指標,也就是
(n+1)×m
.

接下來會提到 Policy-Based Class Design 的進階應用,不過你也可以選擇先跳過他,傳送門在這裡:基於原則設計方法的設計原則

進階應用 (WIP)

透過原則改變類別結構

相容原則與不相容原則

基於原則設計方法的設計原則

當今天要把一個類別拆分成多個原則所組合的宿主類別時,有一個重要的性質是這些類別都必須要是彼此獨立的 (orthogonal),舉例來說,我今天如果想要在 SmartPrt 類別中拆分出 IsArray 以及 DestroyPolicy,那麼就會產生以下問題:

  1. 如果一個指標不是陣列的話,那解建構就是使用 delete 即可。
  2. 但如果一個指標是陣列的話,那解建構就需要對每個元素使用 delete, 並且對陣列本身使用 delete[].

這使得說 IsArray 原則跟 DestroyPolicy 原則是有關連性的 (nonorthogonal),這在原則設計當中是需要避免的。假使這個關聯性是難以避免的話,有一個解套是將其中一個原則送到另一個原則去,讓兩者的關聯性能互相溝通,那麼就能保留 SmartPtr 的彈性,但缺點是原則的封裝性 (encapsulation) 會下降。

另外也不要過分拆分原則,一般來說一個宿主類別擁有超過 4-6 個獨立原則就偏詭異了,除非這能夠展現出複雜、無與倫比、以及有用的功能。

結論

設計是一種選擇。因為有太多種設計方法能夠解決一個問題,因此你需要抉擇說哪一種設計方法的組合應用可以優雅地解決問題。從上層的函式庫結構到下層的程式碼語法都是選擇的議題,同時請留意到我們所談的是設計方法的「組合」應用,因此可執行 (但不優雅) 的設計類型可以說是令人絕望的多。

為了使用合理且少量的程式碼去抵抗設計多樣性,函式庫設計師就需要去開發一些特殊的技術,使得說能透過少量的原始程式碼去支援靈活的程式碼生成──泛型編程就是在做這件事情。其中一種策略是:函式庫公開建構宿主類別的規範,讓使用者可以建構自己的規範,使其達成功能多樣性。這便是基於原則設計 (Policy-Based Class Design) 的精神。

基於原則設計方法定義了原則 (Policy)、宿主類別 (Host Class) 的角色,透過 C++ 的語言特性,開發人員可以在上面加入各式功能,並且在組合複數個原則的情況下能發揮該設計方法的最大價值。

在設計宿主類別時,你需要遵循兩個準則。第一個準則是對各種原則進行合理的命名以及區分;第二個準則為確保不同原則之間的獨立性 (orthogonality),一個評斷的標準是各自可以獨立進行修改,且結果符合預期。

致謝

感謝 Alexandrescu 讓我重新認識 C++.