物件導向設計新思維:深入Policy-Based Class Design新大陸

自前一篇「物件導向設計新思維:探索Policy-Based Class Design新視界」發佈以來,已經過了很長的一段時間。在這篇千呼萬喚始出來的續集裡,將會進一步深入探索 Policy-Based Class Design 背後的設計概念與理論,以及在實作層面上經常會遭遇到的問題;最後,以一個遊戲系統中常見的音源引擎 (Audio Engine) 類別設計為範例,做為本文的片尾曲目。

記得曾經有人問過比爾蓋茲,如果有天他被迫一人獨自在荒島上生活,身旁只有一樣物品陪伴的話,最想要什麼東西?他的回答是:「一部電腦與編譯器。」許多程式設計者心裡所嚮往的美好世界,就是那種無拘無束、無邊無際的自由;好像只要手指還能夠活動、頭腦還能夠寫程式,天底下就沒有辦不到事情!只是,有時候這種無限度的自由,反而會對程式系統造成負面的影響。

《C++ 設計新思維》(Modern C++ Design) 書中的第一章,就對於程式系統的設計就開宗明義地闡述:

理想上,一個良好設計應該在編譯期強制表現出大部分 constraint(約束條件、規範)。

身為一位程式設計者,或多或少都有把自己的程式碼交給其他程式設計者使用的經驗,相對於「程式撰寫者」來說,所謂的「程式使用者」,就是那些使用你所撰寫的程式碼的人。就像是遊戲程式中經常使用的 DirectX、OpenGL 或者 .NET Framework 以及遊戲函式庫與引擎等等,我們都是身為「程式使用者」的身份。

在一個龐大的程式系統框架中,動輒擁有成千上百的原始碼檔案與物件類別,在交錯複雜的物件體系與階層架構中,如何讓使用者不會迷失方向而誤入歧途?即使想要依賴文件資料、UML 圖示以及程式碼中的註解,也只能夠提供相當有限的指引。有經驗的程式設計者應該都知道,很多函式庫與引擎系統都有各自的「眉角」,如果不能真正瞭解這些系統的使用邏輯,經常會造成事半功倍的反效果。因此,換個方向以「程式撰寫者」的角度來看,當我們在撰寫程式時,最重要的關鍵就是如何正確地傳達設計者的意圖 (intention)

舉例來說,C++ 語言中的在物件封裝設計,缺少了 package 的概念,只要是宣告為 public 的成員函式,就無法阻止其他的使用者進行操作,即使這些類別與函式的原意並非如此。由 C++ 語言的所提供的能力,從程式撰寫者的角度來看,是一種很大的自由度;然而如果從程式使用者的角度來看,卻很可能是一種設計上的缺陷。而 C# 的 internal 關鍵字則正好補足了這一點缺陷,讓 public 的屬性只存在於同一個專案的 package 中,對於其他的專案來說,則變成不能擅自使用的私有類別或者私有成員函式。

做為一個有經驗的程式設計者,我們經常會半強迫性或者半自願性地寫出「語法有效」但「語意無效」的程式碼。什麼叫做「語法有效,語意無效」的程式碼?舉個遊戲程式架構中常見的實例,例如 LifeEntity 是一個生物體的基礎類別,而遊戲中的怪物 Monster 類別與玩家 Player 類別,同樣繼承自 LifeEntity 類別。遊戲中的怪物需要 AI 功能的設計,而為了要使 Monster 類別能夠設定 AI 相關的程序,並且不至於影響操作介面,因此必須要在 LifeEntity 類別中,加入虛擬函式 SetAI() 以讓子類別們自行覆寫定義行為:

class LifeEntity
{
public:
    virtual bool SetAI(AI* _pkAI) = 0;
}

class Monster
{
public:
    virtual bool SetAI(AI* _pkAI)
    {
        m_pkAI = _pkAI;
        
        // Do AI initialize stuff
        // ...
        
        return true;
    }
}

class Player
{
public:
    virtual bool SetAI(AI* _pkAI)
    {
        // 玩家不需要AI,忽略不處理
        return false;
    }
}

由於處理玩家的 Player 類別,不需要處理 AI 的相關程序,所以對於 Player 類別來說,SetAI() 函式就形成了「語法有效,語意無作用」的函式。像這樣的程式碼,需要程式設計者花費額外的心力辨識出使用的「眉角」。在物件導向設計的領域中,程式設計者經常難以避免地撰寫出如此語法有效但語意無效的程式碼,而當我們把這樣的程式碼交給其他的程式設計者使用時,如果缺少了清楚易懂的文件說明,就很容易會造成各種誤用的問題。Policy-Based Design,將能夠幫助程式設計者盡可能免除這些問題,達到良好的設計約束條件。

inheritance-vs-policy-based在物件導向程式設計中,我們所熟悉的繼承體系通常是先定義出基底類別 (Base Class),然後再視實際需求繼承出各個衍生類別 (Derived Class),在盡量不更動類別介面的前提下,由子類別們擴充父類別的行為。有趣的是,以 Policy-Based Design 實做出來的系統架構恰好與傳統的繼承體系相反,反而是以「子類別」Host Class 定義出這組系統的操作介面,然後藉由「父類別」Policy Class 定義出實作的細節。如圖所示,Host Class 會繼承它所需的各個 Policy Class,並且在 Host Class 中定義出操作行為的骨架流程,至於真正的實作細節,則全權委派 (delegate) 給各個 Policy Class 進行處理,可以說是反轉了物件導向程式設計中的繼承關係。另外需要特別注意的是,與傳統的繼承概念不同,Policy-Based Class Design 中的繼承體系,並沒有模擬出 is-a 的階層關係。

決定「哪個 policy 被使用」的是使用者而非程式庫自身。和一般多重介面不同的是,policies 給予使用者一種能力,在型別安全 (typesafe) 的前提下擴增 host class 的功能。

表面上看起來,Policy-Based Design 和以虛擬函式實做的 State Pattern 或 Strategy Pattern 作用相同,都可以達到易於更換不同實做程式碼的設計目標。然而 Policy-Based Design 不同於虛擬函式於執行時期所提供的動態繫結 (Dynamic Binding) 能力,而是利用 template 的特性於編譯時期產生出靜態的類別結構以及型別資訊,由編譯器而非程式使用者把關,在編譯時期就可以阻擋誤用程式碼的可能性,提供給程式使用者更加安全的程式架構。因此,Policy-Based Design 並不能夠取代 State Pattern 或 Strategy Pattern 的動態繫結設計方法,但是也存在著虛擬函式所無法提供的優點。

再者,與傳統虛擬函式介面不同的是,Policy-Based Design 僅對於 Policy 類別的語法構造做出規範,屬於「語法導向」(Syntax Oriented) 而非「標記導向」(Signature Oriented) 的設計,所以 Policy 類別的成員函式可以是一般函式、虛擬函式甚至靜態函式。以之前使用的 ReadingPolicy< Subject >::Read() 為例,以下三種形式都是合法的形式:

template< class T >
class BinaryReader
{
public:
    void Read();
}

template< class T >
class XmlReader
{
public:
    static void Read();
}

template< class T >






class TextReader
{
public:
    virtual void Read();
}

在前篇文章的迴響中,有人提到了一個疑問:「如果 XML 和 Binary 的 Read()、Write() 有參數,但參數不一樣的話,Manager 該怎麼辦才能達成需求?」關於這個實做上經常會遇到的問題,我們可以利用 template 的不完整具現化 (Incomplement Instantiation) 特徵,藉以實現各個參數不同的成員函式。

如果 class template 有一個成員函式未曾被用到,它不會被編譯器具體實現出來。編譯器不理會它,甚至也許不會為它進行語法檢驗。

有撰寫樣版類別經驗的程式設計者應該都知道,當我們在表頭檔中寫入樣版類別的成員函式宣告與定義,接著按下專案建置的按鈕後,編譯器並不會立即對這些程式碼進行語法驗證與編譯程序,而是需要等到其中的成員函式真正被使用之後,編譯器才會真正開始動手工作!例如,在 Visual Studio 2005 中,即使寫出這樣無意義而錯誤的程式碼,編譯器也不會發出抗議:

template< class T >
class SuperReader
{
public:
    bool Read()
    {
        // 無意義的程式碼
        T asdasopqwe;
        return kerokero
    }
};

回到前篇文章迴響的 ReadingPolicy 類別問題中,假設 BinaryReader 與 XmlReader 需要使用不同的參數,以利於進行讀取程序:

template< class T >
class BinaryReader
{
public:
    bool Read(DataStream& rkStream);
}

template< class T >
class XmlReader
{
public:
    bool Read(TiXmlElement* pkRoot);
}

BinaryReader 需要接受 DataStream 類別以進行二進位制的檔案讀取,而 XmlReader 則是接受 TinyXML 中的 TiXmlElement 類別,對檔案進行讀取動作。接著,在原有的 ResourceIOManager 類別中,可以撰寫兩個接受不同參數版本的 Read() 函式:

template
<
	class Subject,
	template< class > class ReadingPolicy,
	template< class > class WritingPolicy,
	template< class > class ErrorHandlingPolicy
>
class ResourceIOManager
	:
	public ReadingPolicy< Subject >,
	public WritingPolicy< Subject >,
	public ErrorHandlingPolicy< Subject >
{

public:
	void Read(DataStream& rkStream)
	{
		ReadingPolicy< Subject >::Read(rkStream);
	}
	
	void Read(TiXmlElement* pkRoot)
	{
		ReadingPolicy< Subject >::Read(pkRoot);
	}
};

如果具現化的 ResourceIOManager 物件繼承自 BinaryReader,程式使用者只能夠呼叫 Read(DataStream& rkStream) 函式;如果具現化的 ResourceIOManager 物件繼承自 XmlReader,程式使用者就只能夠呼叫 Read(TiXmlElement* pkRoot) 函式。萬一程式使用者不小心使用了錯誤的版本,編譯器馬上就會察覺使用者的意圖而立即阻攔下來,免於在程式系統中鑄成大錯。

瞭解了 Policy-Based Design 的概念之後,如果要把原來既存的類別架構設計,更改成為符合 Policy-Based Design 的設計,其中最重要也最困難的部分,就是如何將原有類別正確分解為 Policy Class。在將原有類別拆解成各個 Policy Class 時,必須要盡可能達成正交分解 (Orthogonal Decomposition) 的目標;也就是在拆解出來的 Policy Class 中,不能夠彼此相依或相互影響,才不會使類別的架構設計過於複雜而難以使用。越是達到相互獨立的 Policy Class,就越能夠發揮 Policy-Based Design 的功能性。

最後,來看一個應用於遊戲系統中的音源引擎設計實例。顧名思義,音源引擎是用來處理包含音效與音樂在內的相關程序;在 AudioEngine 類別中,需要接受程式使用者的需求來創建音源檔案,並且於類別內部對這些音源物件進行管理。對於程式使用者來說,只需認識 AudioEngine 類別以及由 AudioEngine 創建的 AudioObject 物件即可,即使在 AudioEngine 內部抽換了不同的實做版本,也不應該影響到使用者的程式碼。

class AudioEngine
{
public:
	AudioObject* CreateAudio(std::string& _kFileName)
	{
		AudioObject* pkAudio = NULL;
		// Create the audio!
		// ...
		return pkAudio;
	}
};

以 Policy-Based Design 的方法思考,在音源系統中,AudioEngine 類別就是最適合做為 Host Class 的類別。接下來,要考慮的設計概念就是如何拆解出各個正交的 Policy Class。以 Audio 系統的功能來說,Renderer 能夠有各種不同的版本,例如使用 OpenAL 或者 DirectSound 等等,因此合適於抽取出來做為 Policy Class。再者,在系統內部的管理層面,究竟應該使用以檔案名稱為鍵值的 std::map 結構,或者是使用單純的 std::vector 儲存每一個創建出來的音源檔?所以音源物件的儲存管理功能,也很適合抽取出來成為 Policy Class。

  • RendererPolicy:由 Renderer 創建、操作音源檔案。
  • StoragePolicy:儲存並管理由 Renderer 創建出來的音源檔案。

在此的 RendererPolicy 與 StoragePolicy 符合前述的正交分解觀念,功能彼此獨立而不會相互影響。接著,就可以開始動工打造 AudioEngine 類別:

template
<
	class Subject,
	template< class > class RendererPolicy,
	template< class > class StoragePolicy
>
class AudioEngine
	:
	public RendererPolicy< Subject >,
	public StoragePolicy< Subject >
{
public:
	Subject* CreateAudio(std::string& _kFileName)
	{
		Subject* pkAudio = NULL;
		if (!StoragePolicy< Subject >::GetAt(_kFileName, pkAudio))
		{
			pkAudio = RendererPolicy< Subject >::CreateAudio(_kFileName);
			StoragePolicy< Subject >::SetAt(_kFileName, pkAudio);
		}
		return pkAudio;
	}

	void PlayAudio(Subject* _pkAudio)
	{
		RendererPolicy< Subject >::PlayAudio(_pkAudio);
	}

	void PositionAudio(Subject* _pkAudio, float x, float y, float z)
	{
		RendererPolicy< Subject >::PositionAudio(_pkAudio, x, y, z);
	}
};

在 CreateAudio() 函式中,首先把檔案名稱丟進 StoragePolicy 的 GetAt() 函式,如果無法取得適當的物件,就需要交給 RendererPolicy 進行創建音源物件的動作;在創建完畢後,再將物件丟回 StoragePolicy 的 SetAt() 函式進行資源的管理。而播放音源檔案與調整音源位置的功能,則全部交由 RendererPolicy 處理,與 StoragePolicy 無關。

將音源引擎的功能分解為 RendererPolicy 與 StoragePolicy 之後,就可以衍生出 Policy Class 的各種不同實做版本:

  • RendererPolicy
    • DXSoundRenderer:以 DirectX Sound/Audio 建立的音源 Renderer。
    • OpenALSoundRenderer:以 OpenAL 建立的音源 Renderer。
    • WMMRenderer:以 Windows Muiltmedia 函式庫建立的音源 Renderer。
  • StoragePolicy
    • VectorStorage:使用 std::vector 做為音源物件的儲存容器。
    • StringMapStorage:使用 std::map 做為音源物件的儲存容器。

有了 Policy Class 之後,就可以依照程式使用者的需求,使用不同的 Policy 零件,自行組裝合適的 AudioEngine 類別:

// 使用 DirectX Sound,以及 std::vector 容器
AudioEngine< DXSoundObject, DXSoundRenderer, VectorStorage > kEngine1;
// 使用 OpenAL ,以及 std::map 容器
AudioEngine< OpenALObject, OpenALSoundRenderer, StringMapStorage > kEngine2;
// 使用 Windows Muiltmedia,以及 std::map 容器
AudioEngine< WMMObject, WMMRenderer, StringMapStorage > kEngine3;

回過頭來重新檢視,Policy-Based Design 可以說是利用 Host Class 定義出程序的執行流程,而由各 Policy Class 定義執行細節的一種設計方法;Host Class 是骨架,而 Policy Class 們則是其中的血肉。如上述音源引擎的設計原理,同樣也能夠應用在繪圖系統、人物系統或者其他的系統中。

在撰寫一個程式系統時,經常會遭遇到效能或者其他的因素而必須變更程式碼的設計;例如,將資源物件的儲存容器由 std::vector 更改為 std::map,或是將原來的 Xml 讀檔格式轉換為 Binary 格式。在缺乏良好架構設計的情況下,我們很容易就會在程式碼中散佈大量的 #ifdef、#else 以及 #endif 敘述句,用來分隔出「目前需要」與「暫時不使用」的程式碼。然而這些散佈在程式碼中的的 #ifdef 區塊,經常會使得程式系統的維護更為困難,也更加難以理解程式碼的來龍去脈。如果使用了 Policy-Based Design,就能夠幫助我們優雅而輕巧地擴充新的實作版本,同時也不會影響到舊有的實作版本。

以目前的現況來說,使用 Policy-Based Design 最大的缺點在於多數編譯器對於 template 語法的友善度不足;以 Visual Studio 2005 為例,只要不小心多使用了一個逗號,可能就會產生十數條莫名其妙的錯誤訊息,令人摸不著頭緒而難以排除問題。另外,為了要使團隊中的其他同儕徹底瞭解 Policy-Based Design 的設計概念與實作方法,也需要額外花費一番功夫與時間。

以前剛看到 Policy-Based Design 設計方法時,心裡只有滿滿的讚嘆之情,而目前我已經實際將 Policy-Based Design 應用在工作的專案中,執行效果相當不錯,所以我也希望能夠讓更多的程式設計者認識這個有趣的設計方法,期待未來能夠繼續延伸更多的應用與發展。Policy-Based Design 巧妙地結合了多重繼承與樣版類別的語言特徵,藉由「限制」的方式使程式設計者獲得更多的「自由」,帶領程式設計者航向一片充滿全新視野的新大陸。如果只是犧牲一點點個人的自由,而能夠換來更美好的世界,又何樂而不為之呢?

程式碼範例下載:PolicyBasedAudioEngine_SampleCode.zip (下載次數: 1258 )

13 Replies to “物件導向設計新思維:深入Policy-Based Class Design新大陸”

  1. @jieNew:
    你好,
    很高興這兩篇文章對你有幫助!

    感謝你的支持。 ^^

    @兔子安:
    在範例程式中,本來就只有包含 Policy-Based Audio Engine 的部分而已喔~
    文章中所舉的其他例子,就沒有包含在裡面了。

    謝謝回應。 ^^

  2. 你解釋的很清楚,本來我看書只能略為掌握概念,經過大大講解後就豁然開朗了,受益良多 感謝分享~

  3. 很久以前看過modern C++ 這本書,無意間又看見你的文章
    真是有點懷念!

    另外有個疑問
    為什麼要用多重繼承來搭配 Policy class?
    而不是用 Composition 的方式呢?
    若用 Composition,把 Policy class 變成 Host class 的 data member
    本質上是否有所不同?

  4. @SILENCE:
    相較於比較鬆散的 Composition 方式,採用繼承體系的 Policy-based Class 設計,具有比較強的耦合性與約束力;約束力越強,自由度越低,但理論上也越不容易犯錯。我認為這是 Policy-based Class 設計的主要特點之一。

  5. 版主您好,對於 template template parameter的使用時機有點小小疑問

    對於 policy 推導部份,反正都是由 Subject 推導,與其寫在內部,何不往上拉,變成外部傳入 policy ?如下所示

    template< typename Subject , typename ReadP = ReadingPolicy >
    class ResourceIOManager : public ReadP

    不但保留了自動推導的部份,而且有機會由外部抽換掉ReadPolicy,可使之變成 AudioEngine < Subject, ReadingPolicy >

    對於這部份版主是怎樣看的?

    https://gist.github.com/anonymous/7140159

  6. @Eric:
    我不是很瞭解你的疑慮在哪。請看文中最後一段程式碼,Policy-Based Design 已經是利用外部參數傳入並組裝而成各種不同用途的 AudioEngine 類別了。當然,以此方式組合出來的類別,是靜態成形的類別,組成後無法被改變,或許也不該被改變其行為。

  7. 你好,版主。不好意思回復您 那麼久的文章了 小弟我最近才看到moder c++ design這本奇書。有關於Read()參數該怎麼辦的問題 我記得沒錯C++11不是新增了 variadic templates可以使用
    那這樣的話 在C++11又該怎麼寫呢??

Leave a Reply