一.前言
C++11引入了可变模板参数(variadic template parameters)的概念,它允许我们在模板定义中使用可变数量的参数。这样,我们就可以处理任意数量的参数,而不仅限于固定数量的参数。
二.可变模板参数
我们早在C语言的学习过程中就接触过可变参数:printf和scanf都是可变参数的函数
他们的参数可以是1个,也可以是多个。其底层使用了一个二维数组将所有的参数存储起来。
对于之前的模板来说,都是固定参数个数的模板。C++11引入了可变参数的模板,即不仅参数的类型是不确定的,个数也是不确定的。支持可变参数的函数模板和类模板,可变数目的参数被称为参数包,而参数包又分为两种:模板参数包和函数参数包。
模板参数包:表示0~n个模板参数;函数参数包:表示0~n个函数参数。
template <typename ...Args> // 模板参数包
void Func(Args ...args) // 函数参数包
{}
// 左值引用
template <typename ...Args> // 模板参数包
void Func(Args& ...args) // 函数参数包
{}
// 万能引用——引用折叠
template <typename ...Args> // 模板参数包
void Func(Args&& ...args) // 函数参数包
{}
可变参数模板的语法规范是在定义模板参数列表的时候typename/class后面跟三个点...,然后写上模板参数名,这个名字是可以随便写的,但是为了规范使用Args更好一些。在定义函数形参表的时候类型名后面跟三个点...然后加上对象名。
函数参数包可以传值,传左值引用或者右值引用,跟普通的模板一样,这里也遵循引用折叠规则。
template <typename ...Args>
void Print(Args...args)
{
cout << sizeof...(args) << endl;
}
int main()
{
Print();
Print(1);
Print(1,3.14);
Print(1,"1111111111",3,14);
return 0;
}
对于上面这个Print函数,它支持可变模板参数,我们可以传0~n任意不同的参数。sizeof...可以计算参数包中的参数个数
那么我们在调用Print函数的时候到底发生了什么呢?
编译器首先会根据我们调用该函数的实参的个数,生成有固定参数的函数模板,然后再根据实参的类型,实例化这些函数模板。
我们也可以反过来想一下:
阶段一:
当没有模板语法的时候,我们想要实现可变参数的Print函数时,就要写多个函数,0个、1个、2个,n个,再根据实参类型不同,1个参数的函数也要写多个以适应不同的实参,2个、3个、n个的也一样。就很烦~
阶段二:
当模板出来之后,我们不再需要写那些含有相同参数个数但参数类型不同的函数了,直接一个模板就可以了。这样对于1个参数的函数模板来说,任意类型的参数都可以传了,方便了很多。但是因为参数个数的不同,我们还是需要写很多个参数不同的函数模板。还是很烦~
阶段三:
当可变模板参数出来了之后,我们也不再需要写多个参数个数不同的函数模板了,直接写一个可变参数的函数模板,他既解决了参数个数的不同,也解决了参数类型的不同。
而且,实参传的是左值还是右值也会影响实际生成的函数的样子,所以可变模板参数确实很方便。
虽然我们是这样分析可变模板参数调用的过程,但是在编译的过程中,编译器可能就不会有中间这步生成固定参数的函数模板,而是直接一步到位生成最终要调用的函数。
三.包扩展
对于一个参数包来说,我们除了计算它的参数个数之外,更多的情况下我们要去使用它,那么我们要怎么使用呢?我们能否在该函数内部直接使用for循环遍历该参数包呢?
当然是不行的了.
下面我们介绍两种包扩展的方式:
1.编译时递归包扩展
void ShowList()
{
cout << endl;
}
template<typename T, typename...Args>
void ShowList(T&& x, Args&&...args)
{
cout << x << endl;
ShowList(args...);
}
template <typename ...Args>
void Print(Args&&...args)
{
ShowList(args...);
}
我们通过这两个ShowList函数来实现包扩展。当我们在主函数调用Print函数的时候,实参会形成一个参数包,传给Print函数,Print函数内部无法直接解析包的内容,我们将参数包传给另一个函数,该函数也是一个函数模板,他会把参数包进行拆分:提取出参数包的第一个参数,然后打印该参数,然后自己调用自己将其他的参数作为参数包继续进行拆分。当参数包只剩一个参数的时候,T获取该参数,参数包变成空包,此时就会匹配那么无参数的ShowList,此时包扩展的过程完成。
由上述过程可以看出来,无参的ShowList起到了终止条件的作用。
该过程底层就类似于下图这个过程:
我们在调用Print函数的时候,编译器就会生成右边这几个函数。
那么我们是否可以将这个结束条件写道有参数的ShowList内部呢?
不行!
我们需要注意,我们上述包扩展的过程是编译时完成的,编译阶段,编译器对参数进行解析,生成若干个重载函数,实际上我们是调用那些重载函数来完成操作的。
而上图中if判断则是运行时逻辑,在程序运行的过程中,当参数包为空,递归结束。但是实际上,当参数包为空后,此时ShowList调用不到该函数本身,因为该函数本身必须接受一个参数T。
2. 通过返回值做函数参数包扩展
template<typename T>
const T& getArg(T&& x)
{
cout << x << endl;
return x;
}
template<typename...Args>
void Arguments(Args...args)
{}
template <typename ...Args>
void Print(Args&&...args)
{
Arguments(getArg(args)...);
}
上面这种办法,我们在Print函数内部调用Arguments函数,但是调用该函数我们就得有实参,所以程序会先求该函数的实参,而实参是通过getArg函数解析参数包来获取的,所以我们也可以通过这种方式进行包扩展。
因为参数传参是从右往左的,所以这样解析参数包是反着解析的
四.emplace系列接口
1.emplace和push系列的对比
C++11引入了可变模板参数之后,STL的容器都引入emplace这一系列的接口。该系列的接口使用了可变模板参数,可以传任意类型和任意数量的参数。
那么emplace_back和push_back有什么区别呢? 我们接下来根据插入数据的类别来进行对比:
插入左值,push_back和empalce_back一样,都是拷贝构造。
插入左值push_back和emplace_back会进行引用折叠,最后都会调用左值引用版本,最后在函数内部,会new一个新节点,此时传给data的是一个左值引用,而data的类型是string,所以调用string的拷贝构造
插入右值,push_back和emplace_back一样,都调用移动构造。
插入右值,不会发生引用折叠,就会匹配对应的右值引用版本,在该函数内部,new一个新节点的时候,传给data的是一个右值,就是匹配data的移动构造
传匿名对象,push_back走隐式类型转换,先构造出临时对象,在函数内部引用该临时对象的调用移动构造;而emplace_back直接构造
emplace_back之所以会直接构造,是因为emplace_back的参数类型是不确定的,只有当传参的时候才知道是const char*,所以在函数内部会直接调用string的构造函数来生成对象,list节点存储该对象。
当插入的是多参数的时候,规律和上面是一样的:
当插入左值时,两者都是拷贝构造;插入右值时,两者都是移动构造;插入匿名对象的时候,emplace_back是直接构造,而push_back是构造+移动构造
综上,在大部分情况下,push_back和emplace_back效率是一样的。但是在有些情况下,emplace_back直接构造,而push_back需要构造+移动构造(深拷贝的类型)或者构造+拷贝构造(浅拷贝的类型)。虽然移动构造的消耗很小,但毕竟有消耗;emplace_back和push_back真正的差异在于浅拷贝的类型,浅拷贝的类型没有移动构造,只能拷贝构造。
总的来说,emplace_back的效率整体上还是优于push_back的,所以以后用emplace系列的接口来替代push系列和insert系列的接口。
2.emplace的模拟实现
emplace_back就是可变模板参数类型的函数。但是在函数内部,我们没有必要对参数包进行扩展,我们并不在函数内使用参数,所以我们直接向下一层传递包即可。
template<typename...Args>
void emplace_back(Args...args)
{
insert(end(), std::forward<Args>(args)...); // 直接向下转递参数包
}
完~