C++ Proxy:額外定義介面的非侵入式多型架構

| | 0 Comments| 08:49
Categories:

這篇來記錄一下在《Announcing the Proxy 3 Library for Dynamic Polymorphism》這篇看到、微軟的 Proxy 這個函式庫。

它號稱是新型態的 C++ 動態多型(Dynamic Polymorphism)的架構,提供一個非侵入式、快速、易於管理的方案,可以用來取代以往以繼承為基礎的動態多型。

由於他不需要使用繼承的架構,所以技術上其實可以透過這個函式庫來將多個既有的類別封包成共同的「外觀」(facade)、讓他們可以透過統一的介面來儲存或操作;同時,他也可以和本來是用繼承來設計的架構共用,沒有必要一定要把繼承的部分抽掉。

這個專案放在 GitHub 上,網址是:https://github.com/microsoft/proxy;他是一個單一 header 檔、不用預先編譯的函式庫,使用上也只需要支援 C++20 的編譯器,算是相當便利。
微軟也有把這個函式庫提出來、希望可以成為之後 C++ 的標準。

使用繼承實作的多型

傳統 C++ 的多型基本上是靠繼承來做的。先透過定義基礎型別的介面、然後再讓其他實作繼承它,如此一來,之後就可以透過這個基礎型別的介面來儲存、傳遞、操作了。

這邊先來看一個用傳統繼承來時做的多型範例(GitHub);這個範例是假設有幾個不同的圖形要拿來繪製,所以這邊就先定義了:

// define interface
class IShape
{
public:
  virtual ~IShape() = default;
  virtual void Draw() const = 0;
};
 
// implement a box
class Box : public IShape
{
public:
  virtual ~Box() = default;
 
  virtual void Draw() const override
  {
    std::cout << "Draw Box\n";
  }
};
 
// implement a triangle
class Triangle : public IShape
{
public:
  virtual ~Triangle() = default;
 
  virtual void Draw() const override
  {
    std::cout << "Draw Triangle\n";
  }
};

其中,IShape 是用來定義介面的抽象類別、他要求由他衍生出來的型別都要有 Draw() 這個函式;而在這邊則是根據它實作了 BoxTriangle 兩種圖形。

如此一來,之後就可以用 IShape 來統一儲存、操作這個類型的物件了;下面的例子,就是個建立一個 BoxTriangle 的物件、然後把它們以 IShape 的形式放到 vObjects 這個 vector 裡面。

// build list
std::vector<IShape*> vObjects;
vObjects.push_back(new Box());
vObjects.push_back(new Triangle());

// draw all obects
for (const auto pObj : vObjects)
  pObj->Draw();

如此一來,之後要操作就相當簡單了~

不過由於這邊沒有使用智慧指標,所以就得自己釋放記憶體了~而且解構子也需要定義出 virtual 的版本、才能確保正確解構。


使用 Proxy 函式庫

而如果這邊不想使用繼承的架構、想整個改用這個 proxy 函式庫,該怎麼寫呢?修改完的範例可以參考 GitHub 上的檔案

首先,Proxy 這個函式庫的 header 檔案只有一個、就是 proxy.h;而裡面的東西基本上都放在 pro 這個 namespace 下(總覺得直接用 proxy 不就好?)。

然後,這邊首先是要把本來作為介面的抽象類別 IShape 拿掉,然後也修改 BoxTriangle 的定義。

之後就是要透過 proxy 來定義一個取代 IShape 的類別:

// define interface with proxy
PRO_DEF_MEM_DISPATCH(ProxyDraw, Draw);
 
struct ShapeProxy : pro::facade_builder
  ::add_convention<ProxyDraw, void()const>
  ::build {};

這邊是先透過 PRO_DEF_MEM_DISPATCH 這個巨集、來定義出一個型別 ProxyDraw,它是用來讓 proxy 之後可以知道要呼叫的成員函式的名稱用的,這邊就是 Draw
在這裡是受限於現在的 C++ 語言的標準,所以一定要用巨集的形式。

之後,則是定義一個結構 ShapeProxy,讓他繼承透過 proxy 的語法定義出來的型別:

pro::facade_builder::add_convention<ProxyDraw, void()const>::build

這邊的語法比較複雜一點,不過基本上就是:

  • 先以 pro::facade_builder 開始
  • 加上各種設定(這邊是 add_convention<>)、數量可以很多
  • 最後加上 build 結束

其中,這邊的 add_convention<ProxyDraw, void()const> 是告訴 proxy 要加入 ProxyDraw 這個函式,然後它的型別簽章(type signature)是 void() const,的寫法和 std::function<> 一樣的。

這邊定義 proxy 型別的寫法其實要深究的話還滿複雜、彈性也滿多的,詳細的內容就之後再介紹了。

而在這邊把介面定義好了之後,就可以拿來使用了!

// build list
std::vector<pro::proxy<ShapeProxy>> vObjects;
vObjects.push_back(pro::make_proxy<ShapeProxy,Box>());
vObjects.push_back(pro::make_proxy<ShapeProxy,Triangle>());

// draw all objects
for (const auto& pObj : vObjects)
  pObj->Draw();

要存取這邊定義出來的 ShapeProxy 這個外觀型別,必須要透過 pro::proxy<ShapeProxy> 的形式才行,所以這邊的 vObjects 就變成 std::vector<pro::proxy<ShapeProxy>> 了。

而要建立 pro::proxy<> 的物件有幾種方法:

第一個方法,就是上面的 pro::make_proxy<ShapeProxy,Box>()
這邊需要兩個 template 參數,第一個參數則是要把它當成哪個介面(ShapeProxy)來操作;第二個參數則是他實際上的物件型別(Box),在有的狀況下可以自動推倒、不需要指定。

而函式的參數則是用來建構的參數,基本上就看建構子支援什麼就填什麼;像這邊因為只有預設的建構子,所以就不用填參數了。

另外兩個建立的方法則如下:

// smart pointer
pro::proxy<ShapeProxy> pShape2 = std::make_shared<Box>();

// raw pointer
Box box1;
pro::proxy<ShapeProxy> pShape1 = &box1;

不過要注意的是,如果是使用 raw pointer 的話,proxy 並不會取得資料的所有權、所以物件的生命週期需要自己管理。

都完成後,之後就是像操作指標一樣來操作 pro::proxy<ShapeProxy> 的物件了!不過,能使用的介面基本上就只有在定義 ShapeProxy 這個外觀時加入的東西了。

而如果使用的類別沒有提供外觀需要的成員函式的話,在編譯的時候就會出現錯誤,所以在使用上有一定的安全性。

如果想要寫一個函式可以處理這個介面的物件的話,也一樣可以定義成下面這樣的形式:

void func(pro::proxy<ShapeProxy>& obj)
{
  obj->Draw();
}

這邊的缺點大概就是這個函式不能直接用在原來的型別上了。(要手動轉型成 proxy)


其他函式

這邊的例子是只有用一個成員函式來舉例,不過實際上 proxy 可以加入的函式,總共有四種類型:

  • 成員函式:使用巨集 PRO_DEF_MEM_DISPATCH 定義
  • 全域函式(free function):使用巨集 PRO_DEF_FREE_DISPATCH 定義
  • 運算子:使用類別 operator_dispatch<>
  • 型別轉換:使用類別 conversion_dispatch<>

而且在使用的時候,add_convention<> 也可以定義不同參數的多載,其實在使用上是有相當的彈性的。

除了 add_convention<> 外,proxy 還可以透過 add_facade<> 來把已經定義好的介面組合在一起;所以如果是要定義出許多不同的簡單介面、然後根據需求來組合出複雜的介面的話,也是可以很簡單地就做得到的!

此外,他甚至還有 PRO_DEF_WEAK_DISPATCH、 可以定義當特定的函式不存在的時候不要直接在編譯階段就回報錯誤、而是去定義在執行的時候要怎麼做例外處理,這點還算滿有趣的。

不過這部分要寫的話會太多,就等之後下一篇再說吧。


使用 Proxy 的優缺點

這篇比較基本的使用大概先這樣了?之後預計再用一篇來說明一下他在定義的時候一些其他的功能。

那,使用 proxy 這個架構來取代傳統用計程的多型有什麼好處呢?

官方的說法是:

  • 非侵入式:不需要繼承、也不需要抽象型別
  • 易於管理:提供類似 garbage collection 的功能來管理生命週期
  • 快速:官方說法是會比較快、尤其在資源管理上
  • 彈性:除了可以支援成員函式、運算子、全域函式外,還可以組合不同的抽象類別

這邊由於 proxy 少了繼承時的 virtual table、理論上在呼叫的時候應該會比較快。

而 Heresy 這邊也有試著寫了一個簡單的測試,來比較用繼承和用 proxy 的差別(GitHub);測試的結果:

Visual Studio 17.11.2
g++ 11.4.0
繼承
1140 ms
1180 ms
Proxy
1019 ms
983 ms
直接使用
185 ms
181 ms

可以看到,在 MSVC 上和 g++ 上,雖然 proxy 確實都比使用繼承來的快,但是其實差距並沒有想像得大。(另外一提,proxy 在 debug 模式會超慢…)

而和直接使用原始型別相比,其實都還是有相當的額外負擔的;不過實際上這邊直接使用的例子也算是硬上、在實際上這樣用的彈性會很低就是了(這也是為什麼要用動態多型)。

不過老實說,這部份編譯器到底怎麼去最佳化程式的感覺影響會更大?實際上這邊也有試著做一些不影響架構的調整,但是有可能會讓整個結果翻盤、變成使用 proxy 更慢的狀況…所以最後可能還是得根據自己的情境來做測試了。


另外,以 Heresy 的觀點來看,雖然不使用繼承可以少掉 virtual table 的負擔,但是其實也比較容易會讓程式的架構變得比較分散、而且比較難共用程式碼,所以在個人來看也不盡然都是好事。

不過,proxy 的另一個好處就是在架構上是非侵入式,所以其實要用的話,也不一定要把整個繼承的架構完全拿掉、而是可以直接去使用最後的型別來避免虛擬函式造成的負擔;這樣基本上也可以在保有繼承架構的狀況下,來用 proxy 的外觀介面做操作、並獲得 proxy 架構提供的好處的。

而如果透過全域函式來作重新封包的話,也可以在不需要修改既有的程式碼的情況下建立出共用介面,某種意義上可以在使用上有更大的彈性。

目前個人的一個想法是:或許可以透過 proxy 的架構把同類型的兩個不同函式庫封包成同樣的介面、來統一管理、使用?不過實際上真的要這樣做,應該還是有很多地方要注意、不見得比較好用就是了。


老實說,玩了一陣子,個人會覺得他有點像是 C++20 Concept 的動態版本?
因為實際上,他們主要的目的都是用來限制要使用的型別需要符合哪些條件,不過 proxy 只能限制要有那些函式而已;相對地,concept 雖然能限制的更多、但是它並不是真正的型別、只能用在 template 上、不能拿來作為儲存物件的型別,同時一切也都要在編譯階段就確定才行。
所以實際上差距還是相當大的。

在看到的當下,Heresy 其實是覺得這東西應該會有用。但是實際上真的開始思考後,倒也不知道到底該用在哪裡…畢竟其實本來使用繼承的結構也都寫好了,然後感覺上他又沒有真的快多少…

或許以後等哪天真的有機會用上,再來分享使用的經驗吧。

Leave a Reply

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *