可变参数模板的概念
可变参数模板是C++11新增的最强大的特性之一,它对参数高度泛化,能够让我们创建可以接受可变参数的函数模板和类模板。
在C++98/03中,类模板和函数模板中只能包含固定数量的模板参数,可变模板参数无疑是一个巨大的改进,但由于可变参数模板比较抽象,因此使用起来需要一定的技巧。
可变参数模板的定义方式
函数的可变参数模板定义方式如下:
template<class …Args>
返回类型 函数名(Args… args)
{
//函数体
}
我们来看一个具体实例:
template<class ...Args>
void ShowList(Args... args)
{}
分析:模板参数Args前面有省略号,代表它是一个可变模板参数,我们把带省略号的参数称为参数包,参数包里面可以包含0到N ( N ≥ 0 )个模板参数,而args则是一个函数形参参数包。
例如我们调用ShowList函数时就可以传入任意多个参数了,并且这些参数可以是不同类型的。
int main()
{
ShowList();
ShowList(1);
ShowList(1, 'A');
ShowList(1, 'A', string("hello"));
return 0;
}
我们可以在函数模板中通过sizeof计算参数包中参数的个数。
template<class ...Args>
void ShowList(Args... args)
{
cout << sizeof...(args) << endl; //获取参数包中参数的个数
}
但是我们需要注意的是语法上并不支持使用args[i]
的方式来获取参数包中的参数。我们只能通过展开参数包的方式来获取。
template<class ...Args>
void ShowList(Args... args)
{
//错误示例:
for (int i = 0; i < sizeof...(args); i++)
{
cout << args[i] << " "; //打印参数包中的每个参数
}
cout << endl;
}
参数包的展开方式
递归展开参数包
递归展开参数包的方式如下:
- 给函数模板增加一个模板参数,这样就可以从接收到的参数包中分离出一个参数出来。
- 在函数模板中递归调用该函数模板,调用时传入剩下的参数包。
- 如此递归下去,每次分离出参数包中的一个参数,直到参数包中的所有参数都被取出来。
比如我们要打印调用函数时传入的各个参数,那么函数模板可以这样编写:
//展开函数
template<class T, class ...Args>
void ShowList(T value, Args... args)
{
cout << value << " "; //打印分离出的第一个参数
ShowList(args...); //递归调用,将参数包继续向下传
}
为了能够结束函数的递归调用,我们再编写一个无参的递归终止函数,这样当传入的参数包中参数个数为0,那么就会匹配到无参的递归终止函数,从而结束了递归。
//递归终止函数
void ShowList()
{
cout << endl;
}
//展开函数
template<class T, class ...Args>
void ShowList(T value, Args... args)
{
cout << value << " "; //打印分离出的第一个参数
ShowList(args...); //递归调用,将参数包继续向下传
}
我们来看一个特殊场景:当外部调用ShowList函数时,如果我们没有传入参数的话,例如“ ShowList(); ”我们经过调试后会发现它直接匹配到无参的递归终止函数。而我们本意是想让外部调用ShowList函数时匹配的都是函数模板,而不是让外部调用时直接匹配到这个递归终止函数。因此我们可以将展开函数和递归调用函数的函数名改为ShowListArg,然后重新编写一个ShowList函数模板,这样我们无论有没有传入参数都会先匹配函数模板,然后函数模板根据参数的个数来调用相应的ShowListArg函数展开参数包(个数为0调用递归终止函数,个数大于0调用展开函数)。
//递归终止函数
void ShowListArg()
{
cout << endl;
}
//展开函数
template<class T, class ...Args>
void ShowListArg(T value, Args... args)
{
cout << value << " "; //打印传入的若干参数中的第一个参数
ShowListArg(args...); //将剩下参数继续向下传
}
template<class ...Args>
void ShowList(Args... args)
{
ShowListArg(args...);
}
当然我们也可以编写带参的递归终止函数来规避这个问题。
// 递归终止函数
template <class T>
void ShowList(const T& t)
{
cout << t << endl;
}
// 展开函数
template <class T, class ...Args>
void ShowList(T value, Args... args)
{
cout << value <<" ";
ShowList(args...);
}
int main()
{
ShowList(); //编译报错
ShowList(1);
ShowList(1, 'A');
return 0;
}
我们在调用ShowList函数时必须至少传入一个参数,否则就会报错。因为展开函数和递归终止函数都至少需要传入一个参数。
逗号表达式展开参数包
我们先来看一个场景:如果参数包中各个参数的类型都是整型,那么我们可以把这个参数包放到列表当中初始化这个整型数组,此时参数包中参数就放到数组中了。
//展开函数
template<class ...Args>
void ShowList(Args... args)
{
int arr[] = { args... }; //列表初始化
//打印参数包中的各个参数
for (auto e : arr)
{
cout << e << " ";
}
cout << endl;
}
这时调用ShowList函数时就可以传入多个整型参数
int main()
{
ShowList(1);
ShowList(1, 2);
ShowList(1, 2, 3);
return 0;
}
但是C++规定一个容器中存储的数据类型必须是相同的,因此如果这样写的话,那么调用ShowList函数时传入的参数只能是整型的,因此我们为了能够传入不同的类型,还需要在此基础上借助逗号表达式来展开参数包。
虽然我们不能用不同类型的参数去初始化一个整型数组,但我们可以借助逗号表达式。
- 逗号表达式会从左到右依次计算各个表达式,并且将最后一个表达式的值作为返回值进行返回。
- 将逗号表达式的最后一个表达式设置为一个整型值,确保逗号表达式返回的是一个整型值。
- 将处理参数包中参数的过程封装成一个函数,将该函数的调用作为逗号表达式的第一个表达式。
这样一来,在执行逗号表达式时就会先调用处理函数处理对应的参数,然后再将逗号表达式中的最后一个整型值作为返回值来初始化整型数组。
//处理参数包中的每个参数
template<class T>
void PrintArg(const T& t)
{
cout << t << " ";
}
//展开函数
template<class ...Args>
void ShowList(Args... args)
{
int arr[] = { (PrintArg(args), 0)... }; //列表初始化+逗号表达式
cout << endl;
}
注意:可变参数的省略号需要加在逗号表达式外面,表示需要将逗号表达式展开。代码中的
{(PrintArg(args), 0)...}
将会展开成{(PrintArg(arg1), 0), (PrintArg(arg2), 0), (PrintArg(arg3), 0), etc...}
。
这时调用ShowList函数时就可以传入多个不同类型的参数了。
STL容器中的emplace相关接口函数
C++11给STL中的容器增加emplace版本的插入接口,比如list容器的push_front、push_back和insert函数,都增加了对应的emplace_front、emplace_back和emplace函数。
这些emplace版本的插入接口支持模板的可变参数,比如list容器的emplace_back函数的声明如下:
注意: emplace系列接口的可变模板参数类型都带有“&&”,这个表示的是万能引用,而不是右值引用。
使用方法
emplace系列接口的使用方式与容器原有的插入接口的使用方式类似,但又有一些不同之处。
以list容器的emplace_back和push_back为例:
- 调用push_back函数插入元素时,可以传入左值对象或者右值对象,也可以使用列表进行初始化。
- 调用emplace_back函数插入元素时,也可以传入左值对象或者右值对象,但不可以使用列表进行初始化。
除此之外,emplace系列接口最大的特点就是,插入元素时可以传入用于构造元素的参数包。
int main()
{
list<pair<int, string>> lt;
pair<int, string> kv(10, "111");
lt.push_back(kv); //传左值
lt.push_back(pair<int, string>(20, "222")); //传右值
lt.push_back({ 30, "333" }); //列表初始化
lt.emplace_back(kv); //传左值
lt.emplace_back(pair<int, string>(40, "444")); //传右值
lt.emplace_back(50, "555"); //传参数包
return 0;
}
工作流程
- 先通过空间配置器为新结点获取一块内存空间,注意这里只会开辟空间,不会自动调用构造函数对这块空间进行初始化。
- 然后调用allocator_traits::construct函数对这块空间进行初始化,调用该函数时会传入这块空间的地址和用户传入的参数(需要经过完美转发)。
- 在allocator_traits::construct函数中会使用定位new表达式,显示调用构造函数对这块空间进行初始化,调用构造函数时会传入用户传入的参数(需要经过完美转发)。
- 将初始化好的新结点插入到对应的数据结构当中,比如list容器就是将新结点插入到底层的双链表中。
意义
由于emplace系列接口的可变模板参数的类型都是万能引用,因此既可以接收左值对象,也可以接收右值对象,还可以接收参数包。
- 如果调用emplace系列接口时传入的是左值对象,那么首先需要先在此之前调用构造函数实例化出一个左值对象,最终在使用定位new表达式调用构造函数对空间进行初始化时,会匹配到拷贝构造函数。
- 如果调用emplace系列接口时传入的是右值对象,那么就需要在此之前调用构造函数实例化出一个右值对象,最终在使用定位new表达式调用构造函数对空间进行初始化时,就会匹配到移动构造函数。
- 如果调用emplace系列接口时传入的是参数包,那就可以直接调用函数进行插入,并且最终在使用定位new表达式调用构造函数对空间进行初始化时,匹配到的是构造函数。
总结一下:
- 传入左值对象,需要调用构造函数+拷贝构造函数。
- 传入右值对象,需要调用构造函数+移动构造函数。
- 传入参数包,只需要调用构造函数。
emplace接口的意义:
- emplace系列接口最大的特点就是支持传入参数包,用这些参数包直接构造出对象,这样就能减少一次拷贝,这就是为什么有人说emplace系列接口更高效的原因。
- 但emplace系列接口并不是在所有场景下都比原有的插入接口高效,如果传入的是左值对象或右值对象,那么emplace系列接口的效率其实和原有的push_back插入接口的效率是一样的。
- emplace系列接口真正高效的情况是传入参数包的时候,直接通过参数包构造出对象,避免了额外的一次拷贝。
验证
我们先来编写一个简化版的string。
namespace a
{
class string
{
public:
//构造函数
string(const char* str = "")
{
cout << "string(const char* str) -- 构造函数" << endl;
_size = strlen(str);
_capacity = _size;
_str = new char[_capacity + 1];
strcpy(_str, str);
}
//交换两个对象的数据
void swap(string& s)
{
//调用库里的swap
::swap(_str, s._str);
::swap(_size, s._size);
::swap(_capacity, s._capacity);
}
//拷贝构造函数(现代写法)
string(const string& s)
:_str(nullptr)
, _size(0)
, _capacity(0)
{
cout << "string(const string& s) -- 拷贝构造" << endl;
string tmp(s._str);
swap(tmp);
}
//移动构造
string(string&& s)
:_str(nullptr)
, _size(0)
, _capacity(0)
{
cout << "string(string&& s) -- 移动构造" << endl;
swap(s);
}
//拷贝赋值函数(现代写法)
string& operator=(const string& s)
{
cout << "string& operator=(const string& s) -- 深拷贝" << endl;
string tmp(s);
swap(tmp);
return *this;
}
//移动赋值
string& operator=(string&& s)
{
cout << "string& operator=(string&& s) -- 移动赋值" << endl;
swap(s);
return *this;
}
//析构函数
~string()
{
//delete[] _str;
_str = nullptr;
_size = 0;
_capacity = 0;
}
private:
char* _str;
size_t _size;
size_t _capacity;
};
}
我们在string的构造函数、拷贝构造函数和移动构造函数当中均打印了一条提示语句,因此我们可以通过控制台输出来判断这些函数是否被调用。
下面我们用一个容器来存储模拟实现的string,并以不同的传参形式调用emplace系列函数。
int main()
{
list<pair<int, a::string>> lt;
pair<int, a::string> kv(1, "one");
lt.emplace_back(kv); //传左值
cout << endl;
lt.emplace_back(pair<int, a::string>(2, "two")); //传右值
cout << endl;
lt.emplace_back(3, "three"); //传参数包
return 0;
}
注意:我们在实现string的拷贝构造函数中复用了构造函数,因此在传左值的时候拷贝构造后面会再调用一次构造函数。