C++ Proxy 的一些細節

| | 0 Comments| 09:48
Categories:

前一篇以比較簡單的例子大概說明了微軟提供的 proxy 這個用來做多型的函式庫,當時是只有針對成員函式做簡單的示意,而且對於 proxy 的定義語法也沒有做詳細的說明,這篇就來更詳細看一下吧。

不過 Heresy 這邊也是邊看邊寫的,有理解錯誤也希望可以幫忙指正了。

Proxy 的外觀的定義語法

這邊先回過頭來看怎麼定義 ShapeProxy 這個用來當作外觀(facade)的型別。

以這邊的範例來說,proxy 的程式分成兩個部分。第一個部分是透過 PRO_DEF_MEM_DISPATCH 這個巨集來定義這個介面的成員函式:

PRO_DEF_MEM_DISPATCH(ProxyDraw, Draw);

其中 ProxyDraw 是透過巨集定義出來、用來記錄要執行的函式的型別(dispatch type),Draw 則是實際要去呼叫的成員函式。

這邊非得用巨集的原因,是因為現在的 C++ 語法沒辦法透過 template 或其他方法達成需求、不得已下的作法。

之後,則就是定義 ShapeProxy 這個結構來做為之後要用來共用的介面定義;他的寫法是:

struct ShapeProxy : pro::facade_builder
  ::add_convention<ProxyDraw, void()const>
  ::build {};

這個宣告 Heresy 自己一開始看到的時候,其實不太能理解他在寫什麼?(官方範例更複雜)

後來認真研究,才發現其實就是讓 ShapeProxy 去繼承 pro 這個 namespace 下一個型別:

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

實際上如同前一篇所說的,這邊可以拆成三個部分來看:

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

這邊的 facade_builder 就是最基本的型別,代表要開始建立一個外觀型別。

中間的 add_convention<> 代表要在這個型別裡面根據 ProxyDraw 加入指定函式、然後這個函式的型別簽章是 void() const

設定完成之後,最後再用一個 build 來結束,這樣就可以把前面設定的東西彙整起來、建構出一個最後的外觀型別(facade type)。

實際上,如果這個外觀需要有多個函式的話,在 facade_builderbuild 之間,可以加上多個 add_convention<>(或其他設定)一路串下去;最後只要加上 build 來結束就可以了。例如在官方範例的:

struct Drawable : pro::facade_builder
  ::add_convention<MemDraw, void(std::ostream& output)>
  ::add_convention<MemArea, double() noexcept>
  ::support_copy<pro::constraint_level::nontrivial>
  ::build {};

可以看到在 facade_builderbuild 之間總共加了三層。

為什麼這樣可以一路串下去呢?實際上這邊的 facade_builder::add_convention<> 雖然看起來 add_convention<> 好像是 facade_builder 的子型別,但是實際上它只是透過 using 來做 template 參數的處理、實際上:

facade_builder::add_convention<TA,T1>

在編譯器來看,會是

facade_builder<X,Y,C>

這樣的型別;其中,這邊的 X / Y 是根據 TA / T1 推出來的。

也因為這樣,上面包含 support_copy<> 在內看起來像是五層的巢狀結構,其實實際上最後會被解讀成:

facade_builder<XX,YY,C>::build

這邊的 XXYYC 都是根據中間給的那一串定義推導出來的。

所以它基本上可以透過這樣神奇的 template 寫法一直追加定義、一路串下去。
(補充:這邊的 build 其實也是用 using 定義出來的別名,而上面的 facade_builder 內部其實都是 basic_facade_builder

技術上,這邊的 ShapeProxy 除了用繼承的來使用外,應該也可以透過 using 來取代:

using ShapeProxy = pro::facade_builder
  ::add_convention<ProxyDraw, void()const>
  ::build;

只是這樣之後要偵錯的時候、變數的型別可能會相當難看就是了。 XD


其他的定義

除了可以定義讓這個外觀型別有特定的成員函式外,proxy 其實總共有四種函式可以設定:

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

上面這四種函式都是要透過 add_convention<> 來加入。

而除了函式之外,他也還有一些額外的東西可以設定。像是:

  • add_facade<>:透過加入已經定義好的 proxy 介面來做組合
  • support_copy<>:讓 proxy 物件可以複製
  • support_relocation<>:支援重新配置
  • support_destruction<>:支援解構
  • restrict_layout<>:針對記憶體配置做限制

不過這邊不會全部講就是了,有興趣的就請自行參考官方說明吧。

下面就針對 Heresy 覺得比較會用到的部分來整理一下吧。


使用全域函式

如果是要定義這個外觀型別可以使用特定的全域函式的話,可以直接參考官方的範例(連結)。

#include <iostream>
#include <string>
 
#include "proxy.h"
 
PRO_DEF_FREE_DISPATCH(FreeToString, std::to_string, ToString);
 
struct Stringable : pro::facade_builder
  ::add_convention<FreeToString, std::string()>
  ::build {};
 
int main() {
  pro::proxy<Stringable> p = pro::make_proxy<Stringable>(123);
  std::cout << ToString(*p) << "\n"// Prints: "123"
}

這邊首先是先透過 PRO_DEF_FREE_DISPATCH 這個巨集、定義出要使用的全域函式的資訊:

  • FreeToString:代表這個全域函式的型別
  • std::to_string:實際上要去呼叫的函式
  • ToString():產生出來、用來操作的新函式

而在定義 Stringable 這個介面的時候,則是和使用成員函式的時候一樣,透過 add_convention<> 的形式來加入。

不過在函式的型別簽章的部分就有點微妙了。在傳入參數的定義上,似乎是綁死函式的第一個參數會是這個介面本身(用 proxy 處理過的型別)轉換出來的結果、然後要省略掉?

所以像是這邊使用的 std::to_string() 本來是需要接受一個參數(他有大量的多載、可以支援多種型別),如果是 int 的話、應該是要寫成 std::string(int) 這樣的形式;但是這邊給的定義會是 std::string()、看起來像是不需要傳入參數一樣。

而函式如果需要多個參數的話,似乎只能接受把自己放在第一個?第二個開始的參數,則是直接加進去就可以了。

比如這邊就把官方的範例改成下面的樣子:

#include <iostream>
#include <string>
 
#include "proxy.h"
 
inline std::string quote(int val, std::string b, std::string e)
{
  return b + std::to_string(val) + e;
}
 
PRO_DEF_FREE_DISPATCH(FreeQuote, quote, Quote);
 
struct Stringable : pro::facade_builder
  ::add_convention<FreeQuote, std::string(std::string, std::string)>
  ::build {};
 
int main() {
  pro::proxy<Stringable> p = pro::make_proxy<Stringable>(123);
  std::cout << Quote(*p, "(", ")") << "\n"// Prints: "(123)"
}

這邊定義一個 quote() 的全域函式,可以在把 int 轉成字串的同時、在前後都加上指定的字串,這邊定義出來的函式的型別簽章是 std::string(int,std::string,std::string)

但是在透過 add_convention<> 來設定的時候,在型別簽章的部分由於第一個參數 int 會是被拿來封包成 proxy 的參數,所以會省略掉,就變成 std::string(std::string,std::string)

至於有沒有辦法指定物件自身是第幾個參數?好像沒看到對應的方法?


運算子(operator)

如果是要定義運算子作為介面的話,在使用上就不需要使用巨集來定義函式的型別出來,而是可以直接在 add_convention<> 裡面使用 pro::operator_dispatch<> 來完成。

下面是一個簡單的例子:

#include <iostream>
#include <string>
 
#include "proxy.h"
 
class MyString
{
public:
  std::string sValue;
 
  MyString(std::string_view s) : sValue(s) {}
 
  MyString operator+(const std::string& s)
  {
    return MyString(sValue + "-" + s);
  }
};
 
MyString operator+(const MyString& v, const std::string& s)
{
  return MyString(v.sValue + "-" + s);
}
 
std::ostream& operator<<(std::ostream& oss, const MyString& v)
{
  oss << '[' << v.sValue << "]\n";
  return oss;
}
 
struct IStr : pro::facade_builder
  ::add_convention<pro::operator_dispatch<"+">, MyString(const std::string&)>
  ::add_convention<pro::operator_dispatch<"<<", true>, std::ostream& (std::ostream&) const>
  ::build {};
 
int main() {
  MyString x("abc");
  std::cout << (x + "123");
 
  pro::proxy<IStr> p = std::make_unique<MyString>("123");
  std::cout << (*p + "abc");
}

這邊是定義了一個用來取代字串的類別 MyString,然後這邊也有定義和字串的相加、以及輸出用的 <<

在用 proxy 定義介面 IStr 的時候,則是透過 add_convention<> 搭配 pro::operator_dispatch<>、把這兩個運算子加進來。

operator+ 的部分,這邊是寫成:

::add_convention<pro::operator_dispatch<"+">, MyString(const std::string&)>

可以看到,前面是透過 pro::operator_dispatch<"+"> 來告訴編譯器這是要定義 operator+,意義上就等同於前面例子使用巨集定義出來的型別。

至於型別簽章的部分就和前面的寫法一樣了。

而這邊實作的 operator+ 雖然是定義成成員、但是其實寫成全域函式也可以:

MyString operator+(const MyString& v, const std::string& s)
{
  return MyString(v.sValue + "-" + s);
}

不過以這個函式

operator<< 的部分,這邊是寫成:

add_convention<
pro::operator_dispatch<"<<", true>,
std::ostream& (std::ostream&) const>

可以看到 operator_dispatch<>  有加上第二個 template 參數是 true。這是因為他本來的型別簽章是 std::ostream&(std::ostream&, const MyString&)、代表物件本身的 const MyString& 是第二個參數、而不是第一個參數。

這個時候要讓它可以被正確呼叫,就需要加第二個 template 參數是 true、代表他在 binary operator 裡面自己會是在算式的右邊,藉此和在左邊的運算子做區隔。

而上面的 operator+ 如果要強調的話,也可以加上 false、變成:

add_convention<pro::operator_dispatch<"+", false>, MyString(const std::string&)>

透過這樣的方式,就可以限制只有有定義這些指定的運算子的型別可以當成這個 IStr 介面來使用了~

不過,實際上這邊的寫法其實有點問題。那就是他回傳值的型別會是 MyString,而不會是 pro::proxy<IStr>
而也因為 std::stringoperator+ 回傳的值是 std::string,所以也不能當作 IStr 來用。


型別轉換

C++ 的類別可以自行定義型別轉換函式(參考),而如果要讓 proxy 產生的介面可以轉換成特定的型別的話,則可以在使用 add_convention<> 時搭配 conversion_dispatch<> 來做設定。

下面是一個簡單的範例:

#include <iostream>
#include <string>
 
#include "proxy.h"
 
struct MyInt
{
  int val;
 
  operator std::string() const
  {
    return "<" + std::to_string(val) + ">";
  }
};
 
struct Stringable : pro::facade_builder
  ::add_convention<pro::conversion_dispatch<std::string>, std::string() const>
  ::build {};
 
int main() {
  MyInt a{ 123 };
  std::string s = a;
  std::cout << s << "\n";
 
  pro::proxy<Stringable> p = pro::make_proxy<Stringable, MyInt>(123);
  std::cout << static_cast<std::string>(*p) << "\n";
}

這邊的 MyInt 這個型別有定義 operator std::string()、所以可以轉換成字串。

而如果要讓 proxy 定義的介面 Stringable 可以支援轉換成字串的話,這邊則是要加上

::add_convention<pro::conversion_dispatch<std::string>, std::string() const>

這邊 add_convention<> 的第一個 template 參數是 pro::conversion_dispatch<std::string>, 就是告訴系統這個是要轉換成 std::string 用的轉換函式;而第二個 template 參數,則一樣是函式的型別簽章了。

不過之後要轉換的時候,似乎是一定要強制轉換、沒有辦法自動轉換?


多載(overloading)

在使用 add_convention<> 的時候其實還有一個細節、是他其實允許針對同一個名稱的函式定義多種型別簽章(傳入不同型別或不同數量的參數)的,不同的型別簽章只要繼續放在後面就可以了。

比如說,一個類別定義了兩個成員函式 Draw() 如下:

class Box
{
public:
  void Draw() const;
  void Draw(int v) const;
};

如果希望 proxy 定義出來的介面兩個都可以操作的話,那可以寫成:

PRO_DEF_MEM_DISPATCH(ProxyDraw, Draw);
 
struct ShapeProxy : pro::facade_builder
  ::add_convention<ProxyDraw, void()const, void(int)const>
  ::build {};

這樣就可以了!之後在呼叫 proxy 物件的 Draw() 的時候,就可以選擇不傳參數、或是傳一個 int 進去了。

而對於其他幾種函式的定義方法也是可以同樣辦理,在使用上算是滿方便的。


外觀型別的組合

如果整個架構很複雜,有的功能在某些地方有用、其他地方又不想包這麼多,該怎麼辦呢?proxy 也提供了 add_facade<>、可以把多個外觀加入作組合。

比如說前面 IStr 的例子,就可以寫成:

struct IStrAppend : pro::facade_builder
    ::add_convention<pro::operator_dispatch<"+">, MyString(const std::string&)>
    ::build {}; struct IStrOutStream : pro::facade_builder
    ::add_convention<pro::operator_dispatch<"<<", true>, std::ostream& (std::ostream&) const>
    ::build {}; struct IStr : pro::facade_builder
    ::add_facade<IStrAppend>
    ::add_facade<IStrOutStream>
    ::build {};

這邊就是把兩 operator+operator<< 拆開來各自定義成一個外觀型別、可以分別使用;而如果要兩個都有的時候,再使用 IStr 這個外觀型別。


允許複製

最後,預設沒有特別設定的話,proxy 的物件會是不可複製的。如果要讓它可以被複製的話,只要在設定外觀型別的時候加上 support_copy<> 就可以了。

下面是一個例子:

struct IFacade : pro::facade_builder
  ::add_convention<ProxyGet,int&()>
  ::support_copy<pro::constraint_level::nothrow>
  ::build {};

這邊還可以透過 pro::constraint_level 指定限制的級別;總共有四種,應該是下面的意思:

  • none:禁止
  • trivial:只允許 trivial copy
  • nontrivial:允許 non-trivial copy
  • nothrow:不會丟例外就可以

這邊如果設定成 trivial 的話,那如果要使用的型別有定義自己的複製建構子就會不能用。

不過,這邊的限制似乎也會影像到其他部分?比如說,假設這邊是設定成 trivial 的話,似乎也會讓 proxy 沒辦法從智慧指標轉換過來…

auto p = pro::make_proxy<IFacade, MyInt>();         //OK
pro::proxy<IFacade> x = std::make_shared<MyInt>();  //ERROR

另外,這邊雖然說是複製,但是實際上根據 proxy 物件建立方法的不同,實際上內部的處理也會有所不同。

這邊假設有一個有 set()get() 的整數類別、然後定義了一個名為 ProxyInt 的外觀型別如下:

PRO_DEF_MEM_DISPATCH(ProxyGet, get);
PRO_DEF_MEM_DISPATCH(ProxySet, set);
 
struct ProxyInt : pro::facade_builder
  ::add_convention<ProxyGet,int() const>
  ::add_convention<ProxySet, void(int)>
  ::support_copy<pro::constraint_level::nontrivial>
  ::build {};

然後這邊再寫一個函式:

inline pro::proxy<ProxyInt> test(pro::proxy<ProxyInt> p)
{
  pro::proxy<ProxyInt> px = p;
  px->set(p->get() + 1);
  return px;
}

這邊函式接受一個 proxy 物件作為參數(這邊已經複製一份了),而裡面則會再複製一份、然後把值填入本來的值再加 1、然後回傳。

那問題來了,下面的程式碼執行後,兩個 proxy 內的數值會一樣嗎?

pro::proxy<ProxyInt> p1 = XXXX;
pro::proxy<ProxyInt> p1x = test(p1);

答案是會根據建立的方法、也就是 XXXX 的部分而有所不同…

pro::proxy<ProxyInt> p1 = pro::make_proxy<ProxyInt, MyInt>(1);
pro::proxy<ProxyInt> p1x = test(p1);
std::cout << p1x->get() << " - " << p1->get() << "\n";
// 2 -1

pro::proxy<ProxyInt> p2 = std::make_shared<MyInt>(1);
pro::proxy<ProxyInt> p2x = test(p2);
std::cout << p2x->get() << " - " << p2->get() << "\n";
// 2 -2

MyInt val(1);
pro::proxy<ProxyInt> p3 = &val;
pro::proxy<ProxyInt> p3x = test(p3);
std::cout << p3->get() << " - " << p3->get() << "\n";
// 2 -2

這邊只有透過 make_proxy<>() 建立的 proxy 運作會是真正的複製,而透過 raw pointer 或是 smart pointer 的「複製」實際上都還是會修改到原來的資料…

老實說,這個設計感覺有點微妙啊…畢竟,像是在 test() 這個函式裡面,好像還沒辦法確認傳進來的 proxy 物件是用哪種形式?

這個就不知道到底是設計還是 bug 了…


其他問題

針對 proxy 的使用,這邊其實還有一些其他的問題、或是疑惑。這些問題,應該都算是型別轉換、處理上的問題?

第一個,就是在透過 proxy 的物件操作的時候,似乎沒有辦法可以取得原始型別的資料?如果是使用傳統的繼承架構的話,基本上還有機會可以透過強制轉型的方法來做,但是在透過 proxy 的外觀型別處理的時候,似乎就完全沒有方法可以辦到了?

再來,如果是像是前面 IShape 的例子來說,我們可以很簡單地在抽象類別裡面加入判斷兩個物件是否有交錯的成員函式定義:

class IShape
{
public:
  virtual bool intersection(const IShape*) const = 0;
};

在實作的時候,可能就會透過其他共用的介面來做計算;但是也有可能根據型別的不同、針對自身的資料會有不同的處理方式。

但是在使用 proxy 的時候,感覺上似乎沒有辦法透過 PRO_DEF_MEM_DISPATCH 來定義這種需要把介面本身當作參數來傳遞的成員函式?

當然,這邊或許可以透過另外定義一個全域函式來解決,比如說:

bool intersection(pro::proxy<ShapeProxy>& p1, pro::proxy<ShapeProxy>& p2);

但是實際上還是不太一樣就是了…至少這邊感覺就沒有辦法針對不同的實作型別做區隔、做特化了。

而這類的問題,同樣也會發生在 operator 上,這邊也是不知道該怎麼解決就是了。


微軟的這個 proxy 函式庫大概就先寫到這邊了。其他的東西個人是覺得比較細、就先放著不管了。

理論上這個函式庫因為是單一 header、也不用預先編譯、要引進算是滿簡單的;但是實際上真的引進對於整個架構到底有沒有改善,可能也得在認真想想,畢竟其實繼承的架構還是有它的好處在的。

至於改用 proxy 的話、效能會不會因為沒有使用虛擬函式而能有有意義的提高,老實說沒有拿實際案例測試過之前感覺真的不好說;這部分可能就得等之後哪天有空真的拿實際案例下去測試才知道了。

Leave a Reply

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