使用 Parameter Pack 取代 switch 來做型別的展開

| | 0 Comments| 11:06
Categories:

之前在寫《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
};

這樣,分別代表 charfloatdouble,而如果有需要也可以再追加其他的定義。

而為了讓使用不同型別的 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、搭配 AbsDatagetType() 來做展開、轉換了~下面這個函式,就是一個例子:

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() 這個函式就會去依序檢查、看看 pCgetType() 和哪個型別吻合,當吻合的時候,就會轉換成該型別、並執行 ExecFunc()

而實際上,由於 pCTData<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::mapstd::list 這兩種容器裡面找東西的差別了吧~

所以,這個新弄出來的寫法實不實用?可能變成是取決於這部分的展開到底是不是效能瓶頸、還有要測試的型別有多少了?而如果單就後續修改的便利性來說,這個方法應該算是相對好的了(吧?)。

Leave a Reply

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