2013年4月8日 星期一

Data Hiding 的兩種作法: Pimpl 與 Pure Virtual Interface

大型的軟體專案在開發前期通常會先將功能較獨立、且共用性高的部分規劃為函式庫。在設計 C/C++ 的函式庫接口時,特別要留意那些要公開給外部使用的 header 檔案。裡面盡可能只包含「要公開的 API 定義」,而將「只有內部才需要」的各種定義或 header 藏起來,以免相依性向外轉嫁到函式庫的使用者。這個動作稱為「Data Hiding」或「Information Hiding」。

舉一個設計不良的例子,假設你要對外公開這個 MyTool 類別,而它的 header 長這樣:



#include <thirdparty/xyz.h>

class MyTool
{
public:
    void func1();
    void func2(const char *str);

private:
    void priv_func1();

    thirdparty::xyz mXyz;
};

這樣的 header 有兩個缺點,首先是 thirdparty/xyz.h 這個 header 是只有函式庫內部才需要的東西,因為外界能使用的也只有 func1 跟 func2 這兩個成員函數,而它們都不需要 thirdparty::xyz 的詳細定義。如果就這樣讓使用者引用這個 header 的話,他編譯時也需要提供 thirdparty 的 header 路徑,因此相依性被轉嫁了。其次,如果後續 private 的區域有異動,例如說增減了成員函數或資料,會導致這個 header 檔案發生內容變化,致使所有引用這個 header 的程式碼都必需重新編譯。

為了解決此問題,目前主流的對策可分為兩種: Pimpl 與 Pure Virtual Interface.
關於 Pimpl 和 pure virtual interface 的比較可以參考 Niklas Frykholm 這位老兄的一篇精闢介紹. 以下擇其精要:

Pimpl


Pimpl (Private Implementation) 可以套用在任何既有的 class header 上而不需大幅修改. 以上面 MyTool 類別為例,套用 Pimpl 的方式大致是:
  1. 將既有的 MyTool 類別改名為 MyToolImpl.
  2. 重新建立一個 MyTool.h 與 MyTool.cpp,其內容如下:

class MyToolImpl;

class MyTool
{
public:
    MyTool();
    virtual ~MyTool();
    void func1();
    void func2(const char *str);

private:
    MyToolImpl* pImpl;
};

// MyTool.cpp
#include "MyToolImpl.h"

void MyTool::MyTool()
{
    pImpl = new MyToolImpl;
}

void MyTool::~MyTool()
{
    delete pImpl;
}

void MyTool::func1()
{
    pImpl->func1();
}

void MyTool::func2(const char *str)
{
    pImpl->func2(str);
}

MyTool.h 裡面只列出 MyToolImpl 中所有的公開成員函數, 除此之外它只有一個私有資料成員 名為 pImpl, 類型為 MyToolImpl 的指標. 因為只用到指標型態, 所以直接在 class MyTool 的定義前宣告一個 class MyToolImpl; 就好了. 外界不會、也不能引用 MyToolImpl.h, 只能引用 MyTool.h. 如此一來就能將所有內部才需要使用的定義都藏起來. pImpl 會在類別建構時指向一個 new 出來的 MyToolImpl. 而 MyTool 裡面成員函數的實作都只要轉呼叫 pImpl 指向的 MyToolImpl 實例中裡的同名函數就好, 這個轉向的動作又稱為「Forwarding」. Gamedev 上有一個簡而易懂的 Pimpl 範例. 如果是 Windows 上的動態函式庫,透過 __declspec(dllexport) 宣告函式庫界面時, 也只要加在 class MyTool 上即可.

Pure Virtual Interface


Pure Virtual Interface 的運作方式就需要較大規模地修改既有程式碼. 還以 MyTool 當例子, 首先要先定出一個叫 IMyTool 的 class, 裡面一樣是列出所有 MyTool 中所有的公開成員函數, 但不需要私有資料成員. 強制規範所有 IMyTool 中的成員函數都必需為 Pure Virtual Function (前有 virtual 關鍵字, 後接 =0;). 修改原有 class MyTool 的 header, 使其繼承 IMyTool, 如此它自然提供了所有 Pure Virtual Functions 的實作. 另外需要定義一個 factory 函數去產出這個具 IMyTool interface 的物件, 因此要另外寫一個 createMyTool(), 而它裡面就只是 new 出 MyTool 實例然後返回它的 IMyTool 指標. 外界重頭到尾也只知道 IMyTool 而碰觸不到 MyTool 的實作細節. 如果是 Windows 上的動態函式庫,__declspec(dllexport) 這個關鍵字就只需要下在 factory 函數上面, IMyTool 和 MyTool 的類別定義都不需要再加.

// IMyTool.h

class IMyTool
{
public:
    virtual void func1() = 0;
    virtual void func2(const char *str) = 0;
};

extern IMyTool* createMyTool();

// MyTool.h

#include "IMyTool.h"
#include <thirdparty/xyz.h>

class MyTool : public IMyTool
{
public:
    void func1();
    void func2(const char *str);

private:
    void priv_func1();

    thirdparty::xyz mXyz;
};

// MyTool.cpp
#include "MyTool.h"

//
// Implementation of MyTool ....
//

IMyTool* createMyTool()
{
    return new MyTool();
}

總結 Frykholm 比較兩者後所列的優缺點:

Pimpl: (較多人建議使用)
  • 物件產生跟原本一樣可使用 new / delete
  • 套上了 Pimpl 之後原 class 的行為還是能被繼承
  • 雖然所有 function call 都會被轉向, 但可透過 inline 加速
  • 套用在現用的 class 上所花費的代價比 pure virtual interface 低.

Pure Virtual Interface:
  • 物件必需透過 factory 函數產生
  • 原 class 的行為不能被繼承, 外界只看得到 interface, 成員又都是 pure virtual, 無從繼承. 設計上會受限制.
  • 透過 vtable 轉向 function call, 效能不如有做 inline 的 pimpl 模式.
  • 套用在現用的 class 上所花費的代價比 pimpl 高.

沒有留言:

張貼留言