可变参数模板
- 可变参数模板概念
- 可变参数模板定义
- 参数包展开方式
- 递归展开参数包
- 逗号表达式展开参数包
- STL容器中的emplace相关接口函数
可变参数模板概念
C++11的新特性可变参数模板能够让您创建可以接受可变参数的函数模板和类模板,相比C++98/03,类模版和函数模版中只能含固定数量的模版参数,可变模版参数无疑是一个巨大的改进。
可变参数模板定义
下面就是一个基本可变参数的函数模板:其中,Args
是一个模板参数包,args
是一个函数形参参数包
声明一个参数包Args...args
,这个参数包中可以包含0到任意个模板参数。
template <class ...Args>
void ShowList(Args... args)
{}
上面的参数args前面有省略号,所以它就是一个可变模版参数,我们把带省略号的参数称为“参数包”,它里面包含了0到N(N>=0)个模版参数。我们无法直接获取参数包args中的每个参数的,只能通过展开参数包的方式来获取参数包中的每个参数,这是使用可变模版参数的一个主要特点,也是最大的难点,即如何展开可变模版参数。
接下来我们就可以调用ShowList函数,它的参数可以是任意参数类型:
int main()
{
ShowList(1);
ShowList(1, 2);
ShowList(1, 2, 'A');
ShowList(1, 2, '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 < args; i++)
{
//错误展开方式
cout << args[i] << endl;
}
}
参数包展开方式
递归展开参数包
递归展开参数包的方式如下:
- 给函数模板增加一个模板参数,这样就可以从接收到的参数包中分离出一个参数出来;
- 在函数模板中递归调用该函数模板,调用时传入剩下的参数包;
- 如此递归下去,每次分离出参数包中的一个参数,直到参数包中的所有参数都被取出来。
比如我们要打印调用函数时传入的各个参数,那么函数模板可以这样编写:
template <class T, class ...Args>
void ShowList(T val, Args... args)
{
cout << val << endl;//打印分离出来的第一个参数
ShowList(args...);//递归调用,将参数继续往下传
}
我们还需在刚才的基础上,再编写一个无参的递归终止函数,该函数的函数名与展开函数的函数名相同:
//递归终止函数
void ShowList()
{
cout << endl;
}
//展开函数
template <class T, class ...Args>
void ShowList(T val, Args... args)
{
cout << val << endl;//打印分离出来的第一个参数
ShowList(args...);//递归调用,将参数继续往下传
}
当递归调用ShowList函数模板时,如果传入的参数包中参数的个数为0,那么就会匹配到这个无参的递归终止函数,这样就结束了递归。
- 但如果外部调用ShowList函数时就没有传入参数,那么就会直接匹配到无参的递归终止函数。
- 而我们本意是想让外部调用ShowList函数时匹配的都是函数模板,并不是让外部调用时直接匹配到这个递归终止函数。
我们可以将展开函数和递归调用函数的函数名改为__ShowList,然后重新编写一个ShowList函数模板,该函数模板的函数体中要做的就是调用__ShowList函数展开参数包:
//递归终止函数
void __ShowList()
{
cout << endl;
}
//展开函数
template <class T, class ...Args>
void __ShowList(T val, Args... args)
{
cout << val << " ";//打印分离出来的第一个参数
__ShowList(args...);//递归调用,将参数继续往下传
}
//供外部调用的函数
template <class ...Args>
void ShowList(Args... args)
{
__ShowList(args...);
}
我们除了编写无参的递归终止函数,也可以编写带参数的递归终止函数来终止递归:
//递归终止函数
template<class T>
void __ShowList(const T& t)
{
cout << t << endl;
}
//展开函数
template <class T, class ...Args>
void __ShowList(T val, Args... args)
{
cout << val << " ";//打印分离出来的第一个参数
__ShowList(args...);//递归调用,将参数继续往下传
}
//供外部调用的函数
template <class ...Args>
void ShowList(Args... args)
{
__ShowList(args...);
}
这样一来,在递归调用过程中,如果传入的参数包中参数的个数为1,那么就会匹配到这个递归终止函数,这样也就结束了递归。但是需要注意,这里的递归调用函数需要写成函数模板,因为我们并不知道最后一个参数是什么类型的。该方法还有一个弊端就是,我们在调用ShowList函数时必须至少传入一个参数,否则就会报错。因为此时无论是调用递归终止函数还是展开函数,都需要至少传入一个参数。
逗号表达式展开参数包
通过列表获取参数包中的参数
我们所学习的数组就是可以通过列表进行初始化的:
int arr[] = {1, 2, 3, 4};
如果我们的参数包中的每个元素都是整形,我们就可以将这个参数包放到列表当中初始化这个整型数组,此时参数包中参数就放到数组中了:
template <class ...Args>
void ShowList(Args... args)
{
int arr[] = { args... };
for (auto e : arr)
{
cout << e << " ";
}
cout << endl;
}
int main()
{
ShowList(1);
ShowList(1, 2);
ShowList(1, 2, 3);
ShowList(1, 2, 3, 4);
return 0;
}
C++规定一个容器中存储的数据类型必须是相同的,因此如果这样写的话,那么调用ShowList函数时传入的参数只能是整型的,并且还不能传入0个参数,因为数组的大小不能为0,因此我们还需要在此基础上借助逗号表达式来展开参数包。
这种展开参数包的方式,不需要通过递归终止函数,是直接在expand
函数体中展开的, printarg不是一个递归终止函数,只是一个处理参数包中每一个参数的函数。这种就地展开参数包的方式实现的关键是逗号表达式。我们知道逗号表达式会按顺序执行逗号前面的表达式。expand
函数中的逗号表达式:(printarg(args), 0)
,也是按照这个执行顺序,先执行printarg(args)
,再得到逗号表达式的结果0。同时还用到了C++11的另外一个特性——初始化列表,通过初始化列表来初始化一个变长数组, {(printarg(args), 0)...}
将会展开成((printarg(arg1),0), (printarg(arg2),0), (printarg(arg3),0), etc... )
,最终会创建一个元素值都为0的数组int arr[sizeof...(Args)]
。由于是逗号表达式,在创建数组的过程中会先执行逗号表达式前面的部分printarg(args)
打印出参数,也就是说在构造int数组的过程中就将参数包展开了,这个数组的目的纯粹是为了在数组构造的过程展开参数包。
template <class T>
void PrintArg(T t)
{
cout << t << " ";
}
//展开函数
template <class ...Args>
void ShowList(Args... args)
{
int arr[] = { (PrintArg(args), 0)... };
cout << endl;
}
int main()
{
ShowList(1);
ShowList(1, 'A');
ShowList(1, 'A', std::string("sort"));
return 0;
}
STL容器中的emplace相关接口函数
C++11标准给STL中的容器增加emplace版本的插入接口,我们以list为例:
首先我们看到的emplace系列的接口,支持模板的可变参数,并且万能引用。那么相对insert和emplace系列接口的优势到底在哪里呢?
emplace系列接口的使用方式与容器原有的插入接口的使用方式类似,但又有一些不同之处。
以list容器的emplace_back和push_back为例:
- 调用push_back函数插入元素时,可以传入左值对象或者右值对象,也可以使用列表进行初始化。
- 调用emplace_back函数插入元素时,也可以传入左值对象或者右值对象,但不可以使用列表进行初始化。
- 除此之外,emplace系列接口最大的特点就是,插入元素时可以传入用于构造元素的参数包。
int main()
{
list<pair<int, string>> mylist;
pair<int, string> kv(10, "111");
mylist.push_back(kv); //传左值
mylist.push_back(pair<int, string>(20, "222")); //传右值
mylist.push_back({ 30, "333" }); //列表初始化
mylist.emplace_back(kv); //传左值
mylist.emplace_back(pair<int, string>(40, "444")); //传右值
mylist.emplace_back(50, "555"); //传参数包
return 0;
}
emplace系列接口的工作流程
emplace系列接口的工作流程如下:
- 先通过空间配置器为新结点获取一块内存空间,注意这里只会开辟空间,不会自动调用构造函数对这块空间进行初始化。
- 然后调用allocator_traits::construct函数对这块空间进行初始化,调用该函数时会传入这块空间的地址和用户传入的参数(需要经过完美转发)。
- 在allocator_traits::construct函数中会使用定位new表达式,显示调用构造函数对这块空间进行初始化,调用构造函数时会传入用户传入的参数(需要经过完美转发)。
- 将初始化好的新结点插入到对应的数据结构当中,比如list容器就是将新结点插入到底层的双链表中。
emplace系列接口的意义
由于emplace系列接口的可变模板参数的类型都是万能引用,因此既可以接收左值对象,也可以接收右值对象,还可以接收参数包。
- 如果调用emplace系列接口时传入的是左值对象,那么首先需要先在此之前调用构造函数实例化出一个左值对象,最终在使用定位new表达式调用构造函数对空间进行初始化时,会匹配到拷贝构造函数。
- 如果调用emplace系列接口时传入的是右值对象,那么就需要在此之前调用构造函数实例化出一个右值对象,最终在使用定位new表达式调用构造函数对空间进行初始化时,就会匹配到移动构造函数。
- 如果调用emplace系列接口时传入的是参数包,那就可以直接调用函数进行插入,并且最终在使用定位new表达式调用构造函数对空间进行初始化时,匹配到的是构造函数。
整体来说,emplace只有在传入参数包的过程中会很好的提高效率,对于左值引用和右值引用跟insert相比并没有什么太大的区别。
验证:
namespace gtt
{
class string
{
public:
typedef char* iterator;
iterator begin()
{
return _str;
}
iterator end()
{
return _str + _size;
}
string(const char* str = "")
:_size(strlen(str))
, _capacity(_size)
{
//cout << "string(char* str)" << endl;
_str = new char[_capacity + 1];
strcpy(_str, str);
}
// s1.swap(s2)
void swap(string& s)
{
::swap(_str, s._str);
::swap(_size, s._size);
::swap(_capacity, s._capacity);
}
// 拷贝构造
string(const string& s)
:_str(nullptr)
{
cout << "string(const string& s) -- 深拷贝" << endl;
string tmp(s._str);
swap(tmp);
}
// 赋值重载
string& operator=(const string& s)
{
cout << "string& operator=(string s) -- 深拷贝" << endl;
string tmp(s);
swap(tmp);
return *this;
}
// 移动构造
string(string&& s)
:_str(nullptr)
, _size(0)
, _capacity(0)
{
cout << "string(string&& s) -- 移动语义" << endl;
swap(s);
}
// 移动赋值
string& operator=(string&& s)
{
cout << "string& operator=(string&& s) -- 移动语义" << endl;
swap(s);
return *this;
}
~string()
{
delete[] _str;
_str = nullptr;
}
char& operator[](size_t pos)
{
assert(pos < _size);
return _str[pos];
}
void reserve(size_t n)
{
if (n > _capacity)
{
char* tmp = new char[n + 1];
strcpy(tmp, _str);
delete[] _str;
_str = tmp;
_capacity = n;
}
}
void push_back(char ch)
{
if (_size >= _capacity)
{
size_t newcapacity = _capacity == 0 ? 4 : _capacity * 2;
reserve(newcapacity);
}
_str[_size] = ch;
++_size;
_str[_size] = '\0';
}
string& operator+=(char ch)
{
push_back(ch);
return *this;
}
const char* c_str() const
{
return _str;
}
private:
char* _str;
size_t _size;
size_t _capacity; // 不包含最后做标识的\0
};
}
int main()
{
list<pair<int, gtt::string>> mylist;
pair<int, gtt::string> kv(10, "111");
mylist.push_back(kv); //传左值
mylist.push_back(pair<int, gtt::string>(20, "222")); //传右值
mylist.push_back({ 30, "333" }); //列表初始化
cout << endl;
mylist.emplace_back(kv); //传左值
mylist.emplace_back(pair<int, gtt::string>(40, "444")); //传右值
mylist.emplace_back(50, "555"); //传参数包
return 0;
}
运行叫我们就会发现,传参数包时就会少调用一次构造函数: