泛化之美 —— C++11 可變參數模板的妙用

概述
首先這篇文章出自博客園作者:[ ?? qicosmos ],我對本文的實例代碼進行了學習、思考和整理糾正,理清了文章的全部細節,覺得這是一篇讓我受益匪淺的文章。之所以會接觸「可變參數模板」這部分的內容,是因為我當下剛好在學C++11 function機制,其內部實現需要接收不定長度的參數,因此需要用到「可變參數模板」相關的知識。本文有很多的C++模板元編程「黑魔法」是我之前從來沒接觸過的,比如模板遞歸展開、類型萃取type_traits中的基石integral_constant等等。C++的學習之路任重而道遠呀,那廢話不多說,我們來說說今天的主題,C++11的可變參數模板。
C++11的新特性「可變參數模板(variadic templates)」是C++11新增的「最強大」的特性之一,它對參數進行了高度泛化,它能表示0到任意個數、任意類型的參數。相比C++98/03,類模板和函數模板中只能含固定數量的模板參數,可變模板參數無疑是一個巨大的改進。然而由于可變模板參數比較抽象,使用起來需要一定的技巧,所以它也是C++11中最難理解和掌握的特性之一。雖然掌握可變參數模板有一定難度,但是它卻是C++11 中最有意思的一個特性,本文希望帶領讀者由淺入深的認識和掌握這一特性,同時也會通過一些實例來展示可變參數模板的一些用法。
可變模板的參數展開
可變參數模板和普通模板的語義是一樣的,只是寫法上稍有區別,聲明可變參數模板時需要在typename或class后面帶上省略號「...」。比如我們常常這樣聲明一個可變模板參數:template<typename...>或者template<class...>,一個典型的可變模板參數的定義是這樣的:
template <typename... T>
void f(T... args);
上面的可變模板參數的定義當中,省略號的作用有兩個:
- 聲明一個參數包
T... args,這個參數包中可以包含0到任意個模板參數; - 在模板定義的右邊,可以將參數包展開成一個一個獨立的參數。
上面的參數args「前面」有省略號,所以它就是一個可變模板參數,我們把帶省略號的參數稱為“參數包”,它里面包含了0到N(N>=0)個模板參數。我們無法直接獲取參數包args中的每個參數的,只能通過「展開參數包」的方式來獲取參數包中的每個參數,這是使用可變模板參數的一個「主要特點」,也是「最大的難點」,即如何展開可變模板參數。
可變模板參數和普通的模板參數語義是一致的,所以可以應用于函數和類,即「可變參數模板函數」和「可變參數模板類」,然而,模板函數不支持偏特化,所以可變參數模板函數和可變參數模板類展開可變參數的方法還不盡相同,下面我們來分別看看他們展開可變參數的方法。
可變參數函數模板
一個簡單的可變參數函數模板:
#include <iostream>
using namespace std;
template<typename... T>
void f(T... args) {
cout << sizeof...(args) << endl;
}
int main() {
f(); // 0
f(1, 2); // 2
f(1, 2, "");// 3
return 0;
}
上面的例子中,f()沒有傳入參數,所以參數包為空,輸出的size為0,后面兩次調用分別傳入兩個和三個參數,故輸出的size分別為2和3。由于可變模板參數的類型和個數是不固定的,所以我們可以傳任意類型和個數的參數給函數f。這個例子只是簡單的將可變參數模板的個數打印出來,如果我們需要將參數包中的每個參數打印出來的話就需要通過一些方法了。展開可變模板參數函數的方法一般有兩種:
- 一種是通過「遞歸函數」來展開參數包
- 另外一種是通過「逗號表達式」來展開參數包
下面來看看如何用這兩種方法來展開參數包。
遞歸函數方式展開參數包
通過遞歸函數展開參數包,需要提供一個「參數包展開的函數」和一個「遞歸終止函數」,遞歸終止函數正是用來終止遞歸的,來看看下面的例子。
#include <iostream>
using namespace std;
// @note 遞歸終止函數
void print() {
cout << "empty" << endl;
}
// @note 展開函數
template<typename T, typename... Args>
void print(T head, Args... rest) {
cout << "parameter " << head << endl;
print(rest...);
}
int main() {
print(1, 2, 3, 4);
return 0;
}
上例會輸出每一個參數,直到為空時輸出empty。展開參數包的函數有兩個,一個是遞歸函數,另外一個是遞歸終止函數,參數包Args...在展開的過程中遞歸調用自己,每調用一次參數包中的參數就會少一個,直到所有的參數都展開為止,當沒有參數時,則調用非模板函數print終止遞歸過程。
遞歸調用的過程是這樣的:
print(1,2,3,4);
print(2,3,4);
print(3,4);
print(4);
print();
上面的遞歸終止函數還可以寫成這樣:
template<typename T>
void print(T t) {
cout << t << endl;
}
修改遞歸終止函數后,上例中的調用過程是這樣的:
print(1, 2, 3, 4);
print(2, 3, 4);
print(3, 4);
print(4);
當參數包展開到最后一個參數時遞歸終止。
再看一個通過可變模板參數求和的例子:
#include <iostream>
using namespace std;
template<typename T>
T sum(T t) {
return t;
}
template<typename T, typename... Types>
T sum(T first, Types... rest) {
return first + sum<T>(rest...);
}
int main() {
cout << sum(1, 2, 3, 4) << endl; // 10
return 0;
}
sum在展開參數包的過程中將各個參數相加求和,參數的展開方式和前面的打印參數包的方式是一樣的。
逗號表達式展開參數包
遞歸函數展開參數包是一種標準做法,也比較好理解,但也有一個缺點,就是「必須」要一個重載的遞歸終止函數,即「必須」要有一個同名的終止函數來終止遞歸,這樣可能會感覺稍有不便。有沒有一種更簡單的方式呢?其實還有一種方法可以不通過遞歸方式來展開參數包,這種方式需要借助「逗號表達式」和「初始化列表」。比如前面print的例子可以改成這樣:
#include <iostream>
using namespace std;
template<typename T>
void printArg(T t) {
cout << t << endl;
}
template<typename... Args>
void expand(Args... args) {
int arr[] = {(printArg(args), 0)...};
}
int main() {
expand(1, 2, 3, 4);
return 0;
}
這個例子將分別打印出1、2、3、4四個數字。這種展開參數包的方式,「不需要」通過遞歸終止函數,是直接在expand函數體中展開的,printArg不是一個遞歸終止函數,只是一個處理參數包中每一個參數的函數。這種就地展開參數包的方式實現的關鍵是逗號表達式。我們知道逗號表達式會按順序執行逗號前面的表達式,比如:
d = (a = b, c);
這個表達式會按順序執行:b會先賦值給a,接著括號中的逗號表達式返回c的值,因此d將等于c。
expand 函數中的逗號表達式:
(printArg(args), 0)
也是按照這個執行順序,先執行printArg(args),再得到逗號表達式的結果0。同時還用到了C++11的另外一個特性:「初始化列表」,通過初始化列表來初始化一個「變長數組」:
{(printArg(args), 0)...}
將會展開成
{((printArg(arg1), 0), (printArg(arg2), 0), (printArg(arg3), 0), etc...)}
最終會創建一個元素值都為0的數組int arr[sizeof...(Args)]。由于是逗號表達式,在創建數組的過程中會先執行逗號表達式前面的部分printArg(args)打印出參數,也就是說在構造int數組的過程中就將參數包展開了,這個數組的目的純粹是為了在數組構造的過程展開參數包。我們可以把上面的例子再進一步改進一下,將函數作為參數,就可以支持lambda表達式了,從而可以少寫一個遞歸終止函數了,具體代碼如下:
#include <iostream>
#include <functional>
using namespace std;
template<typename T, typename ...Args>
void expand(const T &func, Args&&... args) {
// 這里用到了完美轉發
int arr[] = { (func(std::forward<Args>(args)), 0)... };
// initializer_list<int>{ (func(std::forward<Args>(args)), 0)... };
}
int main() {
expand([](int i)->void{cout << i << endl;}, 1, 2, 3);
return 0;
}
其實上面這里的
T類型就是function<void int>類型,參數可以寫成const function<void(int)> &func,也可寫成function<void(int)> func,這都無妨,我們只需要知道這是引用、或者是使用了function機制即可。
上面的例子將打印出每個參數,這里如果再使用「C++14的新特性」泛型lambda表達式的話,可以寫更泛化的lambda表達式了(把參數改為auto):
expand([](auto i)->void{cout << i << endl;}, 1, 2.2, "hello");
可變參數類模板
可變參數模板類是一個帶可變參數的模板類,比如C++11中的元組std::tuple就是一個可變模板類,它的定義如下:
template<typename... Types>
class tuple;
這個可變參數模板類可以攜帶任意類型任意個數的模板參數:
std::tuple<int> tp1 = std::make_tuple(1);
std::tuple<int, double> tp2 = std::make_tuple(1, 2.5);
std::tuple<int, double, string> tp3 = std::make_tuple(1, 2.5, "");
//std::tuple<int, double, string> tp3 = {1, 2.5, ""};
//std::tuple<int, double, string> tp3(1, 2.5, "");
可變參數模板類的參數個數可以為0個,所以下面的定義也是也是合法的:
std::tuple<> tp;
可變參數模板類的參數包展開的方式和可變參數模板函數的展開方式不同,可變參數模板類的參數包展開需要通過「模板特化」和「繼承方式」去展開,展開方式比可變參數模板函數要復雜。下面我們來看一下展開可變參數模板類中的參數包的方法。
模板偏特化和遞歸方式來展開參數包
可變參數模板類的展開一般需要定義兩到三個類,包括「類聲明」和「偏特化」的模板類。如下方式定義了一個基本的可變參數模板類:
#include <iostream>
using namespace std;
// 前向聲明
template<typename... Args>
struct Sum;
// 基本定義
template<typename First, typename... Rest>
struct Sum<First, Rest...> {
enum {
value = Sum<First>::value + Sum<Rest...>::value
};
};
// 遞歸終止
template<typename Last>
struct Sum<Last> {
enum {
value = sizeof(Last)
};
};
int main() {
cout << Sum<int, double, short>::value << endl; // 14
// Sum<int, double, short> s;
// cout << s.value << endl; // 4+8+2=14
return 0;
}
這個Sum類的作用是在編譯期計算出參數包中參數類型的size之和,通過Sum<int, double, short>::value就可以獲取這3個類型的size之和為14。這是一個簡單的通過可變參數模板類計算的例子,可以看到一個基本的可變參數模板應用類由三部分組成:
「第一部分」是:
template<typename... Args>
struct Sum;
它是前向聲明,聲明這個Sum類是一個可變參數模板類;
「第二部分」是類的定義:
template<typename First, typename... Rest>
struct Sum<First, Rest...> {
enum {
value = Sum<First>::value + Sum<Rest...>::value
};
};
它定義了一個部分展開的可變參數模板類,告訴編譯器如何遞歸展開參數包。
「第三部分」是特化的遞歸終止類:
template<typename Last>
struct Sum<Last> {
enum {
value = sizeof(Last)
};
};
通過這個特化的類來終止遞歸。
template<typename First, typename... Args>
struct Sum<First, Rest...> {
...
};
這個前向聲明要求Sum的模板參數至少有一個,因為可變參數模板中的模板參數可以有0個,有時候0個模板參數沒有意義,就可以通過上面的聲明方式來限定模板參數不能為0個。上面的這種三段式的定義也可以改為兩段式的,可以將前向聲明去掉,這樣定義:
#include <iostream>
using namespace std;
// 基本模板類定義
template<typename First, typename... Rest>
struct Sum {
enum {
value = Sum<First>::value + Sum<Rest...>::value
};
};
// 特化的終止函數
template<typename Last>
struct Sum<Last> {
enum {
value = sizeof(Last)
};
};
int main() {
cout << Sum<int, double, short>::value << endl; // 14
// Sum<int, double, short> s;
// cout << s.value << endl; // 4+8+2=14
return 0;
}
上面的方式「只要」一個基本的「模板類定義」和一個「特化的終止函數」就行了,而且限定了模板參數至少有一個。
遞歸終止模板類可以有「多種寫法」,比如上例的遞歸終止模板類還可以這樣寫:
#include <iostream>
using namespace std;
template<typename... Args>
struct Sum;
// 基本模板類定義
template<typename First, typename... Rest>
struct Sum<First, Rest...> {
enum {
value = sizeof(First) + Sum<Rest...>::value
};
};
// 遞歸終止模板類
template<typename First, typename Last>
struct Sum<First, Last> {
enum {
value = sizeof(First) + sizeof(Last)
};
};
int main() {
// cout << Sum<>::value << endl; // error
// cout << Sum<int>::value << endl; // error
cout << Sum<int, double>::value << endl; // 12
cout << Sum<int, double, short>::value << endl; // 14
return 0;
}
在展開到最后兩個參數時終止。
還可以在展開到0個參數時終止:
#include <iostream>
using namespace std;
template<typename... Args>
struct Sum;
// 基本模板類定義
template<typename First, typename... Rest>
struct Sum<First, Rest...> {
enum {
value = sizeof(First) + Sum<Rest...>::value
};
};
// 遞歸終止模板類
template<>
struct Sum<> {
enum {
value = 0
};
};
int main() {
cout << Sum<>::value << endl; // 0
cout << Sum<int>::value << endl; // 4
cout << Sum<int, double>::value << endl; // 12
cout << Sum<int, double, short>::value << endl; // 14
return 0;
}
??注:我一開始對遞歸終止條件那里的「展開到2個參數」和「展開到0個參數」的代碼改來改去就是跑不通,發現是「基本模板類定義」那里出了問題,將一開始的
Sum<First>::value改為sizeof(First)即可。對「展開到2個參數」的代碼而言,若不進行「基本模板類定義」這里的修改,那只能保證傳入的參數個數是>=2的偶數個,而當參數個數為奇數個時,就會報錯,這里可以仔細想想為什么,那么對「展開到0個參數」的情況,同理,就不再贅述。
那么說到這里,想必大家都有個疑惑,可以看到不論是模板函數還是模板類的遞歸程序,都用到了enum做遞歸的數值計算。在模板元編程中,enum是一項重要手段。其主要解決的問題是:
enum的「值由編譯器在編譯期間計算」- 利用遞歸算法和模板特化,可以讓編譯器在計算
enum值時「遞歸產生一系列class」
下面直接羅列一個求N的階乘的代碼,可以體會一下如何在模板中借助遞歸算法和模板特化來使用enum實現N的階乘:
#include <iostream>
using namespace std;
template<int N>
class F {
public:
enum {
res = N * F<N-1>::res
};
};
//遞歸終止條件
template<>
class F<1> {
public:
enum {
res = 1
};
};
int main() {
cout << F<4>::res << endl; // 24 = 1*2*3*4
return 0;
}
C++模板元編程中有個重要的類叫做std::integral_constant,用來定義類型的常量,其實可以使用std::integral_constant來消除枚舉定義。利用std::integral_constant也可獲得「編譯期常量」的特性。源碼之下了無秘密,std::intergral_constant的源碼如下:
template<typename _Tp, _Tp __v>
struct integral_constant
{
static constexpr _Tp value = __v;
typedef _Tp value_type;
typedef integral_constant<_Tp, __v> type;
constexpr operator value_type() const noexcept { return value; }
#if __cplusplus > 201103L
#define __cpp_lib_integral_constant_callable 201304
constexpr value_type operator()() const noexcept { return value; } //C++14起
#endif
};
本文對該模板類不做說明,我直接貼出相關文章:[ ?? C++11中type_traits中的基石 - integral_constant ],等后面有機會我再單獨搞一下這里。
因此,可以將前面的Sum例子改為這樣:
#include <iostream>
using namespace std;
//前向聲明
template <typename... Args>
struct Sum;
//基本定義
template <typename First, typename... Rest>
struct Sum<First, Rest...> : std::integral_constant<int, Sum<First>::value + Sum<Rest...>::value>
{};
//遞歸終止
template<typename Last>
struct Sum<Last> : std::integral_constant<int, sizeof(Last)>
{};
int main() {
cout << Sum<int, double, short>::value << endl; // 14 = 4+8+2
return 0;
}
繼承方式展開參數包
還可以通過繼承方式來展開參數包,比如下面的例子就是通過繼承的方式去展開參數包:
//整型序列的定義
template<int...>
struct IndexSeq {};
//繼承方式,開始展開參數包
template<int N, int... Indexes>
struct MakeIndexes : MakeIndexes<N-1, N-1, Indexes...> {};
// 模板特化,終止展開參數包的條件
template<int... Indexes>
struct MakeIndexes<0, Indexes...> {
using type = IndexSeq<Indexes...>;
};
int main() {
using T = MakeIndexes<3>::type;
cout << typeid(T).name() << endl; // IndexSeq<0, 1, 2>
return 0;
}
其中MakeIndexes的作用是為了生成一個可變參數模板類的整數序列,「最終輸出」的類型是:struct IndexSeq<0, 1, 2>。
MakeIndexes繼承于自身的一個「特化的」模板類,這個特化的模板類同時也在展開參數包,這個展開過程是通過繼承發起的,直到遇到特化的終止條件展開過程才結束。MakeIndexes<3>::type的展開過程是這樣的:
MakeIndexes<3> : MakeIndexes<2, 2> {}
MakeIndexes<2, 2> : MakeIndexes<1, 1, 2> {}
MakeIndexes<1, 1, 2> : MakeIndexes<0, 0, 1, 2> {
using type = IndexSeq<0, 1, 2>;
}
通過不斷的繼承遞歸調用,最終得到整型序列IndexSeq<0, 1, 2>。
如果不希望通過繼承方式去生成整型序列,則可以通過下面的方式生成:
#include <iostream>
using namespace std;
//整型序列的定義
template <int...>
struct IndexSeq {};
// 非繼承方式 開始展開參數包
template<int N, int... Indexes>
struct MakeIndexes {
using type = typename MakeIndexes<N-1, N-1, Indexes...>::type;
};
// 模板特化 終止展開參數包的條件
template<int... Indexes>
struct MakeIndexes<0, Indexes...> {
using type = IndexSeq<Indexes...>;
};
int main() {
using T = MakeIndexes<3>::type;
cout << typeid(T).name() << endl;
return 0;
}
我們看到了如何利用「遞歸」以及「偏特化」等方法來展開「可變模板參數」,那么實際當中我們會怎么去使用它呢?我們可以用可變模板參數來消除一些重復的代碼以及實現一些高級功能,下面我們來看看可變模板參數的一些應用。
可變參數模板消除重復代碼
C++11 之前如果要寫一個泛化的工廠函數,這個工廠函數能接受任意類型的入參,并且參數個數要能滿足大部分的應用需求的話,我們不得不定義很多重復的模板定義,比如下面的代碼:
template<typename T>
T *Instance() {
return new T();
}
template<typename T, typename T0>
T *Instance(T0 arg0) {
return new T(arg0);
}
template<typename T, typename T0, typename T1>
T *Instance(T0 arg0, T1 arg1) {
return new T(arg0, arg1);
}
template<typename T, typename T0, typename T1, typename T2>
T *Instance(T0 arg0, T1 arg1, T2 arg2) {
return new T(arg0, arg1, arg2);
}
template<typename T, typename T0, typename T1, typename T2, typename T3>
T *Instance(T0 arg0, T1 arg1, T2 arg2, T3 arg3) {
return new T(arg0, arg1, arg2, arg3);
}
template<typename T, typename T0, typename T1, typename T2, typename T3, typename T4>
T *Instance(T0 arg0, T1 arg1, T2 arg2, T3 arg3, T4 arg4) {
return new T(arg0, arg1, arg2, arg3, arg4);
}
struct A {
A(int) {}
};
struct B {
B(int, double) {}
};
A *pa = Instance<A>(1);
B *pb = Instance<B>(1, 2);
可以看到這個泛型工廠函數存在大量的重復的模板定義,并且限定了模板參數。用可變模板參數可以消除重復,同時去掉參數個數的限制,代碼很簡潔,通過可變參數模板優化后的工廠函數如下:
template<typename T, typename... Args>
T *Instance(Args... args) {
return new T(args...);
}
在上面的實現代碼T *Instance(Args... args)中,Args是值拷貝的,存在性能損耗,可以通過完美轉發來消除損耗,代碼如下:
template<typename T, typename... Args>
T *Instance(Args&&... args) {
return new T(std::forward<Args>(args)...);
}
struct A {
A(int) {}
};
struct B {
B(int, double) {}
};
A *pa = Instance<A>(1);
B *pb = Instance<B>(1, 2);
可變參數模板實現泛化的delegate
C++ 中沒有類似C#的委托,我們可以借助可變模板參數來實現一個。C#中的委托的基本用法是這樣的:
delegate int AggregateDelegate(int x, int y); // 聲明委托類型
int Add(int x, int y) { return x + y; }
int Sub(int x, int y) { return x - y; }
AggregateDelegate add = Add;
add(1, 2); // 調用委托對象求和
AggregateDelegate sub = Sub;
sub(2, 1); // 調用委托對象相減
C#中的委托的使用需要先定義一個委托類型,這個委托類型不能泛化,即委托類型一旦聲明之后就不能再用來接受其它類型的函數了,比如這樣用:
int Fun(int x, int y, int z) { return x + y + z; }
int Fun1(string s, string r) { return s.Length + r.Length; }
AggregateDelegate fun = Fun; //編譯報錯,只能賦值相同類型的函數
AggregateDelegate fun1 = Fun1; //編譯報錯,參數類型不匹配
這里不能泛化的原因是聲明委托類型的時候就限定了「參數類型」和「個數」,在C++11里不存在這個問題了,因為有了可變模板參數,它就代表了任意類型和個數的參數了,下面讓我們來看一下如何實現一個功能更加泛化的C++版本的委托(這里為了簡單起見只處理成員函數的情況,并且忽略const、volatile和const volatile成員函數的處理)。
#include <iostream>
using namespace std;
template<typename T, typename R, typename... Args>
class MyDelegate {
public:
MyDelegate(T *t, R (T::*f)(Args...)) : m_t(t), m_f(f) {}
R operator()(Args&&... args) {
return (m_t->*m_f)(std::forward<Args>(args)...);
}
private:
T *m_t;
R (T::*m_f)(Args...); // 函數指針
};
template<typename T, typename R, typename... Args>
MyDelegate<T, R, Args...> CreateDelegate(T *t, R (T::*f)(Args...)) {
return MyDelegate<T, R, Args...>(t, f);
}
struct A {
void Fun(int i ) { cout << i << endl; }
void Fun1(int i, double j) { cout << i+j << endl; }
};
int main() {
A a;
auto d = CreateDelegate(&a, &A::Fun); // 創建委托
d(1); // 調用委托 將輸出1
auto d1 = CreateDelegate(&a, &A::Fun1); // 創建委托
d1(1, 2.5); // 調用委托 將輸出3.5
return 0;
}
MyDelegate實現的「關鍵」是內部定義了一個能接受任意類型和參數個數的「萬能函數」:R (T::*m_f)(Args...),正是由于「可變模板參數」的特性,所以我們才能夠讓這個m_f接受任意參數。
總結
使用可變模板參數的這些技巧相信讀者看了會有耳目一新之感,使用可變模板參數的關鍵是如何展開參數包,展開參數包的過程是很精妙的,體現了泛化之美、遞歸之美,正是因為它具有神奇的「魔力」,所以我們可以更泛化的去處理問題,比如用它來消除重復的模板定義,用它來定義一個能接受任意參數的「萬能函數」等。其實,可變模板參數的作用遠不止文中列舉的那些作用,它還可以和其它C++11特性結合起來,比如type_traits、std::tuple等特性,發揮更加強大的威力。
我想后面我得系統學習一下C++模板元編程了,這破爛玩意兒太裝逼了,我好喜歡!

浙公網安備 33010602011771號