Abstract Factory:物件家族的抽象工廠

Abstract Factory UML本篇文章將承續前篇「Factory Method:工廠化的物件生產方法」,介紹經常與 Factory Method 模式搭配使用的 Abstract Factory 設計模式,以及 Abstract Factory 模式於遊戲程式中的應用實例。

在複雜的軟體系統架構中,將原來雜亂無章的組件加以分門別類,然後從中取出性質相似與功能相近的物件,使其集中群聚,就能夠大幅地簡化程式系統組件的複雜度。而 Abstract Factory 模式,正是用來協助程式設計者將物件分門別類與集中管理的好方法。

Abstract Factory 的「工廠」,與 Factory Method 的「工廠」目的相同,同樣屬於「生成模式」的分類,都是用來產生出物件成品的製造者。對於設計模式的入門者來說,很容易將 Factory Method 模式與 Abstract Factory 模式兩者的使用目的以及使用時機搞混;雖然都是屬於工廠類型的作業模式,然而 Abstract Factory 與 Factory Method 的不同之處在於,抽象工廠模式是將一組性質相關或相依的物件,放在同一個工廠裡生產。例如對於食品工廠來說,能夠生產的包含飲料、零食與泡麵三項食品類的產品;而皮件工廠,則能夠生產出皮包、皮帶與皮鞋等皮製產品。也就是在一個工廠之內有數個不同的生產線,能夠同時進行不同成品的生產作業。在實際程式系統的應用中,可以說 Abstract Factory 經常是一堆 Factory Method 的組合

以 Abstract Factory 設計模式的 UML 結構圖來看,根據《物件導向設計模式》(Design Patterns) 書中所述,其中定義了參與抽象工廠模式的幾個角色:

  • AbstractFactory:此介面宣告出可生成各抽象成品物件的操作。
  • ConcreteFactory:具體實作出可建構具象成品物件的操作。
  • AbstractProduct:宣告某成品物件類型之介面。
  • ConcreteProduct:是 ConcreteFactory 所建構的成品物件;也是 AbstractFactory 介面的具象實作。
  • Client:只觸及 AbstractFactoryAbstractProduct 兩抽象類別所訂之介面。

與 Factory Method 的結構相似,首先由 AbstractFactory 宣告出創建物件的抽象介面,然後再由繼承而來的子類別 ConcreteFactory 具體實作出對應於各實體成品的操作細節。AbstractProduct 與 ConcreteProduct 之間的關係亦然類似;AbstractProduct 屬於抽象基底產品,而 ConcreteProduct 是衍生而來的實體產品。對於系統外部的使用者 Client 來說,無須瞭解 ConcreteFactory 與 ConcreteProduct 的實作細節,僅需接觸 AbstractFactory 與 AbstractProduct 兩者的介面,就能夠產生出相對應的實體成品。

以常見的遊戲程式情境為例,假設在我們的 MMO 遊戲世界中,存在著三種不同類型的生物體,分別是玩家角色、怪物角色,以及非玩家角色。做為一位聰明的遊戲程式設計者,很自然地能夠將這三個類別的共同概念,抽取出來成為一個基礎的 Actor 父類別。有了 Actor 類別定義一般生物體的基礎屬性與行為後,就能分別衍生出遊戲生物體的三個子類別:玩家角色的 Player 類別、怪物角色的 Monster 類別,以及非玩家角色的 NPC 類別。

在 MMO 遊戲中,Client 端程式需要接收 Server 端程式的指令,才能夠在玩家的電腦中呈現出遊戲的互動世界。所以在 Client 端接收到 Server 端傳送來的指令,命令 Client 端程式要在遊戲世界的某個位置產生某種生物體時,可以這麼寫:

// @file NetworkProtocol.cpp
#include "Player.h"
#include "Monster.h"
#include "NPC.h"

void NetworkProtocol::CreateActor(int _iType, Vector3 _tPos)
{
    Actor* pkActor = NULL;
    
    if (_iType == 1) {
        pkActor = new Player(_tPos);
    }
    else if (_iType == 2) {
        pkActor = new Monster(_tPos);
    }
    else if (_iType == 3) {
        pkActor = new NPC(_tPos);
    }
    else {
        // Unknown type, Error!
    }
    
    // Process actor object
}

NetworkProtocol 是用來接受網路命令的類別,其中的 CreateActor() 成員函式專門處理遊戲角色物件的生成程序。在此以一個整數型別數值取決要創建的生物體是玩家、怪物或非玩家角色,是最簡單而直覺的實作方法。

然而如果只是達到這樣的程度,絕對無法令求知若渴的程式設計者得到滿足感。所以為了消除 if 敘述句中使用的萬惡 Magic Number,可以進一步利用 Enumeration 的宣告與 switch 敘述句將程式結構變得更加清楚易懂:

// @file NetworkProtocol.cpp
#include "Player.h"
#include "Monster.h"
#include "NPC.h"

void NetworkProtocol::CreateActor(int _iType, Vector3 _tPos)
{
    enum ActorType
    {
        Actor_Null = 0,
        Actor_Player = 1,
        Actor_Monster = 2,
        Actor_NPC = 3,
    };

    Actor* pkActor = NULL;
    
    switch (_iType) {
        case Actor_Player: {
            pkActor = new Player(_tPos);
            break;
        }
        case Actor_Monster: {
            pkActor = new Monster(_tPos);
            break;
        }
        case Actor_NPC: {
            pkActor = new NPC(_tPos);
            break;
        }
        default: {
            // Unknown type, Error!
        }
    }
    
    // Process actor object
}

啊哈!這樣的程式結構是不是看起來清楚許多了?未來如果要加入新類型的生物體,只需要新增一個列舉值,然後在 switch 敘述句中增加對應的 case 結構即可,程式區塊的修改方式也變得更加方便而直覺許多。

然而,即使經過上述的改善程序,已經變得比較易於進行修改與新增的動作,對於這個函式中所產生的 Player、Monster 與 NPC 物件仍然不便於進行集中管理。如果在其他類別中,也需要依參數創建出不同的生物體時,是不是需要把上述的程式碼,照抄一份複製到新的類別成員函式中?萬一不幸又有第三個類別需要進行創建生物體的程序時,就再來一份?如此的情況之下,原來顯而易見的程式結構修改,已經逐漸失去控制了。

利用 Abstract Factory 模式,我們有更好的設計模式與實作方法。在此創建出一個新的 ActorFactory 類別,將產生角色物件程序的實做細節隱藏起來,用以產生各種角色物件:

// @file ActorFactory.h
class Actor;
class Player;
class Monster;
class NPC;

class ActorFactory
{
public:
    ActorFactory();
    virtual ~ActorFactory();

    virtual Player* CreatePlayer(Vector3 _tPos);
    virtual Monster* CreateMonster(Vector3 _tPos);
    virtual NPC* CreateNPC(Vector3 _tPos);
};

將這些相關性很高的類別聚集在同一個工廠中,既能夠達到集中管理的目的,也能夠使未來的系統擴充性更加具有彈性。所以在 ActorFactory 類別的定義中,就能夠對這些物件進行創建的程序:

// @file ActorFactory.cpp
#include "Player.h"
#include "Monster.h"
#include "NPC.h"
 
Actor* ActorFactory::CreatePlayer(Vector3 _tPos)
{
    return new Player(_tPos);
}

Actor* ActorFactory::CreateMonster(Vector3 _tPos)
{
    return new Monster(_tPos);
}

Actor* ActorFactory::CreateNPC(Vector3 _tPos)
{
    return new NPC(_tPos);
}

需要 Player 物件?沒問題!交給 CreatePlayer() 窗口就對了。需要 Monster 物件呢?那就交給 CreateMonster() 負責。如果是 NPC 物件呢?當然就是屬於 CreateNPC() 的管轄範圍囉。此後不論在程式系統中,有多少不同的類別需要使用創建生物體的程序,都只要向唯一一個 ActorFactory 類別提出要求就可以了!

只要有一項產品,就有相對應的一項操作方法;產品與操作,存在著一對一的對應關係。是不是覺得這個概念有些熟悉?如本文前言所述,在抽象工廠模式中,最常見的做法就是為每一項產品分別定義一個 Factory Method。這裡的程式架構就是利用了這樣的做法,使同一個工廠之中,能夠生產出性質相近但是實體不相同的產品。

更進一步,如果在遊戲系統中,人類與非人類型態的角色,存在著非常大的差異性,並不適合集中於同一個工廠進行管理,就可以將 ActorFactory 類別抽象化,形成一個基底的抽象類別,然後分別衍生出處理人類角色的 HumanActorFactory 工廠類別,以及處理非人類角色的 NonHumanActorFactory 工廠類別:

// @file HumanActorFactory.h
#include "ActorFactory.h"

class HumanActorFactory : public ActorFactory
{
public:
    HumanActorFactory();
    virtual ~HumanActorFactory();

    virtual Player* CreatePlayer(Vector3 _tPos);
    virtual NPC* CreateNPC(Vector3 _tPos);
};

// @file NonHumanActorFactory.h
#include "ActorFactory.h"

class NonHumanActorFactory : public ActorFactory
{
public:
    NonHumanActorFactory();
    virtual ~NonHumanActorFactory();

    virtual Monster* CreateMonster(Vector3 _tPos);
};

於是每次要新增一種新的生物體角色,只要在 ActorFactory 類別中新增一個 CreateXXX() 的虛擬成員函式,然後在對應的實作工廠中定義產品的實作生產細節,就能在妥善分類與集中管理的情形下,得到良好的物件生成系統架構。

為了管理這些物件,非常合適於使用改良版的 STL 容器;以 HumanActorFactory 類別為例,可以在其中新增兩個 Dictionary 用來存放與索引 Player 及 NPC 的物件指標:

// @file HumanActorFactory.h
#include "ActorFactory.h"
#include "Dictionary.h"

class HumanActorFactory : public ActorFactory
{
public:
    HumanActorFactory();
    virtual ~HumanActorFactory();

    virtual Player* CreatePlayer(Vector3 _tPos);
    virtual NPC* CreateNPC(Vector3 _tPos);

private:
    Dictionary< int, Actor*, TRUE >* m_pkPlayersPool;
    Dictionary< int, Actor*, TRUE >* m_pkNPCsPool;
};

至此,就完成了一套以抽象工廠管理物件家族的系統架構了。

而除了上述每一種物件都有一個相對應的函式 CreateXXX() 的做法,Abstract Factory 模式的另一種實作方法,是僅定義唯一的一個函式介面,然後藉由使用者傳入的整數數值或字串數值,用以決定所要創造的產品。以 HumanActorFactory 類別為例,原有的實作方式可以修改成:

// @file HumanActorFactory .h
#include "ActorFactory.h"

class HumanActorFactory : public ActorFactory
{
public:
    HumanActorFactory();
    virtual ~HumanActorFactory();

    virtual Actor* CreateActor(int _iType, Vector3 _tPos);
};

// @file HumanActorFactory.cpp
#include "HumanActorFactory.h"

Actor* HumanActorFactory::CreateActor(int _iType, Vector3 _tPos)
{
    Actor* pkActor = NULL;
    
    switch (_iType) {
        case Actor_Player: {
            pkActor = new Player(_tPos);
            break;
        }
        case Actor_NPC: {
            pkActor = new NPC(_tPos);
            break;
        }
        default: {
            // Error!
        }
    }
    
    return pkActor;
}

使用上述這個實作方法的優點在於,工廠的抽象類別與實體類別不必隨著新增的產品需求,而需要不斷地增加相對應的成員函式 CreateXXX(),僅需要唯一的一個 CreateActor() 成員函式,藉由傳入一個參數值指定出要創造的物件類型,而能夠達到更便利的工廠擴充性。

然而如果使用這個方法來實作 Abstract Factory 的話,CreateActor() 函式需要嚴格檢查傳入的參數值,確保萬一傳入不明的值也不會產生出例外的錯誤狀況。另外的問題,在於 CreateActor() 函式不論創建了什麼種類的物件,回傳值都是基底抽象類別 Actor,系統使用者需要自行將這個值進行向下轉型 (Downcast) 以進行後續的操作處理,需要注意轉型時可能發生的錯誤情形。

根據《物件導向設計模式》(Design Patterns) 書中所述,在程式架構中使用 Abstract Factory 模式的效果如下:

  • 將具象類別隔離開來。
  • 易於將整族成品物件抽換掉。
  • 增進成品物件的一致性。

回頭檢視上述的程式範例,是不是能夠瞭解使用 Abstract Factory 模式所達成的這三項效果了?

再舉一個例子,回到前篇 Factory Method 的文章中所提到的創建貼圖範例,如果使用 Abstract Factory 模式加以延伸,甚至能夠將所有相關於繪圖渲染程序的物件,例如:Texture、Light、Material、Camera、Font 等等物件,全部聚集在 GraphicsRenderer 類別中;

class GraphicsRenderer
{
public:
    GraphicsRenderer();
    virtual ~GraphicsRenderer();

    Texture* CreateTexture();
    Light* CreateLight();
    Material* CreateMaterial();
};

Abstract Factory Applied Example由上述類別架構所產生的 UML 結構如圖所示。對程式系統撰寫者來說,在這樣的系統架構之下,就能夠簡化這群高度相依物件的管理與生滅關係;而對於程式系統的使用者來說,也能夠很清楚地意識到:如果我需要創建與繪圖程序相關的物件,找 GraphicsRenderer 這位負責人就一定不會錯了!

至此,結合「Factory Method:工廠化的物件生產方法」與本篇文章中的知識,就能夠習得生成設計模式中,兩大工廠的基本概念與應用。

對於 Factory 相關的設計模式,還有許多進階的延伸設計模式與變形實作方法;例如很直覺地能夠將上述的 HumanActorFactory 與 NonHumanActorFactory 類別實作成為 Singleton 模式以利使用。另外像是 Manager Pattern 之類的設計模式,把物件家族全權交由一位專業經理人打點系統內部的管理與外部的溝通行為,也是很常見的作法。

程式碼範例下載:AbstractFactory_SampleCode.zip (下載次數: 1598 )

9 Replies to “Abstract Factory:物件家族的抽象工廠”

  1. Singleton、Manager和Factory在遊戲引擎中真的是很好用!在我們家的引擎裡實作過後再來跟您的文章互相印證,受益良多啊~

    半路:
    這幾個 Pattern 的確是越來越被廣泛使用在遊戲引擎中了。
    在不同的開發環境之下,應該也會依據不同的需求,
    而存在不同實作細節的修改版本。

    這裡只是提供了一點相關設計模式的入門概念,
    至於在真實世界的程式系統中要怎麼運用與修改,就看各人的造化了。 XD

  2. 對於 factory method,除了 if 和 switch-case 外,還可以用一個 static 的 hash map 存放 id -> factory function pointer。那就可以從外部登記類別,而不用每次增加類別而要修改 factory method 的實現。

    我已前也使用 abstract factory 來抽像化圖像底層 API,這個用法的優點是可以在執行期才選擇或改變使用那一個 API,但是相對的缺點是函式都要經由 virtual function call來使用,效率會較低。而實際應用時,很多時候在比較高層的地方也要執行一些個別 API,例如設定某一個 API 或平台獨有的 render state,那些高層的類別又可能要做 abstract factory,但這將會很困難。我現時比較傾向使用靜態的Graphics API和平台選擇,包括用 macro 或為每個API/平台編寫個別的 .cpp,而且盡量抽像化高層的API而減少抽像低層的。

    半路:
    沒錯呀~ 以 Hash Map 存放創建物件的函式指標,的確是一個好方法!
    這也是 Factory 模式一個很好的改良變形體,之後應該會再開一篇新的文章,提出我所使用過的幾種作法。

    我個人認為 Virtual Function Call 在 C++ 的物件導向設計中,幾乎是很難以免除的 performance hit。在這樣的情境下,我會傾向於在專案開發前期專注於整體系統的架構設計,然後在中後期進行效能測試的驗證程序,如果證明 Virtual Function Call 會對整體效能造成致命的影響,再進行相關程式的重構工作,以架構的彈性換取程式執行效能。

    至於某些特別的或平台獨有的 API 與物件,就不太適合使用 Factory 模式產生了。
    這樣的情況下,應該要使用其他的方法或設計模式來實作比較好。

    另外,您所提的使用靜態 API 與 macro 是指以 #define 在編譯時期切換不同的 .h 與 .cpp 實作版本嗎?我曾在 PC 與 XBox360 的程式碼切換中看過這樣的做法,不過如果只是 D3D 與 OpenGL 繪圖 API 之間的切換,似乎就比較少見這樣的實作方式了。

    看了您的網頁介紹,才知道原來您是香港地區的遊戲人!
    能有機會認識同在遊戲業界,同樣專注於遊戲開發領域的您,真是超開心的~ :D
    非常感謝您的意見與回覆,希望之後也能多多相互交流! :)

  3. 很高興能和你交流。

    我之前可能說的不夠清楚。對於Graphics API的設換,我認為要視乎是否需要在執行期切換。這個需求在個別項目可能不同。但是對於 console 來說這個需求是不需要的,所以為要配合 console 的話,我比較傾向不用 virtual function 做執行期的 Graphics API切換。一般來說,每個決定都會有好處壞處,很難有「萬靈藥」或「銀子彈」。但是能考慮多些不同的選擇和它們的優缺點,甚至能多做實驗,就是最好的。

    我現時自己私人做的東西也希望支持D3D和OGL,不過支持OGL也只是用來測試整體架構,方便之後可以做其他平台(如PSP)。

    半路:
    確實是如此,對於 Console 開發來說,
    盡可能減少不必要的開銷而能夠提升效能,還是相當首要的考量點。

    與本站「猴子靈藥」名稱不符,遊戲開發裡的確是沒有所謂的「萬靈藥」, XD
    所以能多接觸與多學習幾種不同的用法,對於設計與實作的轉換也是很有幫助的。

    感謝您的進一步解釋與回應。 ^^

  4. 您好,

    我想請問一些有關您範例code中的問題,

    我有先google尋找過,不過沒有看到想要的解答@@,希望板主可以幫我解惑~

    (1) 為什麼deconstructor都要宣告為virtual? 這樣有什麼特別的用意嗎?

    (2) enum 出 ActorType後,為什麼不直接將_iType宣告成ActorType呢?
    我在網路上看到的用法,都是先enum一個類別後,都會將之宣告使用(例如:ActorType _iType)
    但是這個範例裡,只是單純宣告而已,沒有看到任何一個ActorType的實體,
    請問這樣做是否就只是類似於基本的define,define出各種不同腳色所需的狀態?

    (3) 同(2),如果我將int _iType替換成ActorType _iType,
    這樣在實作上會有什麼不好嗎?

    還有,在Strategy & State中提到不要濫用switch判斷,

    範例中的switch在實際的專案實作中會不會容易變成switch大怪獸呢?

    還是我太在意design pattern了,其實這樣的switch就算延伸也無傷大雅@@?

    不好意思有些問題都滿基本的,

    謝謝!!!!

  5. @Bear:
    1) 這是關於 C++ 的重要特徵「虛擬」與「多型」特性,詳細原因請參考《Effective C++》書中的內容。

    2) enum 可以直接以 ActorType 宣告沒問題,這樣的用法會比較嚴謹,也比較不容易出錯。

    3) switch 敘述句的內容,經常會隨著時間成長,在不知不覺中變成一頭難以駕馭的巨獸。當 case 裡的程式碼行數超過一頁的時候,就要特別注意了。

    但也不是所有情形都適合使用 Strategy Pattern,千萬別為了擁抱 Pattern 的光環而去使用 Pattern。

  6. 这种设计很不错,有一点我不太赞同,light中的大多功能都与graphicsRenderer相关, 所以我认为还是把device 指针传入light类别要好些

  7. 這個是不是可以再利用singleton把ActorFactory 做成唯一的實體,這樣存取上更方便?

Leave a Reply