潮.C++11:Variadic Template, Parameter Pack

TJSW
12 min readFeb 10, 2019

--

簡單說就是 C++11 對於樣版的一個大增強。古代的 C++ 對於樣版 (樣版類別、樣版函數等) 只能有固定數量的樣版參數。但這樣對於想在編譯時期 (compile-time) 秀操作太不方便也沒有空間。

所以 Variadic Template 最主要的目的就在於讓 C++ 樣版參數數量成為一個編譯前的未定數,讓「呼叫」也就是具現化樣版的人去決定參數數量。

函式樣版 Function Template

就先從比較容易懂的樣版函式講起。以前想要把兩個數字 int 加起來,我們可能這樣寫:

int sum(int a, int b)
{
return a + b;
}

我想同學們的智商都知道這只能對付 int 型態變數,如果今天我們換成 double 想要看是否相等,甚至是類別 std::string,或是有三個以上的物件呢?

template<typename T>
T sum(const T &a, const T &b)
{
return a + b;
}
template<typename T>
T sum(const T &a, const T &b, const T &c)
{
return a + b + c;
}
template<typename T>
T sum(const T &a, const T &b, const T &c, const T &d)
{
return a + b + c + d;
}

大概是這種感覺,我需要傳幾個參數,我就需要自己手動尻相對應的函數宣告出來,這樣很累,所以 C++11 就讓大家很開心的只要一次通關。

template<typename T>
T sum(const T& first)
{
return first;
}
template<typename T, typename... Args>
T sum(const T& first, const Args&... args)
{
return first + sum(args...);
}
int main()
{
std::cout << sum(1, 2, 3) << std::endl; // 6
std::cout << sum(1, 2, 3, 4) << std::endl; // 10
std::cout << sum(1, 2, 3, 4, 5) << std::endl; // 15
}

這樣就會印出 10 還有 15。我知道各位同學不太知道上面在寫三小。

不過先別急,不管我們丟幾個參數給 sum() 都沒問題,也就是 sum() 變成了一個未定參數長度的函式了。意思就是你開心丟 0 個,丟 1 個,丟任意個參數進去呼叫都行。

參數包 Parameter Pack

關鍵就在樣版參數的宣告 typename... Args 。古時候 C++ 就規定可以用 typename 或是 class 來表示型別樣版參數。C++11 告訴我們只要在 typename 後面加上 ... 來表示這裡有未定個數個型別樣版參數。後面的 Args 就代表了一堆型別參數。

而相對應宣告的函式裡面所接的參數 Args... args,同樣就不再只代表一個參數,而是一包參數 (parameter pack),姑且就叫他參數包 XD。一包參數也不能直接拿來用,要使用的時候就必須把它打開變成一個一個的參數,在 C++11 裡面就叫 pack expansion 包展開~。

參數包展開 Pack expansion

C++11 裡頭,參數包展開 (pack expansion) 有非常非常多的場合,但配合上面的內容我們先講用於呼叫函式,也就是把 parameter pack 當作參數來呼叫別的函式該怎麼辦。

typename 變成 typename... 的精神很像,我們想要「使用」parameter pack 的時候就只需要在後面加上 ... 也就是上面 code 的 args...

讓我們來逐步解析上面 main()裡呼叫的 sum(1, 2, 3)發生了什麼事:

  1. sum() 樣版函式,first 對應到 1,T 推導成 int,後面那包 2 和 3 都被丟進去 args 了。於是 sum(args...) 就等價展開成去呼叫 sum(2, 3)。意思就是理解成呼叫 1 + sum(2, 3)
  2. 依此類推,呼叫 sum(2, 3) 編譯器會理解成你想做的事是 2 + sum(3)
  3. 最後,只有一個參數的 template<typename T> sum(const T&) 就發揮作用,把 sum(3) 吃進來,然後直接 return 3;

如此一來就會得出 6。

當然編譯器不會真的知道我是直接撰寫 sum(2, 3) 這麼明確的 expression,這邊寫出來是模擬實際執行時期 (run-time) 情形,跟編譯時期 (compile-time) 就知道是 2 和 3 還是有區別的。

而上面的 Args... 和一般樣版參數一樣,代表的是可以匹配到各種型別,所以我們其實可以不只傳整數給 sum() 。我們開心的話,只要傳入的物件支援 operator+() 也都可以丟進去,比如:

std::string a("a"), b("bc"), c("def");
std::cout << sum(a, b, c) << std::endl; // abcdef

不過參數包展開並沒有直接加上 ... 這麼小兒科,因為參數包展開的對象不單單是參數包本身,而是參數包的表達式 (expression)。也就是說今天我們想把上面的數字總和,改為數字的立方和,也是可以如法炮製:

template<typename T>
T cube(const T& t)
{
return t * t * t;
}
template<typename ...Args>
void sum_of_cube(const Args&... args)
{
std::cout << sum(cube(args)...) << std::endl;
}
sum_of_cube(2, 4, 5); // 197

這樣當我們想呼叫 sum(cube(args)…) 時,編譯器就會自動幫我們展開 args 這個參數包所表達的 expression。可以等價於這個執行情況:

sum_of_cube(const int &n1, const int &n2, const int &n3)
{
std::cout << sum(cube(n1), cube(n2), cube(n3)) << std::endl;
}

也就是對 args 內的每一個參數都去呼叫 cube() 然後再以逗號分隔丟給 sum(), 如此一來就輕鬆的做到立方和了。當然同學們想寫成下面這樣也是無傷大雅:

template<typename ...Args>
void sum_of_cube(const Args&... args)
{
std::cout << sum(args * args * args ...) << std::endl;
}

我不想要支援那麼多型別

你說你不想要自動推導型別,只想要支援 int 的加總?也沒問題,那就是 C++ 原本就有的非型別樣版參數 (non-type template parameter) 出場的時候。也是照抄,原本 C++ 的 non-type template parameter 就只是把 typename 換成型別名稱。在 variadic template 的情境下也一樣,就直接在型別後面加 ... 就行。也就是把上述的 sum() 宣告改成

template<int... Is>
int sum();

類別樣版 Class Template

講完了函式之後該來講講 variadic template 對於樣版類別 (class template) 會有什麼影響。

通常我們需要用到樣版類別的場合不外乎是

  1. 某個樣版參數用來當作成員函式的樣版參數,或參數型別。
  2. 成員變數的樣版參數,或直接就是成員物件的宣告型別 (或指標,參照...)。
  3. 樣版參數標示繼承基底類別
  4. 標示繼承基底類別的樣版參數
  5. Template meta programming (樣版元編程),炫技炫炮秀操作用,讓別人看不懂自己 code 用的 。

前兩項沒有特別好說的,有趣的是後三項,但最後一項太炫炮了,暫時不講。

成員變數的型別或樣版參數

大家 C++ 最常見的好朋友 std::pair 就是這個用途的經典作,可以概略示意如下。

template<typename T, typename U>
struct C
{
T first;
U second;
};
C<int, int> c1;
C<std::string, uint64_t> c2;

如果想要存放不定個數型別成員,那就如同函式樣版一樣,把 typenametypename... 代表有很多型別的意思。

template <typename... Ts>
struct C
{
};
C<int> c1;
C<double, char, std::string> c2;
C<std::vector<int>, int*> c3;

具體 class 裡面想怎麼表示以及存放這些不同型別的成員變數那就是 std::tuple 做的事情了。

樣版參數標示繼承基底類別

大概就是這種情況,你有多個基底類別他們同時被一個衍生類別繼承,繼承的目的是想要使用到基類別的一些成員,比如函式。

struct C1 {
void say_hi(int) { puts("int hi"); }
};
struct C2 {
void say_hi(char) { puts("char hi"); }
};
struct C3 {
void say_hi(double) { puts("double hi"); }
};
template<typename T, typename U, typename V>
struct D : T, U, V {
using T::say_hi;
using U::say_hi;
using V::say_hi;
};
D<C1, C2, C3> d;

上面這樣就是我衍生類 D 繼承了三個基類,然後把他們三個人的 say_hi 都拿來加入 D 的 overloading scope。今天假設你的衍生類 D 想要繼承不定個數的基類,怎麼辦?

是的,variadic template 他又來了。寫法也很簡單:

#include <iostream>template<typename T> struct C { };template<>
struct C<int> {
void say_hi(int) { puts("int hi"); }
};
template<>
struct C<double> {
void say_hi(double) { puts("double hi"); }
};
template<>
struct C<char> {
void say_hi(char) { puts("char hi"); }
};
template<typename... Args>
struct D {
void say_hi();
};
template<typename First, typename... Args>
struct D<First, Args...> : C<First>, D<Args...> {
using C<First>::say_hi;
using D<Args...>::say_hi;
};
int main()
{
D<int, double, char> d;
d.say_hi(1); // int hi
d.say_hi(2.1); // double hi
d.say_hi('a'); // char hi
}

我的基類為了耍帥,也寫成了一堆樣版類別。主要意思是 D 繼承了 C<int>, C<double>, C<char> 這三個基類。

同學們可能會覺得這個 D 的繼承寫得天花亂墜。衍生類繼承時作了一點小技巧,因為在 C++11 我們沒辦法直接 using 所有 parameter pack 標示的繼承基類,也就是這件事做不到:

using C<Args>::say_hi...;

所以我們就必須使用一點 template meta programming 的基本技法,也就是遞迴的方式來將 parameter pack 一項一項拆解,並且一項一項的去做我們想要的操作,也就是 using 某個基類的 say_hi()

這個遞迴的方法在上面 function template 的求總和也看到了,原則上就是拆解第一項,然後剩下的歸在另一個 parameter pack 再遞迴。

所以上面的繼承關係應該是:

  1. D<int, double, char> 繼承的是 C<int> 以及 D<double, char> 這兩個基類。
  2. D<double, char> 繼承 C<double> 以及 D<char>
  3. D<char> 繼承 C<char> 以及 D<>,也就是 code 裡面比較無害的 struct D)。
  4. using 部份就是每一個上面提到的 D<...> 反過來把他所繼承的 C<...> 以及 D<...> 裡面的 say_hi() 都拿進來,最後全部匯總到 D<int, double, char> 這個類裡面。
  5. 所以 D<int, double, char> 最後就會看得到 C<int>, C<double>, 以及 C<char>say_hi()

寫到這類其實已經有一些 template meta programming 的雛形了。不過真的不健康,所以這邊不展開篇幅。

所以各位同學們可能覺得,這到底幹嘛?為了三個 using 把好好的平行繼承寫成歪歪的繼承。只能說 C++ 的標準推動真的很緩慢,上面 parameter pack 在 using declarations 的展開要等到 C++17 才納入標準,在支援 C++17 的各編譯器就可以直接平行繼承所有 parameter pack 標示的基類,並且一套 using 解決。

template<typename... Args>
struct D : C<Args>... {
using C<Args>::say_hi...;
};

小結

寫到這邊其實只涵蓋非常少 variadic template 以及 parameter pack expansion 的用法。事實上 pack expansion 可以發生的地方,在 C++11 可以一句話不精確的敘述:

所有需要用到 , (comma operator) 的地方,就可以展開 parameter pack。

C++11 可以展開參數包的地方包含 (但不限於)

  1. 函式呼叫時傳入參數 (最一開始的 sum() 範例)
  2. 函式定義中的參數列表 (同上,出現在 sum() 定義的參數列表)
  3. 物件宣告時的括號初始化 (白話就是呼叫 constructor)
  4. 大括號初始化 (比如 int arr[5] = {args...}; )
  5. 用於樣版參數 (上面 struct D 的一系列繼承 D<args…>)
  6. 用於樣版類別標示繼承基類 ( struct D : Cs… )
  7. Lambda 捕捉
  8. sizeof (得到參數包裡面所有型別個別的記憶體)

其實還有非常多地方可以發生 pack expansion 或是套用 variadic template,就有待同學們發掘鑽研啦!

--

--