之前在寫《C++11 的「…」:Parameter Pack》的時候,有提過 Heresy 其實不太知道這東西該怎麼用?不過後來過了一小段時間,Heresy 倒是找到了一個可能的應用了!那就是用來
取代 switch case、拿來做型別的判斷、展開!
有的時候,在資料結構上可能會因為資料有不同的型別、而使用 template 來實作,然後再讓他去繼承一個抽象類別,來方便操作,例如下面就是一個例子:
class AbsData { public: virtual EType getType() const = 0; }; template <typename TYPE> class TData : public AbsData { public: TYPE mData; EType getType() const override; };
在上面的程式碼中,AbsData 就是定義介面用的抽象類別,而 TData<> 則是可以對應各種型別的實作。
而其中的 EType,則是自己定義的列舉型別,讓外部可以透過 getType() 這個函式,來得知 AbsData 內部實際上是什麼型別的資料。
在這邊是簡單地定義成:
enum EType { E_C, E_F, E_D };
這樣,分別代表 char、float 和 double,而如果有需要也可以再追加其他的定義。
而為了讓使用不同型別的 TData<> 可以回報正確的型別資訊,所以這邊需要針對這些型別,明確地去定義 getType() 的行為。
template<> EType TData<char>::getType() const { return E_C; } template<> EType TData<float>::getType() const { return E_F; } template<> EType TData<double>::getType() const { return E_D; }
如此一來,在使用 TData<> 的時候,其實都可以把他們當作 AbsData 來做儲存、操作,例如下面的 aDataArray 就是一個大小是 3 的 AbsData* 陣列,裡面放的實際上是 TData<char>、TData<float>、TData<double> 的指標。
std::array<AbsData*, 3> aDataArray = { new TData<char>(), new TData<float>(), new TData<double>() };
這樣一來,就可以靠 C++ 的繼承、抽象函式的概念,在不考慮實際型別的狀況下,完成大部分的操作了。
不過,在某些情況下,我們還是會需要把 AbsData 轉換回 TData<>、使用它真正的型別來做操作。
這時候,典型的作法應該就是使用 switch case、搭配 AbsData 的 getType() 來做展開、轉換了~下面這個函式,就是一個例子:
inline void RunFuncOld(AbsData* pData) { switch (pData->getType()) { case E_C: ExecFunc((TData<char>*)(pData)); break; case E_F: ExecFunc((TData<float>*)(pData)); break; case E_D: ExecFunc((TData<double>*)(pData)); break; } }
在這邊,RunFuncOld() 這個函式會把傳入 pData、根據 getType() 回報的結果,強制轉型成對應的 TData<> 型別, 然後傳給 ExecFunc() 這個函式來做參數;而 ExecFunc() 則會根據不同的型別,做不同的處理。
這樣的寫法很直覺、也很簡單,大部分的人都應該可以看得懂、並做維護。但是實際上,如果 TData<> 支援的型別更多的話,或是同樣的展開要用在很多地方的話,這段程式碼的維護就會變得頗有難度…
至少在 Heresy 這邊,就常常會出現複製貼上後、各型別修改上的錯誤;而如果 ExecFunc() 的介面有修改的話,要更動的地方也會相當地多、工作會變得相當地繁瑣…
而雖然某種程度上,可以把 RunFuncOld() 這個函式改成 template 函式、把一個 template class 傳進來取代 ExecFunc() 當作函式用、增加通用性(參考《C++ Template of template》);但是,如果又要對應不同的組合(例如有的函式不支援 TData<doubel>),那還是相當當地麻煩。
而由於前一陣子在研究 Parameter Pack,所以後來就開始嘗試、是不是可以用這個技術來處理這個問題?
後來,寫出來的結果就是:
inline void RunFunc(AbsData* pData){} template<typename TP1, class ... Types> inline void RunFunc(AbsData* pData, TP1 p1, Types ... args) { if (pData->getType() == p1.getType()) ExecFunc((TP1*)(pData)); else RunFunc(pData, args...); }
這個寫法的意義,基本上就是讓 pData 依序去和後面傳進來的物件,做型別的比對,如果型別符合的話,就把 pData 轉換成該型別、然後傳給 ExecFunc() 來執行;如果當下的物件(p1)不符合的話,則會遞迴式地找下去。
如果寫成這樣的話,則可以像下面這樣使用:
AbsData* pC = new TData<double>(); RunFunc(pC, TData<char>(), TData<float>(), TData<double>());
首先,這邊第一個參數 pC 就是實際要去執行 ExecFunc() 時使用的參數;而之後後面建立的三個 TData<> 物件,則是要去比對的型別。
如此一來,RunFunc() 這個函式就會去依序檢查、看看 pC 的 getType() 和哪個型別吻合,當吻合的時候,就會轉換成該型別、並執行 ExecFunc()。
而實際上,由於 pC 是 TData<double>,所以他會找到最後;整個函式呼叫的紀錄,會相當於是:
RunFunc(pC, TData<char>(), TData<float>(), TData<double>()); RunFunc(pC, TData<float>(), TData<double>()); RunFunc(pC, TData<double>()); ExecFunc((TData<double>*)pC);
而如果是要測試的型別數量有變動的話,也可以簡單地在呼叫時修改後面傳遞的參數數量;例如,如果寫成:
RunFunc(pC, TData<char>(), TData<float>());
的話,他就只會去測試 TData<char> 和 TData<float> 這兩種型別,所以最後會執行到終端函式、而不會呼叫到 ExecFunc()。
他的呼叫歷史會是:
RunFunc(pC, TData<char>(), TData<float>()); RunFunc(pC, TData<float>()); RunFunc(pC);
而如果再搭配 Template of template 的寫法、把要呼叫的函式傳遞進來的話,那這個型別展開的函式就會有相當好的通用性了!
這個方法感覺有相當好的通用性,不過很遺憾的是,他還是有很大的缺點的,那就是他的效能不算好。
本來的 switch case 的機制,在編譯階段應該會建立出一張表、使用查表法來快速地進行切換。
所以,當要查的型別變多的時候,理論上 switch case 在效能上不會有太大的變化,而且和要找的項目的位置也沒有關係。
但是這個使用 Parameter Pack 的方法,基本上會等同於採用線性搜尋的方法、一個一個依序找下去。
而其結果,就是他的效能會取決於要找的型別到底是在哪個位置了…當要測試的型別數量很多、而要找的東西又在很後面的話,那所需時間就會成線性成長,有可能會變得很可怕了。
某方面來說,這邊的效能差異,大概就是在 std::map 和 std::list 這兩種容器裡面找東西的差別了吧~
所以,這個新弄出來的寫法實不實用?可能變成是取決於這部分的展開到底是不是效能瓶頸、還有要測試的型別有多少了?而如果單就後續修改的便利性來說,這個方法應該算是相對好的了(吧?)。