現代c++與模板超程式設計

apocelipes發表於2019-08-02

最近在重溫《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++簡化超程式設計和泛型程式設計任務。

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

相關文章