持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第12天,点击查看活动详情
概述
C++11的新特性--可变模版参数(variadic templates)是C++11新增的最强大的特性之一,它对参数进行了高度泛化,它能表示0到任意个数、任意类型的参数。相比C++98/03,类模版和函数模版中只能含固定数量的模版参数,可变模版参数无疑是一个巨大的改进。然而由于可变模版参数比较抽象,使用起来需要一定的技巧,所以它也是C++11中最难理解和掌握的特性之一。虽然掌握可变模版参数有一定难度,但是它却是C++11中最有意思的一个特性,本文希望带领读者由浅入深的认识和掌握这一特性,同时也会通过一些实例来展示可变参数模版的一些用法。
可变参数模板
可变参数模板和普通模板的语义是一样的,只是写法上稍有区别,声明可变参数模板时需要在typename
或class
后面带上省略号...
:
template<class ... Args>
复制代码
有两个比较重要的概念如下:
- 模板参数包:使用
typename/class ... Args
来指出 Args 是一个模板参数包,其中可能包含了零个或多个模板参数(注意包含0个); - 函数参数包:使用
Args ... rest
来指出 rest 是一个函数参数包,其中可能包含了零个或多个函数参数;
看下面一个简单例子:
#include <iostream>
template <class ... Args>
void Print(Args ... rest) {
std::cout << sizeof...(rest) << std::endl;
}
int main() {
Print();
Print(1);
Print(1, "ab");
Print(1.0, "23", 4);
return 0;
}
复制代码
上面的程序,分别调用了Print的四个重载函数,第一个参数包中参数个数为0,第二个为1,第三个为2,最后一个参数是4个。所以在输出的时候结果分别为0,1,2,3。编译器会为 Print 实例化出以下四个不同版本,我们看下上面程序的汇编代码:
push rbp
mov rbp, rsp
call void Print<>() # 1
mov edi, 1
call void Print<int>(int) # 2
mov esi, OFFSET FLAT:.LC0
mov edi, 1
call void Print<int, char const*>(int, char const*) # 3
mov rax, QWORD PTR .LC2[rip]
mov esi, 4
mov edi, OFFSET FLAT:.LC1
movq xmm0, rax
call void Print<double, char const*, int>(double, char const*, int) # 4
mov eax, 0
pop rbp
复制代码
另外,如果需要不包含0个的变长参数模板,则可以采用以下的定义:
template<class Head, class ... Tail>
复制代码
本质上,...
可接纳的模板参数个数仍然是0个及以上的任意数量,但由于多了一个Head类型,由此该模板可以接纳1个及其以上的模板参数。
参数包展开方式
参数包的展开方式有两种,分别是递归函数方式展开、逗号表达式和函数初始化列表方式展开。
递归扩展
扩展一个参数包最常见的方法是递归。既然用到了递归,自然就免不了递归体和递归终止条件。例如:
#include <iostream>
// 递归终止条件
void Print() {
std::cout << "Print()" << std::endl;
}
// 递归体
template<typename T, typename ... Args>
void Print (T first, Args ... rest)
{
std::cout << "arg: " << first << std::endl;
Print(rest...);
}
int main(void){
Print(1, 2, 3);
return 0;
}
复制代码
在递归体函数中,我们将函数参数包的首个元素打印出来,然后利用剩余参数调用自身,直到最后当参数包为空时,调用非可变参数版本的 Print 函数退出递归。
注意:在使用递归方式进行包扩展时,将非可变参数版本 (递归终止条件) 必须要声明在可变参数版本 (递归体) 的作用域当中,否则会导致无限递归!!
这是因为当非可变参数版本的函数声明在可变版本的作用域中时,在执行递归终止条件时会进行函数匹配,根据特例化原则会优先考虑调用可变参数版本。若将非可变参数版本的声明放到可变参数版本的作用域之外,则在执行递归终止条件时只会匹配到可变参数版本,从而造成无限递归。
递归终止模板类可以有多种写法,比如上例的递归终止模板类还可以这样写:
#include <iostream>
// 递归终止条件
template<typename T>
void Print(T t) {
std::cout << "Print(): " << t << std::endl;
}
// 递归体
template<typename T, typename ... Args>
void Print (T first, Args ... rest)
{
std::cout << "arg: " << first << std::endl;
Print<T>(rest...);
}
int main(void){
Print(1, 2, 3);
return 0;
}
复制代码
输出:
arg: 1
arg: 2
Print(): 3
复制代码
对比第一个程序少了一个函数调用,第一个程序输出:
arg: 1
arg: 2
arg: 3
Print()
复制代码
逗号表达式扩展
包扩展的第二种方法则是借助逗号表达式和初始化列表来实现。还是以前面的 Print 函数为例,使用逗号表达式和初始化列表来实现:
#include <iostream>
template<typename T>
void Print(T t) {
std::cout << "end: " << t << std::endl;
}
template<typename ... Args>
void Print (Args ... args)
{
//逗号表达式+初始化列表
int arr[] = { (Print(args), 0)... };
}
int main(void){
Print(1, 2, 3);
return 0;
}
复制代码
展开的代码依次:
(Print(1), 0) // 逗号表达式
(Print(2), 0) // 逗号表达式
(Print(3), 0) // 逗号表达式
复制代码
可以看到,每个元素都是一个逗号表达式,以 (Print(1), 0) 为例,它会先计算 Print(1),然后将 0 作为整个表达式的值返回给数组,因此 arr 数组最终存储的都是 0。arr 数组纯粹是为了将参数包展开,没有发挥其它作用。
注意:使用借助初始化列表来实现的方式,虽然可以定义一个接受可变数目实参的函数,但这些参数必须具有同一类型。
遐(假/瞎)想
介绍到这里,可能有些人觉得不管是递归方式还是逗号表达式方式展开,可变参数模板它总是需要定义2次,有没有更优雅的办法呢?C++11引入了sizeof...
操作符,可以得到可变参数的个数,当然sizeof...
的参数只能是parameter pack,不能是其它类型的参数。会不会有人这样想了,如果通过sizeof...
判断出这个parameter pack的个数为零了,那就退出递归调用不好吗?就像下面这样:
#include <iostream>
//void Print() {
// std::cout << "Print()" << std::endl;
//}
// 递归体
template<typename T, typename ... Args>
void Print (T first, Args ... rest)
{
std::cout << "arg: " << first << std::endl;
if(sizeof...(rest) > 0) { // 增加这个判断
Print(rest...);
}
}
int main(void){
Print(1, 2, 3);
return 0;
}
复制代码
你看,Print只定义了1次,我们在函数体里使用了sizeof...
,如果parameter pack为零了,我们就停止递归调用。但不幸的是,编译程序报错:
error: no matching function for call to 'syszuxPrint()'
复制代码
为什么呢?为啥sizeof...
if条件表达式没有生效?还在试图调用空的Print?这是因为,可变参数模板Print的所有分支都被实例化了,并不会考虑上面那个if表达式。编译期间,编译器发现是可变参数包就会实例化所有的函数,包括参数包为0的情况,所以针对上面语句Print(rest...)
会进行如下特例化:
void Print<int, int, int>(int, int, int);
void Print<int, int>(int, int);
void Print<int>(int);
void Print();
复制代码
一个实例化的代码是否有用,是在runtime时决定的,而所有的实例化过程是在编译时决定的。所以Print()空参数版本照样被实例化出来了,而当Print()被实例化的时候并没有发现对它应的实现,于是编译期报错。
两种方法的优缺点
-
递归包扩展方式:
优点:实现更加灵活,我们可以针对递归终止条件进行不同于递归体函数的操作。
缺点:
- 递归函数会反复压栈弹栈,因此运行时会消耗更多资源;
- 若递归终止条件没有声明在递归体的作用域内,则会导致无限循环(不过所幸的是编译器可以检查出这样的问题)。
-
逗号表达式扩展方式:
优点:执行的效率高于递归的方式;
缺点:
- 只能适用于对参数包中的每一个参数都执行相同操作的场景;
- 浪费了一部分的内存空间,构造出来的初始化列表没有任何作用。
总结
使用可变模版参数的这些技巧相信你们看了会有耳目一新的感觉,使用可变模版参数的关键是如何展开参数包。展开参数包的过程很是精妙且多样化,体现了泛化之美、递归之美,当然可变模版参数的作用远不止文中列举的那些作用,其他的需要你们慢慢挖掘了。下一章我们介绍可变参数类模板中的应用。