1.可变模板参数
在C语言中我们学习的第一个函数就是printf,这个函数有一个特点,即支持任意个参数,即可变参数。C++中引入了可变模板参数,我们可以在C++中利用模板函数实现像printf那样的功能。但众所周知C语言是没有模板函数的,所以C和C++在任意函数参数个数的实现上是有明显区别的,后面会提到。
要理解可变模板参数,我们首先要会分析一些符号,在其它语法中符号的含义都显而易见,但在可变模板参数这里却是个大难点,理解清楚这个,再结合一些递归思维,可变模板参数就很简单了
我们先把代码展示出来
#include <iostream>
using namespace std;
template<class T>
void Fun(T&& cur)
{
cout << "end" << endl;
}
template<class T, class...Args>
void Fun(T&& cur, Args&&...args)
{
cout << typeid(T).name() << endl;
Fun(args...);
}
int main()
{
Fun(1, 2, 3);
return 0;
}
结果是
第一时间看上去确实很懵,我会按顺序逐句讲解用法
(1)模板参数的含义
(2)对函数参数和调用函数的理解
(3)对终止条件的设定
理解清楚上述代码后,我们便可理解其中的递归思想,当我传任意个参数时,第一个参数会被单独抓出来实例化T,其余的参数放进参数包。当利用完T之后就丢了,再次调用这个函数时传递参数包,相比较上一次调用这个函数就少了一个参数(T被单独拿出来了),如此往复,就像给一个人装满东西的袋子,那个人只需要将袋子里面的东西一样一样拿出来,就能处理所有袋子里的东西。
但是思维严谨一点的就会想到,我们需要设定一个终止条件,当递归到最后调用Fun(1)时,1可以成功匹配,而参数包class...Args可以匹配0个参数,当再调用Fun()时就没有参数了,class T不是参数包,必须匹配一个参数,这个时候就会报错,所以我们要单独写一个Fun()来终止调用。
我们也可以像之前展示的写法那样,在还剩最后一个参数的时候就进行终止,也就是我们写一个单参数的模板函数在可变模板参数函数前面,当调用Fun(1)的时候直接调用那个终止的模板函数,就不多走一趟可变模板参数函数。
我们可以很明显地发现,前后两次int的数量不一样,后者是因为提前终止,在还剩一个参数的时候就终止递归调用了。
(4)终止函数的位置
我前面说过,终止函数要写在可变模板参数函数的前面,至少要在它前面声明,这是因为C/C++编译器在找函数的时候是从调用位置起向前找而不会向后找,在C语言中有的编译器会优化导致可以向后找普通函数,但在C++这里就没这么幸运了,因为编译模板本身就需要很大的性能开销,所以必须严格遵守向上查找的规则,所以任何函数一律不像后找。
编译器从调用位置向前找,找到最匹配的那一个,其中显然Args参数包参数个数为0的优先级就要低一些,优先匹配的是有一个模板参数的函数。
(5)...的其它应用方式
还有一些...的用法比较常见,下面会分析一下,顺便再次加深对这个符号的理解
sizeof...(arg)的特殊处理自然对应sizeof在此的特殊功能,我们稍微记一下就好。
我们只需要记住,当参数包args出现时一定要使用...,在主要重复部分的后面加上...,其余不需要深究,args可以作为整个函数参数包的名字,也可以作为每个函数参数的名字,很灵活。
2.编译时和运行时逻辑
要理解C和C++在实现可变参数上操作的不同,我们要引入编译时和运行时逻辑。
C语言中可变参数是利用类似数组实现可变参数(了解),在代码跑起来后依次解析每个参数,这个过程也可叫运行时逻辑。每个参数的类型,干什么都是在运行到该代码语句时确定的。这样有个缺点就是不安全,因为编译时、链接时都检查不出来错误,运行时可能程序就出问题了。
在C++中可变参数的实现依赖模板函数,编译时递归推导解析参数,但这是编译时逻辑。区别在于运行时允许可变参数当一个特殊的角色,编译时不报错,但在运行的时候才确定参数的类型、功能等。但编译时要求必须在编译的时候就完全展开,也就是说当程序运行起来的时候,模板的概念就完全消失了,也没有所谓的特殊代码,执行的都是已展开的普通的代码。
那么这两者的区别映射到我们的代码书写上又有什么不同呢?
编译的时候,编译器会对所有的可变模板参数展开处理,比如函数声明的是void Fun(Args...args),那么所有含有Args和args的语句都要进行处理,如sizeof...(args)会被直接转为int嵌入到代码中,Fun(args...)会被直接转为Fun(1, 2, 3, 4)这种普通形式。
但是这似乎并没有完全展现出编译时和运行时的区别。那我们看一下下面这段代码:
这段代码编译时报错了。但我们按照运行时逻辑来看这段代码是完全没有问题的。当最后调用Fun函数,即Fun(8)时,8被T占领,参数包的个数为0,此时应该走else语句,终止。
但是这里就出现了个最大的错误,if、for这种语句是典型的运行时逻辑,在程序运行起来了且当运行到该位置的时候才知道if条件是不是为真,for应该循环几次。在上面的代码中,我们依靠的是else来终止函数确实看上去没什么问题,但是我们要带入编译器的视角,当编译的时候编译器只管将sizeof...(args)换成数字,检查语法上有没有问题,至于if-else要实现什么东西编译器是完全管不着的,所以在编译器的视角上,我们并没有写终止函数,编译器找不到,最后一次匹配不了函数,于是就报错了。假设运行起来了,我们确实发现代码是能跑通的,因为不存在匹配不上的情况,但是编译器都报错了,谈论运行的逻辑毫无意义。
看看下面这段代码,再对这两种逻辑加深印象
其实我们仔细带入运行时逻辑就会发现,上面的那个void Fun()根本就没有任何用处,因为if(sizeof...(args))就决定了当无参时是会直接走else语句,直接返回的。但这只是运行时逻辑,按编译时逻辑来讲Fun()是必须存在的。编译器只管展开可变模板参数,Fun(args...)虽然在if内,但它不会进行任何判断,它只管不停地递归,找函数,当它递归到最后的Fun()时,匹配上我们所写的函数就停下来,找不到就报错。
3.emplace_back
emplace_back兼容部分push_back的用法,如emplace_back(1)和push_back(1)
但是emplace_back(底层是用可变模板参数实现的)还支持另外一种写法,当容器是一个pair<string, string>时,可以使用emplace_back("苹果", "apple")这种写法。"苹果", "apple"这两个参数会被emplace_back的参数包args接收,在emplace_back内部new出一个新的空间时,会直接使用类似new pair<string, string>(args...)将整个args传到构造函数那边去。在构造函数那边同样有个可变模板参数实现的构造函数,它将除pair<string, string>以外的成员变量都初始化后,再将参数包args传过去,构造出pair(vector<string, string>可没有专门为<string, string>设置的构造,而pair有,所以要单独设置一层可变模板参数实现的构造函数)。
emplace_back只需要走一层构造就行了,push_back则是构造+拷贝构造/移动拷贝,效率要高一些。但是emplace_back并不完全兼容push_back,如emplace_back( {"苹果", "apple"} )就会报错
这是因为参数包class...Args在接收的时候会按照initializer_list来接收,传递也是如此,最后会按照initializer_list传递给pair的构造函数
而pair不能使用initializer_list来构造,所以不兼容。
这里容易混的是为什么不触发隐式类型转换,我们要搞清楚调用构造函数本身就是一层{},调用又是一层initializer_list,第二层的initializer_list就需要看构造函数支不支持了
相比之下,push_back接收的值就是pair<string, string>,而不是什么参数包,因此push_back( {"苹果", "apple"} )触发隐式类型转换,能够正常构造。我们也可以将{"苹果", "apple"}同样理解为initializer_list,它去匹配构造函数里面的参数。我们也可发现将{"苹果", "apple"}传给pair<string, string>ret就相当于pair<string, string>ret = {"苹果", "apple"},会以一层initializer_list去匹配参数。注意这里的initializer_list是传给对象而不是函数(构造函数自带一层{}),所以才能触发隐式类型转换
emplace_back也要考虑移动拷贝的情况,所以每次转发前要使用forward保证属性正确,防止右值变左值。