【C++11】深入解析C++可变参数模板

可变参数模板

  • 一、基本语法与原理
  • 二、包扩展
    • 1. 参数包展开(一)——递归展开
    • 2. 参数包展开(二)——利用函数返回值
    • 3. 参数包展开(三)——初始化列表
  • 三、emplace
    • 情况一:左值
    • 情况二:右值
    • 情况三:直接传参
    • 情况四:多参数类型场景

在之前,如果我们想要实现一个函数可以接收不同类型的参数,我们可以通过模板来实现;那如果想要它接收不同数量的参数怎么办呢?在以前我们可以通过函数重载来实现,但是这种方法非常麻烦,要写多个函数。于是,C++11引入了可变参数模板。

一、基本语法与原理

可变参数模板,顾名思义,就是支持可变数量的函数模板或类模板,即可以传不同数量的参数,可变数量的参数被称为参数包,参数包分为:模板参数包和函数参数包。它们都能支持传入零到多个函数参数。
经典形式:

template <class ...Args> void Func(Args... args) {} //左值引用 template <class ...Args> void Func(Args&... args) {} //万能引用 template <class ...Args> void Func(Args&&... args) {}
  • 我们用省略号来指出⼀个模板参数或函数参数的表示一个包,在模板参数列表中,class...typename...指出接下来的参数表示零或多个类型列表;在函数参数列表中,类型名后面跟…指出接下来表示零或多个形参对象列表;函数参数包可以用左值引用或右值引用表示,跟前面普通模板一样,每个参数实例化时遵循引用折叠规则。
  • 上面的Args不是一个类型,是一组类型。
  • 可变参数模板的原理跟模板类似,本质还是去实例化对应类型和个数的多个函数。
  • 我们可以使用运算符sizeof...去获取参数包中参数的个数
template <class ...Args> void Print(Args&&... args) { cout << sizeof...(args) << endl; } int main() { double x = 2.2; Print(); // 包⾥有0个参数 Print(1); // 包⾥有1个参数 Print(1, string("xxxxx")); // 包⾥有2个参数 Print(1.1, string("xxxxx"), x); // 包⾥有3个参数 return 0; }

可变参数模板是在编译时就确定了参数的类型,上面的调用会结合引用折叠规则实例化出以下四个函数:

void Print(); void Print(int&& arg1); void Print(int&& arg1, string&& arg2); void Print(double&& arg1, string&& arg2, double& arg3);

二、包扩展

当我们传入参数包时。我们是无法直接去使用其中的变量的,此时我们就引出包扩展以使用包中的参数

1. 参数包展开(一)——递归展开

void ShowList() { // 编译器时递归的终⽌条件,参数包是0个时,直接匹配这个函数 cout << endl; } template <class T, class ...Args> void ShowList(T x, Args... args) { cout << x << " "; // args是N个参数的参数包 // 调⽤ShowList,参数包的第⼀个传给x,剩下N-1传给第⼆个参数包 ShowList(args...); } // 编译时递归推导解析参数 template <class ...Args> void Print(Args... args) { ShowList(args...); }

我们来看看它的递归展开图:

再来看一段代码:

template <class T, class ...Args> void ShowList(T x, Args... args) { if(sizeof...(args)==0) return; cout << x << " "; // args是N个参数的参数包 // 调⽤ShowList,参数包的第⼀个传给x,剩下N-1传给第⼆个参数包 ShowList(args...); } // 编译时递归推导解析参数 template <class ...Args> void Print(Args... args) { ShowList(args...); }

这是一个典型的错误,可变参数模板的参数匹配是在编译时进行的,而ShowList中的if语句是在参数确定之后才执行的,是运行时逻辑,当args是零个参数时,无法匹配到函数。

2. 参数包展开(二)——利用函数返回值

template <class T> const T& GetArg(const T& x) { cout << x << " "; return x; } template <class ...Args> void Arguments(Args... args) {} template <class ...Args> void Print(Args... args) { // 注意GetArg必须返回获得到的对象,这样才能组成参数包给Arguments Arguments(GetArg(args)...); } int main() { Print(1, 2.2, string("hello")); return 0; }

当我们调用Print时,传入的args<int, double, const char*>Print...GetArg(args)后面,因此展开后得到

Arguments( GetArg(1), GetArg(2.5), GetArg("hello") );

然后它就会依次调用GetArgs依次输出。
结果:

我们看到:结果好像反了一下。这是为什么呢?其实像上面的Arguments函数这样的参数为函数返回值的函数,编译器并没有准确规定一定要先执行谁,后执行谁,这全取决于编译器的实现。
如果我们想要正序的输出,可以用第三种初始化列表的方法。

3. 参数包展开(三)——初始化列表

template<class... Args> void Print(Args... args) { int arr[] = { (cout << args << ' ',0)... }; cout << endl; } int main() { Print(1, 2.2, string("hello")); return 0; }

我们借助了一个数组来依次执行输出,它的值是一个逗号表达式,表达式会先执行第一个参数cout,然后再返回第二个参数0,刚好让数组接收,...前面就是这个表达式,因此将其展开后得到:

int arr[] = { (cout << 1 << ' ', 0), (cout << 2.2 << ' ', 0), (cout << string("hello") << ' ', 0) };

随后arr的值就是[0,0,0]
这样就能保证编译器依次执行了。

三、emplace

我们以listemplace_back为例来学习emplace系列接口。
先看定义:

可以看到,list::emplace_back是用万能引用和可变参数模板来实现的。
同样都是插入元素,emplace_backpush_back有什么区别呢?


情况一:左值

int main() { list<zzt::string> lt; zzt::string s1 = "1111111111"; cout << "********************************" << endl; lt.push_back(s1); cout << "********************************" << endl; lt.emplace_back(s1); return 0; }

输出:

可见,传左值时,两者没有任何区别。

情况二:右值

int main() { list<zzt::string> lt; zzt::string s1 = "1111111111"; zzt::string s2 = "1111111111"; cout << "********************************" << endl; lt.push_back(move(s1)); cout << "********************************" << endl; lt.emplace_back(move(s2)); return 0; }

输出:

可见,传右值时,两者依旧完全相同。

情况三:直接传参

int main() { list<zzt::string> lt; lt.push_back("1111111111"); cout << "********************************" << endl; lt.emplace_back("1111111111"); }

输出:

此时就不一样了,push_back构造+移动构造emplace_back直接构造
我们先来看看两者声明的区别:
push_back:

void push_back (const value_type& val); void push_back (value_type&& val);

emplace_back:

template <class... Args> void emplace_back (Args&&... args);

对于push_back"1111111111"const char*类型,当我们传入"1111111111"时,由于push_back中的value_type在定义list<zzt::string> lt时就已经确定为zzt::string了,因此,编译器会先构造一个zzt::string类型的临时对象,再移动构造val
对于emplace_back,它的参数类型是在编译时展开确定的,因此传入const char*,它的参数类型就是const char*,然后再进行一系列传递,直至传到构造函数,直接构造

实际上,两者效率几乎相同,因为移动构造的开销非常非常小。

情况四:多参数类型场景

就以pair为例:

int main() { list<pair<zzt::string, int>> lt; lt.push_back({ "苹果",1 }); cout<< "********************************" <<endl; lt.emplace_back("苹果", 1); return 0; }

输出:

这个也是不同的,原理和上面的差不多。
对于push_back,它只能通过花括号传参,不能直接像emplace_back一样通过多个参数来传参。
对于emplace_back,它只能通过多个参数传参,不能使用花括号,因为可变参数模板是在编译时确定类型的,传入一个花括号,它不知道一个花括号代表什么,无法推出参数类型。


总结:部分场景下,emplace可以直接构造,push和insert是构造+移动构造或构造+拷贝构造,在效率方面emplace略胜一筹,emplace综合而言更好用、更强大,因此使用时推荐用emplace替代push和insert。