物件導向設計新思維:探索Policy-Based Class Design新視界

Policy-Based Class Design 到底是什麼樣的東西?又能夠用來解決什麼樣的問題?本文將由一個在程式專案中常見的現實案例,帶領讀者進入 Policy-Based Class Design 的全新思維領域。

學習一項新的思維方法或實作技術,最容易的方法就是從需求面出發。所以在介紹 Policy-Based Design 之前,先來看看一個現實中經常會遇到的程式設計案例:假設在處理 Input/Output 功能的系統層,原來存在著 BinaryReader 與 BinaryWriter 兩個類別,分別用來讀取以及寫入二進位制的資料串流。

現在,在新的專案項目中,我們希望能夠利用這兩個既存的 IO 類別,但是又不希望將檔案的讀取與寫入動作,分成 BinaryReader 與 BinaryWriter 兩個不同的類別進行處理。因此,程式設計者需要設計一個新的 IO 管理者類別,將原有的讀取與寫入功能合併在一起,以達到集中操作以及檔案資源管理的功用。

而為了盡量減少對既存程式碼的修改,其中一種設計方法是使新的 IOManager 類別,同時繼承自 BinaryReader 與 BinaryWriter 類別,然後藉由虛擬函式的宣告,修改專案所需的行為程序:

class IOManager
    :
    public BinaryReader,
    public BinaryWriter
{
public:
    virtual void Read(); // 覆寫自 BinaryReader
    virtual void Write(); // 覆寫自 BinaryWriter
}

這樣設計的方法確實能夠在不修改原有類別的前提下,達到 IO 系統介面集中的目的。程式系統的撰寫者無須再同時照料 BinaryReader 與 BinaryWriter 兩個類別,只要一個 IOManager 類別就能夠處理 IO 層面的功能操作。

而除了前例所使用的多重繼承 (Multiple Inheritance) 以外,另外一種設計方法則是使用物件複合 (Object Composition) 的方式,將 BinaryReader 與 BinaryWriter 類別的物件當做成員變數包含在 IOManager 類別中:

class IOManager
{
public:
    void Read()
    {
        pkReader->Read();
    }

    void Write()
    {
        pkWriter->Write();
    }

private:
    BinaryReader* m_pkReader;
    BinaryWriter* m_pkWriter;
}

為了將 BinaryReader 與 BinaryWriter 類別所提供的功能當作黑盒子組件使用,不論是多重繼承或者物件複合,都是很常見的合理作法。然而,如果在專案開發過程中提出了新的設計需求,需要讀取其他格式的檔案,例如 XML 檔案時,應該要如何整合至 IOManager 類別中?

在程式系統的設計中,大部分的情況下我們會希望在管理者類別中,能夠盡量保持一致的使用介面,例如 IOManager 類別中的 Read() 與 Write() 函式,對於 IO 系統的使用者來說,並不需要去瞭解目前讀取的是 Binary 格式或 XML 格式的資料,只需要直接使用 Read() 與 Write() 函式就能夠達到讀取與寫入檔案的目標。

為了完成這項新的設計需求,程式設計者需要依照前述的作法實作出 XmlReader 與 XmlWriter 類別之後,再以多重繼承或物件複合的方法創建出新的 XmlIOManager 類別:

class XmlIOManager
    :
    public XmlReader,
    public XmlWriter
{
public:
    virtual void Read(); // 覆寫自 XmlReader
    virtual void Write(); // 覆寫自 XmlWriter
}

如果需要讀取或寫入 Binary 格式的檔案就使用原來的 IOManager;而如果需要讀取或寫入 XML 格式的檔案,就利用新的 XmlIOManager 類別。OK,搞定收工!

等等,如果假設萬一有可能(就是真的會發生)專案需要以 XML 格式讀取檔案,同時使用 Binary 格式寫入檔案時,應當如何是好?同樣地,我們可以使用物件複合的方式,創建一個新的(名字很長的) XmlInputBinaryOutputManager 類別:

class XmlInputBinaryOutputManager
{
public:
    void Read()
    {
        pkReader->Read();
    }

    void Write()
    {
        pkWriter->Write();
    }

private:
    XmlReader* m_pkReader;
    BinaryWriter* m_pkWriter;
}

問題是解決了,但是按照這樣的程式設計與實作方法,很快地就會出現 BinaryInputBinaryOutputManager、XmlInputXmlOutputManager、XmlInputBinaryOutputManager、BinaryInputXmlOutputManager 四個高階功能介面相同(名字同樣很長),但實作細節不同的 IOManager 類別家族。

另外一個比較理想的解決方案,應該是新建一個抽象的 BaseReader 類別以及一個抽象的 BaseWriter 類別,使 BinaryReader 與 XmlReader 衍生自 BaseReader 類別,BinaryWriter 與 XmlWriter 衍生自 BaseWriter 類別,然後在建構 IOManager 類別時,傳入所需的實作版本:

class IOManager
{
public:
    IOManager(BaseReader* pkReader, BaseWriter* pkWriter)
    {
        m_pkReader = pkReader;
        m_pkWriter = pkWriter;
    }

    void Read()
    {
        pkReader->Read();
    }

    void Write()
    {
        pkWriter->Write();
    }

private:
    BaseReader* m_pkReader;
    BaseWriter* m_pkWriter;
}

不是很直覺的使用方式,但還算是個能夠接受的解決方案。只是,做為程式設計者,是否只能屈服於這樣的設計方法?是否還有其他的可能性存在?

其實,在上述的程式設計案例中,已經點出了一個在專案開發流程中常見的情形:存在數種不同版本的設計實作方案,每次只從中選擇其一的方案,但是又需要保持切換與擴充的彈性。設計,本身就是一種不斷變動的行為。設計者經常會遇到兩難的情境:一方面既想保持系統的修改彈性,另一方面又要能夠達到理想的執行效能與成果。

《C++ 設計新思維》(Modern C++ Design) 中的第一章,作者 Andrei Alexandrescu 為人們展示了一種全新的 Template 程式設計技術,稱之為 Policy-Based Class Design。Policy-Based Class Design 同時使用了 Template 以及 Multiple Inheritance 兩項技術,結合兩者的優點,成為一種既新穎又實用的設計方法。Policy,字面上的意思是:政策、方針,或策略。所以 Policy-Based Class Design 也就是將原來錯綜複雜的程式系統,拆解成為數個獨立運作的「策略」,然後藉由 Policy 之間的分工合作與交替組合能力,使得以這個設計架構完成的程式系統能夠同時得到擴充性、彈性與易用性等多項優點。

如果使用 Policy-Based Design 方法,重新改寫上述的類別設計範例,可以得到以下的初步結果:

template
<
	class ReadingPolicy,
	class WritingPolicy
>
class ResourceIOManager
	:
	public ReadingPolicy,
	public WritingPolicy
{
public:
	void Read()
	{
		ReadingPolicy::Read();
	}

	void Write()
	{
		WritingPolicy::Write();
	}
};

請特別注意在這段程式碼中,同時運用 Template 與 Multiple Inheritance 的作法。在 Template 中所使用的 ReadingPolicy 與 WritingPolicy 類別,是程式設計者能夠在編譯時期自行決定的一種類別參數,再加上 ResourceIOManager 類別繼承自這兩個當作 Template 建構參數傳入的 ReadingPolicy 與 WritingPolicy 類別,所以在 Read() 與 Write() 函式中,就可以直接呼叫並且委託這兩個 Policy 類別進行讀取與寫入的程序。

而上述兩項需要傳入做為 Template 建構參數的 ReadingPolicy 與 WritingPolicy 類別是什麼?其實也就是之前已經存在的 BinaryReader、BinaryWriter、XmlReader 以及 XmlWriter 類別:

class BinaryReader
{
public:
	void Read()
	{
	    // Reading in binary mode
	}
};

class BinaryWriter
{
public:
	void Write()
	{
	    // Writing in binary mode
	}
};

在 Policy-Base Class Design 中,ResourceIOManager 類別所扮演的角色為 Host Class,而 BinaryReader 與 BinaryWriter 這些不同版本的 Reader 與 Writer 實作類別則稱為 Policy Class。只需要切換不同 Policy Class,程式設計者就能夠輕鬆地組合出完全合乎設計需求的類別功能:

void main()
{
    // Binary read, binary write
    ResourceIOManager< BinaryReader, BinaryWriter > kBinaryIOMgr;
    kBinaryIOMgr.Read();
    kBinaryIOMgr.Write();

    // Xml read, Xml write
    ResourceIOManager< XmlReader, XmlWriter > kXmlIOMgr;    
    kXmlIOMgr.Read();
    kXmlIOMgr.Write();

    // Xml read, Binary write
    ResourceIOManager< XmlReader, BinaryWriter > kXmlBinaryIOMgr;
    kXmlBinaryIOMgr.Read();
    kXmlBinaryIOMgr.Write();
}

如上述的使用範例所示,只需要一個 ResourceIOManager 類別,以及四個 Reader 與 Writer 類別,就能夠自由地組合出四種不同實作版本的 IO 程序處理器!未來如果需要加入處理純文字檔案用的 TextReader 與 TextWriter,只要利用相同的方法,同樣能夠輕易地達到擴充實作版本的目標。

然而,上述的實作方法,仍然不是 Policy-Based Design 的正確形式,還沒有將 Policy-Based Design 的威力發揮到極致。在前例中,建構 ResourceIOManager 類別所使用的 BinaryReader 等四個 Policy Class,都只是一般的類別。Policy-Based Design 真正厲害之處在於,這些用來建構 Template 的類別參數,本身亦可以是一個 Tempate 化的類別!

也就是說,我們可以將 BinaryReader 與 BinaryWriter 修改成為 Templated Class:

template< class T >
class BinaryReader
{
public:
	void Read()
	{
	    // Reading in binary mode
	}
};

template< class T >
class BinaryWriter
{
public:
	void Write()
	{
	    // Writing in binary mode
	}
};

接著,將 ResourceIOManager 修改為接受 Templated Classes 做為建構參數的類別:

template
<
	class Subject,
	template< class > class ReadingPolicy,
	template< class > class WritingPolicy
>
class ResourceIOManager
	:
	public ReadingPolicy< Subject >,
	public WritingPolicy< Subject >
{
public:
	void Read()
	{
		ReadingPolicy< Subject >::Read();
	}

	void Write()
	{
		WritingPolicy< Subject >::Write();
	}
};

請注意上述程式碼中 template< class > class ReadingPolicy 以及 template< class > class WritingPolicy 的用法。這樣的程式碼寫法,宣告了傳入 ResourceIOManager 類別的 ReadingPolicy 與 WritingPolicy 類別參數,本身也是一個 Template 化的類別,需要再傳入另一個類別做為兩者的建構參數。而在此,就是由其中的 Subject 類別,扮演這個 Template Template Parameter(樣版的樣版參數)的角色。

這個 Subject 類別,做為建構實體物件用的參數傳入 ResourceIOManager 類別,同時也做為 ReadingPolicy 以及 WritingPolicy 類別的建構參數。有了 Subject 類別參數之後,就能夠使 Policy Class 更具擴充性與彈性,能夠處理其他類型的實體檔案,例如 Animation、Texture 或 Script 資源等等:

void main()
{
	// 管理 Animation 檔案資源
	ResourceIOManager< AnimationResource, BinaryReader, BinaryWriter > kMgr1;
	kMgr1.Read();
	kMgr1.Write();

	// 管理 Texture 檔案資源
	ResourceIOManager< TextureResource, XMLReader, XMLWriter > kMgr2;
	kMgr2.Read();
	kMgr2.Write();

	// 管理 Script 檔案資源
	ResourceIOManager< ScriptResource, TextReader, TextWriter > kMgr3;
	kMgr3.Read();
	kMgr3.Write();
}

以上的實作方式,才是 Policy-Based Class Design 真正的設計形式以及使用方法。至此,原先存在的 BinaryReader、BinaryWriter、XMLReader 與 XMLWriter 類別,不再需要綁死於單一的 IOManager 管理者類別之中,而能夠自由組合替換,甚至對不同類型的檔案資源做出特定的處理程序,也完成了兼具修改彈性與執行效能的系統架構設計目標。

更進一步,如果需要在 ResourceIOManager 類別中再加入新的 Policy Class,例如用來處理錯誤狀態的 ErrorHandlingPolicy,同樣也非常容易:

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()
	{
		if (!ReadingPolicy< Subject >::Read())
		{
			ErrorHandlingPolicy< Subject >::HandleError();
		}
	}

	void Write()
	{
		if (!WritingPolicy< Subject >::Write())
		{
			ErrorHandlingPolicy< Subject >::HandleError();
		}
	}
};

在 Policy-Based Class Design 中,可以說整個 Host Class 幾乎就是 Policy Class 們的集合。Host Class 本身不經手功能層面的實作細節,而僅訂立出功能運作的骨架流程,真正的運作細節則交由各個不同的 Policy Class 分工進行處理。在 Wikipedia 中對於 Policy-Based Design 的介紹,有一段很貼切的敘述:

Wherever we would need to make a possible limiting design decision, we should postpone that decision and delegate it to an appropriately named policy.

在程式系統的設計過程中,有時由於時間或資源的因素,所做出的設計決定往往考慮不周全而十分受侷限。因此做為一位程式設計者,應該盡可能延後做出決定的時機,不要過早將設計決定綁死在前期的程式系統設計架構中,才能夠因應未來不斷發生的需求變動以及設計變動,也才能夠真正地達到程式系統組件的可復用性 (Reusability)。也正如《C++ 設計新思維》中所提到的:

「設計」就是一種「選擇」。大多數時候我們的困難並不在於找不到解決方案,而是有太多解決方案。

而 Policy-Based Class Design 正是為此目的而生。藉由既有技術的巧妙結合,Policy-Based Class Design 為程式設計者帶來前所未有的全新思維模式以及實作技術,使程式設計者能夠在許多不同的解決方案中,自由選擇並且輕鬆組合出最合適的實作版本。

在下篇文章裡,我將會更深入詳細地闡述 Policy-Based Class Design 背後的思維模式,以及更多在遊戲程式系統中的相關應用。

程式碼範例下載:PolicyBasedDesign_SampleCode.zip (下載次數: 1241 )

5 Replies to “物件導向設計新思維:探索Policy-Based Class Design新視界”

  1. 也許用設計模式中的狀態模式會比較輕鬆一點?好像從以前就被灌輸觀念不要用多重繼承,當然我想多重繼承還是有它的必要性,不然早就被c++淘汰掉了。

    半路:
    Policy-Based Design 看起來和 State Pattern 或 Strategy Pattern 的作用很相似,但是其實這兩種設計方法的優缺點很不相同,也有不同的應用之處。在下篇文章中會說明這個部分。

    另外,多重繼承並不是完全不能用,要避免的是誤用以及濫用的情形。只要能夠小心而且正確的使用,多重繼承在很多設計方法中,還是扮演著很重要的角色。

  2. 我有一個疑問..
    如果XML和Binary的Read()、Write()有參數..但參數不一樣的話,
    Manager該怎麼辦才能達成需求?

    半路:
    你好,

    這個問題很好,也是 Policy-Based Design 中經常會遇到的實作問題。解決方案是寫出不同參數版本的 Read() 與 Write(),然後透過 Template 的不完整具現化 (incomplement instantiation) 機制,就能夠達到兼具功能性與選擇性的 Manager 類別。在下篇文章裡,會對這個實作問題與解決方案做出詳細的解釋。

    謝謝你的回應。 ^^

  3. 每次看到這東西都覺得很神奇,但就連叫我clone一個出來我都作不到orz

    半路:
    我已經盡可能用淺顯易懂的例子解釋 Policy-Based Design 了哩。 Orz

    我想只要具備 C++ Template 的知識,在閱讀這篇文章之後,再看看完整的程式碼範例,應該不會太難理解才對。是不是還有哪些地方的概念解釋不清楚?有疑問的話也歡迎提出來討論喔~ XD

  4. 謝謝半路的分享,讓我知道了還有這樣的設計思維,
    另外突然察覺半路的coding style看了很舒服很乾淨清楚呢!
    我們的前輩也是教我們這樣的coding style的XD

  5. @哈士奇:
    這篇講的只是一些入門的概念而已,如果你對 Policy-Based Class Design 感興趣的話,推薦你一定要去找《C++ 設計新思維》這本書來讀讀。

    Coding Style 嘛,我覺得就跟整理自己的房間和桌面一樣,如果你在意它的整潔性,自然就會把它們一一整理乾淨且排列清楚啦~ XD

Leave a Reply