現代c++與模板元編程


最近在重溫《c++程序設計新思維》這本經典著作,感慨頗多。由於成書較早,書中很多元編程的例子使用c++98實現的。而如今c++20即將帶着concept,Ranges等新特性一同到來,不得不說光陰荏苒。在c++11之后,得益於新標准很多元編程的復雜技巧能被簡化了,STL也提供了諸如<type_traits>這樣的基礎設施,c++14更是大幅度擴展了編譯期計算的適用面,這些都對元編程產生了不小的影響。今天我將使用書中最簡單也就是最基礎的元容器TypeList來初步介紹現代c++在元編程領域的魅力。

本文索引

什么是TypeList

TypeList顧名思義,是一個存儲和操作type的list,你沒有看錯,存儲的是type(類型信息)而不是data。

這些被存儲的type也被稱為元數據,存儲的它們的TypeList也被稱為元容器。

那么,我們存儲了這些元數據有什么用呢?答案是用處很多,比如tuple,工廠模式,這兩個后面會舉例;還可用來實現CRTP技巧(一種元編程技巧),線性化繼承結構等,這些也在原書中有詳細的演示。

不過光看我在上面的解釋多半是理解不了什么是TypeList以及它有什么用的,不過沒關系,元編程本身就是高度抽象的腦力活動,只有多讀代碼勤思考才能有所收獲。下面我就展示如何使用現代c++實現一個TypeList,以及對c++11以前的古典版本做些簡單的對比。

TypeList的定義

最初的問題是我們要如何存儲類型呢?數據可以存變量,單是type和data的不同的東西,怎么辦?

聰明的你可能以及想到了,我們可以讓模板參數成為type信息的容器。

但是緊接着第二個問題來了,所謂list它的元素數量是固定的,但是直到c++11以前,模板參數的數量都是固定的,那么怎么辦?

其實也很簡單,參考普通list的鏈表實現法,我們也可以用相同的思想去構造一個“異質鏈表”:

template <typename T, typename U>
struct TypeList {
    typedef T Head;
    typedef U Tail;
};

這就是最簡單的定義,其中,T是一個普通的類型,而U則是一個普通類型或TypeList。創建TypeList是這樣的:

// 創建unsigned char和signed char的list
typedef TypeList<unsigned char, signed char> TypedChars;
// 現在我們把char也添加進去
typedef TypeList<char, TypeList<unsigned char, signed char> > Chars;
// 創建int,short,long,long long的list
typedef TypeList<int, TypeList<short, TypeList<long, long long> > > Ints;

可以看到,通過TypeList環環相扣,我們就能把所有的類型都存儲在一個模板類組成的鏈表里了。但是這種實現的弊端有很多:

  1. 首先是定義類型不方便,上面的第三個例子中,僅僅為了4個元素的list我們就要寫出大量的嵌套代碼,可讀性大打折扣;
  2. 原書中提到,為了簡化定義,Loki庫提供了TYPELIST_N這個宏,但是它是硬編碼的,而且最大只支持50個元素,硬編碼在程序員的世界里始終是丑陋的,更不用說還存在硬編碼的數量上限,而且這么做也違反了“永遠不要復讀你自己”的原則,不過對於c++98來說只能如此;
  3. 我們沒辦法清晰得表示只有一個元素或是沒有元素的list,所以我們只能引入一個空類NullType來表示list的某一位上沒有數據存在,比如:TypeList<char, NullType>TypeList<NullType, NullType>,當然,你特化出單參數的TypeList也只是換湯不換葯。
  4. 無法有效得表示list的結尾,除非像上面一樣使用NullType最為終結標志。

好在現代c++有變長模板,上述限制大多都不存在了:

template <typename...> struct TypeList;

template <typename Head, typename... Tails>
struct TypeList<Head, Tails...> {
    using head = Head;
    using tails = TypeList<Tails...>;
};

// 針對空list的特化
template <>
struct TypeList<> {};

通過變長模板,我們可以輕松定義任意長度的list:

using NumericList = TypeList<short, unsigned short, int, unsigned int, long, unsigned long>;

同時,我們特化出了空的TypeList,現在我們可以用它作為終止標記,而不用引入新的類型。如果你對變長模板不熟悉,可以搜索相關的資料,cnblogs上就有很多優質教程,介紹這個語法特性已經超過了本文的討論范疇。

當然,變長模板也不是百利而無一害的,首先變長模板的參數包始終可以解包出空包,這會導致模板的偏特化和主模板發生歧義,因此在處理一些元函數(編譯期計算出某些元數據的模板類就叫做元函數,概念來自於boost.mpl)的時候就要格外小心;其次,雖然我們方便了類型定義和部分的處理,但是向list頭部添加數據就很困難了,參考下面的例子:

// TL1是一個包含int和long的list,現在我們在頭部添加一個short
// 古典實現很簡單
using New = TypeList<short, TL1>;

// 而現代的實現就沒那么輕松了
// using New = TypeList<short, TL1>; 這么做是錯的

問題出在哪?...運算符只能對參數包進行解包擴展,而TL1是一個類型,不是參數包,但是我們有需要把TL1包含的參數拿出來,於是問題就出現了。

對於這種需求我們只能使用一個元函數來解決,這是現代化方法為數不多的缺憾之一。

元函數的實現

定義了TypeList,接下來是定義各種元函數了。

也許你會疑惑為什么不把元函數定義為模板類的內部靜態constexpr函數呢?現代c++不是已經具備強大的編譯期計算能力了嗎?

答案是否定的,編譯期函數只能計算數值常量,而我們的元數據還包括了type,這時函數處理不了的。

不過話也不能說死,因為在處理數值常量的地方constexpr的作用還是很大的,后面我也會用constexpr函數輔助元函數。

Length元函數求list長度

最常見的需求就是求出TypeList中存放了多少個元素,當然這也是實現起來最簡單的需求。

先來看看古典技法,所謂古典技法就是讓模板遞歸特化,依靠偏特化和特化來確定退出條件達到求值的目的。

因為編譯期很難存儲下迭代需要的中間狀態,因此我們不得不依賴這種像遞歸函數般的處理技巧:

template <typename TList> struct Length; // 主模板,為下面的偏特化服務

template <>
struct Length<TypeList<>> {
    static constexpr int value = 0;
}

template <typename Head, typename... Types>
struct Length<TypeList<Head, Types...>> {
    static constexpr int value = Length<Types...>::value + 1;
};

解釋一下,static constexpr int value是c++17的新特性,這種變量將會被視為類內的靜態inline變量,可以就地初始化(c++11)。否則你可能需要將值定義為匿名的enum,這也是常見的元編程技巧之一。

我們從參數包的第一個參數開始逐個處理,遇到空包就返回0結束遞歸,然后從底層逐步返回,每一層都讓結果+1,因為每一層代表了有一個type。

其實我們可以用c++11的新特性——sizeof...操作符,它可以直接返回參數包中參數的個數:

template <typename... Types>
struct Length<TypeList<Types...>> {
    static constexpr int value = sizeof...(Types);
};

使用現代c++的代碼簡單明了,因為參數包總是可以展開為空包,這時候value為0,還可以少寫一個特化。

TypeAt獲取索引位置上的類型

list上第二個常見的操作就是通過index獲取對應位置的數據。為了和c++的使用習慣相同,我們規定TypeList的索引也是從0開始。

在Python中你可以這樣引用list的數據list_1[3],但是我們並不會給元容器創建實體,元容器和元函數都是配合typedef或其他編譯期手段實現編譯期計算的,只需要用到它的類型本身和類型別名。因此我們只能這樣操作元容器:using res = typename TypeAt<TList, 3>::type

有了元函數的調用形式,我們可以開始着手實現了:

template <typename TList, unsigned int index> struct TypeAt;
template <typename Head, typename... Args>
struct TypeAt<TypeList<Head, Args...>, 0> {
    using type = Head;
};

template <typename Head, typename... Args, unsigned int i>
struct TypeAt<TypeList<Head, Args...>, i> {
    static_assert(i < sizeof...(Args) + 1, "i out of range");
    using type = typename TypeAt<TypeList<Args...>, i - 1>::type;
};

首先還是聲明主模板,具體的實現交給偏特化。

雖然c++已經支持編譯期在constexpr函數中進行迭代操作了,但是對於模板參數包我們至今不能實現直接的迭代,即使是c++17提供的折疊表達式也只是實現了參數包在表達式中的就地展開,遠遠達不到迭代的需要。因此我們不得不用老辦法,從第一個參數開始,逐漸減少參數包中參數的數量,在減少了index個后這次偏特化的模板中,index一定是0, 而Head就一定是我們需要的類型,將它設置為type即可,而上層的元函數只需要不斷減少index的值,並把Head從參數包中去除,將剩下的參數和index傳遞給下一層的元函數TypeAt即可。

順帶一提,static_assert不是必須的,因為你傳遞了不合法的索引,編譯器會直接檢測出來,但是在我這(g++ 8.3, clang++ 8.0.1, vs2017)編譯器對此類問題發出的抱怨實在是難以讓人類去閱讀,所以我們使用static_assert來明確報錯信息,而其余的信息比如不合法的index是多少,編譯器會給你提示。

如果你不想越界報錯而是返回NullType,那么可以這樣寫:

template <typename Head, typename... Args>
struct TypeAt<TypeList<Head, Args...>, 0> {
    using type = Head;
};

template <typename Head, typename... Args, unsigned int i>
struct TypeAt<TypeList<Head, Args...>, i> {
    // 如果i越界就返回NullType
    using type = typename TypeAt<TypeList<Args...>, i - 1>::type;
};

// 越界后的退出條件
template <unsigned int i>
struct TypeAt<TypeList<>, i> {
    using type = NullType;
};

因為不想越界后報錯,所以我們要提供越界之后參數包為空的退出條件,在參數包處理完后就會立即使用這個新的特化,返回NullType。

聰明的讀者也許會問為什么不用SFINAE,沒錯,在類模板和它的偏特化中我們也可以在模板參數列表或是類名后的參數列表中使用enable_if實現SFINAE,但是這里存在兩個問題,一是類名后的參數列表必須要能推演出模板參數列表里的所有項,二是類名后的參數列不能和其他偏特化相同,同時也要符合主模板的調用方式。有了如上限制,利用SFINAE就變得無比困難了。(當然如果你能找到利用SFINAE的實現,也可以通過回復告訴我,大家可以相互學習;不清楚SFINAE是什么的讀者,可以參閱cppreference上的簡介,非常的通俗易懂)

當然這么做的話靜態斷言就要被忍痛割愛了,為了接口表現的豐富性,Loki的作者將不報錯的TypeAt單獨實現為了不同的元函數:

template <typename TList, unsigned int Index> struct TypeAtNonStrict;
template <typename Head, typename... Args>
struct TypeAtNonStrict<TypeList<Head, Args...>, 0> {
    using type = Head;
};

template <typename Head, typename... Args, unsigned int i>
struct TypeAtNonStrict<TypeList<Head, Args...>, i> {
    using type = typename TypeAtNonStrict<TypeList<Args...>, i - 1>::type;
};

template <unsigned int i>
struct TypeAtNonStrict<TypeList<>, i> {
    using type = Null;
};

IndexOf獲得指定類型在list中的索引

IndexOf的套路和TypeAt差不多,只不過這里的遞歸不用掃描整個參數包(逐個按順序處理參數包,是不是和掃描一樣呢),只需要匹配到Head和待匹配類型相同,就返回0;如果不匹配就像TypeAt中那樣遞歸調用元函數,對其返回結果+1,因為結果在本層之后,所以需要把本層加進索引里,遞歸調用返回后逐漸向前相加最終的結果就是類型所在的index(從0開始)。

IndexOf一個重要的功能就是判斷某個類型是否在TypeList中。

如果處理完參數包仍然找不到對應類型呢?這時候對空的TypeList做個特化返回-1就行,當然前面的偏特化元函數也需要對這種情況做處理。

現在我們來看下IndexOf的調用形式:“IndexOf<TList, int>::value”

現在我們就照着這個形式實現它:

template <typename TList, typename T> struct IndexOf;
template <typename Head, typename... Tails, typename T>
struct IndexOf<TypeList<Head, Tails...>, T> {
private:
    // 為了避免表達式過長,先將遞歸的結果起了別名
    using Result = IndexOf<TypeList<Tails...>, T>;
public:
    // 如果類型相同就返回,否則檢查遞歸結果,-1說明查找失敗,否則返回遞歸結果+1
    static constexpr int value =
            std::is_same_v<Head, T> ? 0 :
            (Result::value == -1 ? -1 : Result::value + 1);
};

// 終止條件,沒找到對應類型
template <typename T>
struct IndexOf<TypeList<>, T> {
    static constexpr int value = -1;
};

因為有了c++11的type_traits的幫助,我們可以偷懶少寫了一個類似這樣的偏特化:

template <typename... Tails, typename T>
struct IndexOf<TypeList<T, Tails...>, T> {
    static constexpr int value = 0;
};

然而現代c++的威力遠不止如此,前面我們說過不能對參數包實現迭代,但是我們可以借助折疊表達式、constexpr函數,編譯期容器這三者,將參數包中每一個參數映射到編譯期容器中,之后便可以對編譯期容器進行迭代操作,避免了遞歸偏特化。

當然,這種方案只是證明了c++的可能性,真正實現起來比遞歸的方式要麻煩的多,性能可能也並不會比遞歸好多少(當然都是編譯期的計算,不會付出運行時代價),而且需要一個完全支持c++14,至少支持c++17折疊表達式的編譯期(vs2019可以設置使用clang,原生的編譯器對c++17的支持有點慘不忍睹)。

技術的關鍵是c++14的std::arraystd::index_sequence

前者是我們需要使用的編譯期容器(vector也許以后也會成為編譯期容器,編譯期的動態內存分配已經進入c++20),后者負責把一串數字映射為模板參數包,以便折疊表達式展開。(折疊表達式仍然可以參考cppreference上的解釋)

std::index_sequence的一個示例:

using Ints = std::make_index_sequence<5>; // 產生std::index_sequence<0, 1, 2, 3, 4>

// 將一串數字傳遞給模板,重新映射為變長模板參數
template <typename T, std::size_t... Nums>
void some_func(T, std::index_sequence<Nums...>) {/**/}

some_func("test", Ints{}); // 這時Nums包含<0, 1, 2, 3, 4>

這個用法看着很像元編程的慣用法之一的標簽分派,但是仔細看的話兩者不是同一種技巧,暫時沒有發現這種技巧的具體名字,因此我們就暫時稱其為“整數序列映射”。

有了這些前置知識,現在可以看實現了:

template <typename TList, typename T> struct IndexOf2;
template <typename T, typename... Types>
struct IndexOf2<TypeList<Types...>, T> {
    using Seq = std::make_index_sequence<sizeof...(Types)>;
    static constexpr int index()
    {
        std::array<bool, sizeof...(Types)> buf = {false};
        set_array(buf, Seq{});
        for (int i = 0; i < sizeof...(Types); ++i) {
            if (buf[i] == true) {
                return i;
            }
        }
        return -1;
    }

    template <typename U, std::size_t... Index>
    static constexpr void set_array(U& arr, std::index_sequence<Index...>)
    {
        ((std::get<Index>(arr) = std::is_same_v<T, Types>), ...);
    }
};

// 空TypeList單獨處理,簡單返回-1即可,因為list里沒有任何東西自然只能返回-1
template <typename T>
struct IndexOf2<TypeList<>, T> {
    static constexpr int index()
    {
        return -1;
    }
};

其中index很好理解,首先初始化一個array,隨后將參數包的每個參數的狀態映射到array里,之后循環找到第一個true的index,整個過程都在編譯期進行。

問題在於set_array里,里面究竟發生了什么呢?

首先是我們前面提到的整數序列映射,Index在映射后是{0, 1, 2, ..., len_of(Array) - 1},接着被折疊表達式展開為:

(
    (std::get<0>(arr) = std::is_same_v<T, Types_0>),
    (std::get<1>(arr) = std::is_same_v<T, Types_1>),
    (std::get<2>(arr) = std::is_same_v<T, Types_2>),
    ...,
    (std::get<len_of(Array) - 1>(arr) = std::is_same_v<T, Types_(len_of(Array) - 1>)),
)

真實的展開是類似Arg1, (Arg2, (Arg3, Arg4))這種,為了可讀性我把括號省略了,反正在這里執行順序並不影響結果。

get會返回array中指定的index的內容的引用,因此我們可以對它賦值,Types_N則是從左至右被依次展開的參數,這樣不借助遞歸就將參數包中所有的參數處理完了。

不過本質上方案B還是舍近求遠式的雜耍,實用性並不高,但是它充分展示了現代c++給模板元編程帶來的可能性。

Append為TypeList添加元素

看完前面幾個元函數你可能已經覺得有點累了,沒事我們看個簡單的放松一下。

Append可以在TypeList前添加元素(雖然這個操作嚴格來說不叫Append,但后面經常要用而且實現類似,所以請允許我把它當作特殊的Append),在TypeList后面添加元素或是其他TypeList中的所有元素。

調用形式如下:

Append<int, TList>::result_type;
Append<TList, long>::result_type;
Append<TList1, TList2>::result_type;

借助變長模板實現起來頗為簡單:

template <typename, typename> struct Append;
template <typename... TList, typename T>
struct Append<TypeList<TList...>, T> {
    using result_type = TypeList<TList..., T>;
};

template <typename T, typename... TList>
struct Append<T, TypeList<TList...>> {
    using result_type = TypeList<T, TList...>;
};

template <typename... TListLeft, typename... TListRight>
struct Append<TypeList<TListLeft...>, TypeList<TListRight...>> {
    using result_type = TypeList<TListLeft..., TListRight...>;
};

Erase和EraseAll刪除元素

顧名思義,Erase負責刪除第一個匹配的type,EraseAll刪除所有匹配的type,它們有着一樣的調用形式:

Erase<TList, int>::result_type;
EraseAll<TList, long>::result_type;

Erase的算法也比較簡單,利用了遞歸,先在本層查找,如果匹配就返回去掉Head的TypeList,否則對剩余的部分繼續調用Erase:

template <typename TList, typename T> struct Erase;
template <typename Head, typename... Tails, typename T>
struct Erase<TypeList<Head, Tails...>, T> {
    using result_type = typename Append<Head, typename Erase<TypeList<Tails...>, T>::result_type >::result_type;
};

// 終止條件1,刪除匹配的元素
template <typename... Tails, typename T>
struct Erase<TypeList<T, Tails...>, T> {
    using result_type = TypeList<Tails...>;
};

// 終止條件2,未發現要刪除的元素
template <typename T>
struct Erase<TypeList<>, T> {
    using result_type = TypeList<>;
};

注意模板的第一個參數必須是一個TypeList。

如果Head和T不匹配時,我們需要借助Append把Head粘回TypeList,這是在定義那節提到的弊端之一,因為我們不可能直接展開TypeList類型,它不是變長模板的參數包。后面的幾個元函數中都需要用到Append來完成相同的工作,與傳統的鏈式實現相比這一點確實不夠優雅。

有了Erase,實現EraseAll就簡單很多了,我們只需要在終止條件1那里不終止,而是對剩下的list繼續進行EraseAll即可:

template <typename TList, typename T> struct EraseAll;
template <typename Head, typename... Tails, typename T>
struct EraseAll<TypeList<Head, Tails...>, T> {
    using result_type = typename Append<Head, typename EraseAll<TypeList<Tails...>, T>::result_type >::result_type;
};

// 這里不會停止,而是繼續把所有匹配的元素刪除
template <typename... Tails, typename T>
struct EraseAll<TypeList<T, Tails...>, T> {
    using result_type = typename EraseAll<TypeList<Tails...>, T>::result_type;
};

template <typename T>
struct EraseAll<TypeList<>, T> {
    using result_type = TypeList<>;
};

有了Erase和EraseAll,下面去除重復元素的元函數也就能實現了。

NoDuplicates去除所有重復type

NoDuplicates也許看起來會很復雜,其實不然。

NoDuplicates算法只需要三步:

  1. 先對去除Head之后的TypeList進行NoDuplicates操作,形成L1;現在保證L1里沒有重復元素
  2. 對L1進行刪除所有Head的操作,形成L2,因為L1里可能會有和Head相同的元素;
  3. 最后將Head添加回TypeList

步驟1中遞歸的調用還會重復相同的步驟,這樣最后就確保了TypeList中不會有重復的元素出現。這個元函數也是較為常用的,比如你肯定不會想在抽象工廠模板類中出現兩個相同的類型,這不正確也沒有必要。

調用形式為:

NoDuplicates<TList>::result_type;

按照步驟實現算法也不難:

template <typename TList> struct NoDuplicates;
template <>
struct NoDuplicates<TypeList<>> {
    using result_type = TypeList<>;
};

template <typename Head, typename... Tails>
struct NoDuplicates<TypeList<Head, Tails...>> {
private:
    // 保證L1中沒有重復的項目
    using L1 = typename NoDuplicates<TypeList<Tails...>>::result_type;
    // 刪除L1中所有和Head相同的項目,L1中已經沒有重復,所以最多只會有一項和Head相同,Erase就夠了
    using L2 = typename Erase<L1, Head>::result_type;
public:
    // 把Head添加回去
    using result_type = typename Append<Head, L2>::result_type;
};

在處理L1時我們只使用了Erase,注釋已經給出了原因。

Replace和ReplaceAll

除了刪除,偶爾我們也希望將某些type替換成新的type。

這里我只講解Replace的實現,Replace和ReplaceAll的區別就像Erase和EraseAll,因此不再贅述。

Replace其實就是翻版的Erase,只不過它並不刪除匹配的Head,而是將其替換成了新類型。

template <typename TList, typename Old, typename New> struct Replace;
template <typename T, typename U>
struct Replace<TypeList<>, T, U> {
    using result_type = TypeList<>;
};

template <typename... Tails, typename T, typename U>
struct Replace<TypeList<T, Tails...>, T, U> {
    using result_type = typename Append<U, TypeList<Tails...>>::result_type;
};

template <typename Head, typename... Tails, typename T, typename U>
struct Replace<TypeList<Head, Tails...>, T, U> {
    using result_type = typename Append<Head, typename Replace<TypeList<Tails...>, T, U>::result_type>::result_type;
};

Derived2Front將派生類型移動至list前部

前面的元函數基本都是將參數包分解為Head和Tails,然后通過遞歸依次處理,但是現在描述的算法就有些復雜了。

通過給定一個Base,我們希望TypeList中所有Base的派生類都能出現在list的前部,位置先於Base,這在你處理繼承的層次結構時會很有幫助,當然我們后面是示例中沒有使用此功能,不過作為一個比較重要的接口,我們還是需要進行一定的了解的。

首先想要將派生類移動到前端就需要先找出在list末尾上的派生類型,我們使用一個幫助類的元函數MostDerived來實現:

template <typename TList, typename Base> struct MostDerived;
// 終止條件,找不到任何派生類就返回Base自己
template <typename T>
struct MostDerived<TypeList<>, T> {
    using result_type = T;
};

template <typename Head, typename... Tails, typename T>
struct MostDerived<TypeList<Head, Tails...>, T> {
private:
    using candidate = typename MostDerived<TypeList<Tails...>, T>::result_type;
public:
    using result_type = std::conditional_t<std::is_base_of_v<candidate, Head>, Head, candidate>;
};

首先我們遞歸調用MostDerived,結果保存為candidate,這是Base在去除Head之后的list中最深層次的派生類或是Base自己,然后我們判斷Head是否是candidate的派生類,如果是就返回Head,否則返回candidate,這樣就可以得到最末端的派生類類型了。

std::conditional_t則是c++11的type_traits提供的基礎設施之一,通過bool值返回類型,有了它我們就可以省去自己實現Select的工夫了。

完成幫助元函數后就可以着手實現Derived2Front了:

template <typename TList> struct Derived2Front;
template <>
struct Derived2Front<TypeList<>> {
    using result_type = TypeList<>;
};

template <typename Head, typename... Tails>
struct Derived2Front<TypeList<Head, Tails...>> {
private:
    using theMostDerived = typename MostDerived<TypeList<Tails...>, Head>::result_type;
    using List = typename Replace<TypeList<Tails...>, theMostDerived, Head>::result_type;
public:
    using result_type = typename Append<theMostDerived, List>::result_type;
};

算法步驟不復雜,先找到最末端的派生類,然后將去除頭部的TypeList中與最末端派生類相同的元素替換為Head,最后我們把最末端的派生類添加在處理過的TypeList的最前面,就完成了派生類從末端移動到前端。

元函數實現總結

通過這些元函數的示例,我們可以看到現代c++對於元編程有了更多的內建支持,利用新的標准庫和語言特性我們可以少寫很多代碼,也可以實現在c++11之前看似根本不可能的任務。

當然現代c++也帶來了自己獨有的問題,比如邊長模板參數包無法直接迭代,這導致了我們大多數時間仍然需要依賴遞歸和偏特化這樣的古典技法。

然而不可否認的是,隨着語言的進化,c++進行元編程的難度在不斷下降,元編程的能力和代碼的表現力也越來越強了。

示例

我想通過兩個示例來更好地展示TypeList和現代c++的威力。

第一個例子是個簡陋的tuple類型,模仿了標准庫。

第二個例子是工廠類,傳統的工廠模式要么避免不了復雜的繼承結構,要么避免不了大量的硬編碼導致擴展困難,我們使用TypeList來解決這些問題。

自制tuple

首先是我們的玩具tuple,之所以說它簡陋是因為我們只選擇實現了get這一個接口,並且標准庫的tuple並不是向我們這樣實現的,因此這里的tuple只是一個演示用的玩具罷了。

首先是我們用來存儲數據的節點:

template <typename T>
struct Data {
    explicit Data(T&& v): value_(std::move(v))
    {}
    T value_;
};

接着我們實現Tuple:

template <typename... Args>
class Tuple: private Data<Args>... {
    using TList = TypeList<Args...>;
public:
    explicit Tuple(Args&&... args)
    : Data<Args>(std::forward<Args>(args))... {}

    template <typename Target>
    Target& get()
    {
        static_assert(IndexOf<TList, Target>::value != -1, "invalid type name");
        return Data<Target>::value_;
    }

    template <std::size_t Index>
    auto& get()
    {
        static_assert(Index < Length<TList>::value, "index out of range");
        return get<typename TypeAt<TList, Index>::type>();
    }

    // const的重載
    template <typename Target>
    const Target& get() const
    {
        static_assert(IndexOf<TList, Target>::value != -1, "invalid type name");
        return Data<Target>::value_;
    }

    template <std::size_t Index>
    const auto& get() const
    {
        static_assert(Index < Length<TList>::value, "index out of range");
        return get<typename TypeAt<TList, Index>::type>();
    }
};

// 空Tuple的特化
template <>
class Tuple<> {};

我們的Tuple實現地簡單暴力,通過private繼承,我們就可以同時存儲多種不同的數據,引用的時候只需要Data<type>.value_,因此我們的第一個get很容易就實現了,只需要檢查TypeList中是否存在對應類型即可。

但是標准庫的get還有第二種形式:get<1>()。對於第一種get,事實上我們不借助TypeList也能實現,但是對於第二種我們就不得不借助TypeList的力量了,因為我們除了利用元容器記錄type的出現順序之外別無辦法(這也是為什么標准庫不會這樣實現tuple的原因之一)。因此我們利用TypeAt元函數找到對應的類型后再獲取它的值。

另外標准庫不使用這種形式最重要的原因就是如果你在tuple里存儲了2個以上相同type的數據,會報錯,很容易想到是為什么。

所以類似的技術更適合用於variant這樣的對象,不過這里只是舉例所以我們忽略了這些問題。

下面是一些簡單的測試:

Tuple<int, double, std::string> t{1, 1.2, "hello"};
std::cout << t.get<std::string>() << std::endl;
t.get<std::string>() = "Hello, c++!";
std::cout << t.get<2>() << std::endl;
std::cout << t.get<1>() << std::endl;
std::cout << t.get<0>() << std::endl;

// Output:
// hello
// Hello, c++!
// 1.2
// 1

簡化工廠模式

假設我們有一個WidgetFactory,用來創建不同風格的Widgets,Widgets的種類有很多,例如Button,Label等:

class WidgetFactory {
public:
    virtual CreateButton() = 0;
    virtual CreateLabel() = 0;
    virtual CreateToolBar() = 0;
};

// 風格1
class KDEFactory: public WidgetFactory {
public:
    CreateButton() override;
    CreateLabel() override;
    CreateToolBar() override;
};

// 風格2
class GnomeFactory: public WidgetFactory {
public:
    CreateButton() override;
    CreateLabel() override;
    CreateToolBar() override;
};

// 使用
WidgetFactory* factory = new KDEFactory;
factory->CreateButton(); // KDE button
delete factory;
factory = new GnomeFactory;
factory->CreateButton(); // Gnome button

這種實現有兩個問題,一是如果增加/改變/減少產品,那么需要改動大量的代碼,容易出錯;二是創建不同種類的widget的代碼通常是較為相似的,所以我們在這里需要不斷復讀自己,這通常是bug的根源之一。

較為理想的形式是什么呢?如果widget構造過程相同,只是參數上有差別,你可能已經想到了,我們有變長模板和完美轉發:

class WidgetFactory {
public:
    template <typename T, typename... Args>
    auto Create(Args&&... args)
    {
        return new T(std::forward<Args>(args)...);
    }
};

這樣我們可以通過Create<KDEButton>(...)來創建不同的對象了,然而這已經不是一個工廠了,我們創建工廠的目的之一就是為了限制產品的種類,現在我們反而把限制解除了!

那么這么解決呢?答案還是TypeList,通過TypeList限制產品的種類:

template <typename... Widgets>
class WidgetFactory {
    // 我們不需要重復的類型
    using WidgetList = NoDuplicates<TypeList<Widgets...>>::result_type;
public:
    template <typename T, typename... Args>
    auto Create(Args&&... args)
    {
        static_assert(IndexOf<WidgetList, T>::value != -1, "unknow type");
        return new T(std::forward<Args>(args)...);
    }
};

using KDEFactory = WidgetFactory<KDEButton, KDEWindow, KDELabel, KDEToolBar>;
using GnomeFactory = WidgetFactory<GnomeLabel, GnomeButton>;

現在如果我們想增加或改變某一個工廠的產品,只需要修改有限數量的代碼即可,而且我們在限制了產品種類的同時將重復的代碼進行了抽象集中。同時,類型檢查都是編譯期處理的,無需任何的運行時代價!

當然,這樣簡化的壞處是靈活性的降低,因為不同工廠現在實質是不同的不相關類型,不可能通過Base*Base&關聯起來,不過對於接口相同但是類型相同的對象,我們還是可以依賴模板實現靜態分派,這只是設計上的取舍而已。

總結

這篇文章只是對模板元編程的入門級探討,旨在介紹如果使用現代c++簡化元編程和泛型編程任務。

本文雖然不能帶你入門元編程,但是可以讓你對元編程的概念有一個整體的概覽,對深入的學習是有幫助的。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM