可变参数模版
一、可变参数模版的概念
可变参数模板是C++11新增的最强大的特性之一,它对参数高度泛化,能够让我们创建可以接受可变参数的函数模板和类模板。
- 在C++11之前,类模板和函数模板中只能包含固定数量的模板参数,可变模板参数无疑是一个巨大的改进,但由于可变参数模板比较抽象,因此使用起来需要一定的技巧。
- 在C++11之前其实也有可变参数的概念,比如printf函数就能够接收任意多个参数,但这是函数参数的可变参数,并不是模板的可变参数。
二、可变参数模板的定义方式
函数的可变参数模板定义方式如下:
template<class …Args>
返回类型 函数名(Args… args)
{
//函数体
}
template<class ...Args>
返回类型 函数名(Args... args)
{
// 函数体
}
我们使用如下:
template<class ...Args>
void showlist(Args... args)
{}
说明一下:
- 模板参数Args前面有省略号,代表它是一个可变模板参数,我们把带省略号的参数称为参数包,参数包里面可以包含0到N ( N ≥ 0 ) 个模板参数,而args则是一个函数形参参数包。
- 模板参数包Args和函数形参参数包args的名字可以任意指定,并不是说必须叫做Args和args。
现在调用showlist函数时就可以传入任意多个参数了,并且这些参数可以是不同类型的。
#include<iostream>
#include<string>
using namespace std;
template<class ...Args>
void showlist(Args... args)
{}
int main()
{
showlist();
showlist(1);
showlist(1, 'a');
showlist(1, 'a', string("1234"));
return 0;
}
我们可以在函数模板中通过sizeof计算参数包中参数的个数:
template<class ...Args>
void showlist(Args... args)
{
std::cout << sizeof...(args) << std::endl;
}
特别注意,语法并不支持使用args[i]的方式来获取参数包中的参数。
template<class ...Args>
void showlist(Args... args)
{
// 错误例子
for (int i = 0; i < sizeof...(args); i++)
{
std::cout << args[i] << " "; // 这里不能调用args[i]
}
std::endl;
}
因此要获取参数包中的各个参数,只能通过展开参数包的方式来获取,一般我们会通过递归或逗号表达式来展开参数包。
三、参数包的展开方式
1、递归展开法
- 给函数模板增加一个模板参数,这样就可以从接收到的参数包中分离出一个参数出来。
- 在函数模板中递归调用该函数模板,调用时传入剩下的参数包。
- 如此递归下去,每次分离出参数包中的一个参数,直到参数包中的所有参数都被取出来。
// 展开函数
template<class T, class ...Args>
void showlist(T value, Args... args)
{
std::cout << value << " "; // 打印分离出的第一个参数
showlist(args...); // 递归调用,将参数包继续向下传
}
但就有一个问题了,这个递归结束的出口是什么,也就是怎么样才能退出递归呢?
(1)无参的递归终止函数
我们可以在刚才的基础上,再编写一个无参的递归终止函数,该函数的函数名与展开函数的函数名相同。如下:
// 无参的递归终止函数
void showlist()
{
std::cout << std::endl;
}
// 展开函数
template<class T, class ...Args>
void showlist(T value, Args... args)
{
std::cout << value << " "; // 打印分离出的第一个参数
showlist(args...); // 递归调用,将参数包继续向下传
}
int main()
{
//showlist();
showlist(1);
showlist(1, 'a');
showlist(1, 'a', string("1234"));
return 0;
}
这样一来,当递归调用showlist函数模板时,如果传入的参数包中参数的个数为0,那么就会匹配到这个无参的递归终止函数,这样就结束了递归。
- 但如果外部调用showlist函数时就没有传入参数,那么就会直接匹配到无参的递归终止函数。
- 而我们本意是想让外部调用showlist函数时匹配的都是函数模板,并不是让外部调用时直接匹配到这个递归终止函数。
则我们就开始写一个新的showlistarg的函数,供外部函数能够进行调用,我们看下面的代码来进行讲解:
// 无参的递归终止函数
void showlistarg()
{
std::cout << std::endl;
}
// 供外部函数进行调用
template<class T, class ...Args>
void showlistarg(T value, Args... args)
{
std::cout << value << " "; // 打印分离出的第一个参数
showlistarg(args...); // 递归调用,将参数包继续向下传
}
// 展开函数
template<class ...Args>
void showlist(Args... args)
{
showlistarg(args...);
}
这时无论外部调用时传入多少个参数,最终匹配到的都是同一个函数了。
(2)编写带参的递归终止函数
除了编写无参的递归终止函数,也可以编写带参数的递归终止函数来终止递归,比如这里编写带一个参数的递归终止函数:
// 带参的递归终止函数
template<class T>
void showlistarg(const T& t)
{
std::cout << t << std::endl;
}
// 供外部函数进行调用
template<class T, class ...Args>
void showlistarg(T value, Args... args)
{
std::cout << value << " "; // 打印分离出的第一个参数
showlistarg(args...); // 递归调用,将参数包继续向下传
}
// 展开函数
template<class ...Args>
void showlist(Args... args)
{
showlistarg(args...);
}
这样一来,在递归调用过程中,如果传入的参数包中参数的个数为1,那么就会匹配到这个递归终止函数,这样也就结束了递归。但是需要注意,这里的递归调用函数需要写成函数模板,因为我们并不知道最后一个参数是什么类型的。
但该方法有一个弊端就是,我们在调用ShowList函数时必须至少传入一个参数,否则就会报错。因为此时无论是调用递归终止函数还是展开函数,都需要至少传入一个参数。
(3)错误用例:判断参数包中的参数个数
// 错误示范 -- 判断参数包中的参数个数
template<class T, class ...Args>
void showlist(T value, Args... args)
{
std::cout << value << " ";
if (sizeof...(args) == 0)
{
return;
}
showlist(args...);
}
这种方式是不可行的,原因如下:
- 函数模板并不能调用,函数模板需要在编译时根据传入的实参类型进行推演,生成对应的函数,这个生成的函数才能够被调用。
- 而这个推演过程是在编译时进行的,当推演到参数包args中参数个数为0时,还需要将当前函数推演完毕,这时就会继续推演传入0个参数时的showlist函数,此时就会产生报错,因为showlist函数要求至少传入一个参数。
- 这里编写的if判断是在代码编译结束后,运行代码时才会所走的逻辑,也就是运行时逻辑,而函数模板的推演是一个编译时逻辑。
2、逗号表达式展开参数包
(1)通过列表获取参数包中的参数
如果参数包中各个参数的类型都是整型,那么也可以把这个参数包放到列表当中初始化这个整型数组,此时参数包中参数就放到数组中了。
// 展开函数
template<class ...Args>
void showlist(Args ...args)
{
int arr[] = { args... }; // 将参数包中的整数全部放进arr数组中
for (auto& e : arr)
{
std::cout << e << " ";
}
std::cout << std::endl;
}
但有缺陷,缺陷就是不能传无参进去,并且还有个致命的缺陷就是只能传一种类型的参数,如果我们第一个参数是整数,那么后面的参数也都得是整数类型的参数进行传参。
(2)通过逗号表达式展开参数包
虽然我们不能用不同类型的参数去初始化一个整型数组,但我们可以借助逗号表达式。
- 逗号表达式会从左到右依次计算各个表达式,并且将最后一个表达式的值作为返回值进行返回。
- 将逗号表达式的最后一个表达式设置为一个整型值,确保逗号表达式返回的是一个整型值。
- 将处理参数包中参数的动作封装成一个函数,将该函数的调用作为逗号表达式的第一个表达式。
我们如下代码,将int数组的arr首表达式设置成一个函数并将其进行传递args的参数,并用0进行结尾给数组说是这是整数类型的。
// 处理参数包中的每个参数
template<class T>
void PrintArg(const T& t)
{
std::cout << t << " ";
}
// 展开函数
template<class ...Args>
void showlist(Args... args)
{
int arr[] = { (PrintArg(args), 0)... };
std::cout << endl;
}
- 我们这里要做的就是打印参数包中的各个参数,因此处理函数当中要做的就是将传入的参数进行打印即可。
- 可变参数的省略号需要加在逗号表达式外面,表示需要将逗号表达式展开,如果将省略号加在args的后面,那么参数包将会被展开后全部传入PrintArg函数,代码中的
{(PrintArg(args), 0)...}
将会展开成{(PrintArg(arg1), 0), (PrintArg(arg2), 0), (PrintArg(arg3), 0), etc...}
。
自此我们就可以传入不同的类型的参数了,但我们此时还有一个问题,就是我们假如说是无参函数怎么办?其实很简单,我们再写一个无参的重载函数即可:
// 无参的调用函数
void showlist()
{
std::cout << std::endl;
}
// 处理参数包中的每个参数
template<class T>
void PrintArg(const T& t)
{
cout << t << " ";
}
// 展开函数
template<class ...Args>
void showlist(Args... args)
{
int arr[] = { (PrintArg(args), 0)... };
cout << endl;
}
(3)利用返回值代替逗号表达式
我们利用传入的函数进行返回一个整数即可,就不需要繁琐的逗号表达式,我们可以将处理函数的返回值设置为整型,然后用这个返回值去初始化整型数组也是可以的。
// 无参的调用函数
void showlist()
{
std::cout << std::endl;
}
// 处理参数包中的每个参数
template<class T>
int PrintArg(const T& t)
{
cout << t << " ";
return 0;
}
// 展开函数
template<class ...Args>
void showlist(Args... args)
{
int arr[] = { PrintArg(args)... };
cout << endl;
}
四、STL容器中的emplace相关接口函数
1、emplace版本的插入接口
下面是list的emplace版本的新增加的修改功能的函数接口:
这些emplace版本的插入接口支持模板的可变参数,比如list容器的emplace_back函数的声明如下:
emplace_back和push_back这个原生尾插大致是相同的,但有如下不同的地方:(我们以list中的emplace_back和push_back为例进行讲解)
- 调用push_back函数插入元素时,可以传入左值对象或者右值对象,也可以使用列表进行初始化。
- 调用emplace_back函数插入元素时,也可以传入左值对象或者右值对象,但不可以使用列表进行初始化。
- 除此之外,emplace系列接口最大的特点就是,插入元素时可以传入用于构造元素的参数包。
int main()
{
list<pair<int, string>> mylist;
pair<int, string> kv(10, "222");
mylist.push_back(kv); // 左值
mylist.push_back(pair<int, string>(10, "111")); // 右值
mylist.push_back({ 20,"123" }); // 初始化列表
mylist.emplace_back(kv); // 左值
mylist.emplace_back(pair<int, string>(30, "444")); // 右值
mylist.emplace_back(20, "12"); // 传参数表
return 0;
}
2、emplace系列接口的工作流程
emplace系列接口的工作流程如下:
- 先通过空间配置器为新结点获取一块内存空间,注意这里只会开辟空间,不会自动调用构造函数对这块空间进行初始化。
- 然后调用
allocator_traits::construct
函数对这块空间进行初始化,调用该函数时会传入这块空间的地址和用户传入的参数(需要经过完美转发)。 - 在
allocator_traits::construct
函数中会使用定位new表达式,显示调用构造函数对这块空间进行初始化,调用构造函数时会传入用户传入的参数(需要经过完美转发)。 - 将初始化好的新结点插入到对应的数据结构当中,比如list容器就是将新结点插入到底层的双链表中。
3、emplace系列接口的意义
由于emplace系列接口的可变模板参数的类型都是万能引用,因此既可以接收左值对象,也可以接收右值对象,还可以接收参数包。
- 如果调用emplace系列接口时传入的是左值对象,那么首先需要先在此之前调用构造函数实例化出一个左值对象,最终在使用定位new表达式调用构造函数对空间进行初始化时,会匹配到拷贝构造函数。
- 如果调用emplace系列接口时传入的是右值对象,那么就需要在此之前调用构造函数实例化出一个右值对象,最终在使用定位new表达式调用构造函数对空间进行初始化时,就会匹配到移动构造函数。
- 如果调用emplace系列接口时传入的是参数包,那就可以直接调用函数进行插入,并且最终在使用定位new表达式调用构造函数对空间进行初始化时,匹配到的是构造函数。
即我们总结:
- 传入左值对象,需要调用构造函数+拷贝构造函数。
- 传入右值对象,需要调用构造函数+移动构造函数。
- 传入参数包,只需要调用构造函数。
当然,这里的前提是容器中存储的元素所对应的类,是一个需要深拷贝的类,并且该类实现了移动构造函数。否则调用emplace系列接口时,传入左值对象和传入右值对象的效果都是一样的,都需要调用一次构造函数和一次拷贝构造函数。
实际emplace系列接口的一部分功能和原有各个容器插入接口是重叠的,因为容器原有的push_back、push_front和insert函数也提供了右值引用版本的接口,如果调用这些接口时如果传入的是右值对象,那么最终也是会调用对应的移动构造函数进行资源的移动的。
4、emplace接口意义
- emplace系列接口最大的特点就是支持传入参数包,用这些参数包直接构造出对象,这样就能减少一次拷贝,这就是为什么有人说emplace系列接口更高效的原因。
- 但emplace系列接口并不是在所有场景下都比原有的插入接口高效,如果传入的是左值对象或右值对象,那么emplace系列接口的效率其实和原有的插入接口的效率是一样的。
- emplace系列接口真正高效的情况是传入参数包的时候,直接通过参数包构造出对象,避免了中途的一次拷贝。
5、验证一下
我们继续用到我们写的string类了,我们代码如下:
#include<iostream>
#include<cassert>
#include<string>
#include<list>
using namespace std;
// string类
namespace JRH
{
class string
{
private:
char* _str;
size_t _size;
size_t _capacity;
public:
// 构造函数
string(const char* str = "")
{
std::cout << "string(const char* str) -- 构造函数" << std::endl;
_size = strlen(str); // 初始时字符串大小设置成字符字符串长度
_capacity = _size; // 初始时字符串容量设置成字符串大小
_str = new char[_capacity + 1]; // +1是为了后面还有个\0,为存储字符串开辟空间
strcpy(_str, str);
}
// 析构函数
~string()
{
delete[] _str; // 释放_str指向的空间
_str = nullptr; // 置空
_size = 0; // 字符串大小置0
_capacity = 0; // 字符串容量置0
}
// 移动构造
string(string&& s)
:_str(nullptr)
, _size(0)
, _capacity(0)
{
std::cout << "string(string&& s)" << std::endl;
swap(s);
}
// 移动赋值
string& operator=(string&& s)
{
std::cout << "string& operator=(string&& s) -- 移动赋值" << std::endl;
swap(s);
return *this;
}
// 正向迭代器
typedef char* iterator;
// begin
iterator begin()
{
return _str; // 返回字符串中首字母元素的地址
}
// end
iterator end()
{
return _str + _size; // 返回字符串中最后一个字母的下一个字符的地址
}
// 交换两个对象的数据
void swap(string& s)
{
// 调用库里面的swap
::swap(_str, s._str); // 交换两个对象的字符串
::swap(_size, s._size); // 交换两个对象的字符串大小
::swap(_capacity, s._capacity); // 交换两个对象的字符串容量
}
// 拷贝构造(现代写法)-- 深拷贝
string(const string& str)
:_str(nullptr)
, _size(0)
, _capacity(0)
{
std::cout << "string(const string& str) -- 深拷贝" << std::endl;
// 先开辟一个tmp空间
string tmp(str);
swap(tmp); // 将tmp扔到swap中进行交换
}
// 赋值运算符操作
string& operator=(const string& str)
{
std::cout << "string& operator=(const string& str) -- 深拷贝" << std::endl;
// 开辟一个tmp临时空间
string tmp(str);
// 丢到swap将其进行交换
swap(tmp);
// 返回左值
return *this;
}
// 运算符[]重载函数
char& operator[](size_t i)
{
assert(i < _size);
return _str[i]; // 返回下标相对应的字母
}
// 改容量,但大小不变
void reserve(size_t n)
{
if (n > _capacity) // 只有当n大于容量的时候才进行修改
{
char* tmp = new char[n + 1]; // 开辟个临时空间,后面带上\0
strncpy(tmp, _str, _size + 1); // 将_str的所有字符拷贝进tmp中
delete[] _str; // 释放对象原本空间
_str = tmp; // 将tmp的空间给_str(tmp只是临时空间,它需要给原住民_str开辟)
_capacity = n; // 容量变成n
}
}
// push_back进行尾插字符
void push_back(char ch)
{
if (_size == _capacity) // 判断是否需要增容
{
reserve(_capacity == 0 ? 4 : _capacity * 2); // 初始为4,后面两倍两倍增加
}
_str[_size] = ch; // 尾插
_str[_size++] = '\0'; // 后面一个字符为\0并将其_size++
}
// +=运算符重载
string& operator+=(char ch)
{
push_back(ch);
return *this; // 返回左值
}
// 返回c类型的字符串
const char* c_str() const
{
return _str;
}
};
}
我们用不同的传参形式进行传参看一下:
说明:
- 模拟实现string的拷贝构造函数时复用了构造函数,因此在调用string拷贝构造的后面会紧跟着调用一次构造函数。
- 为了更好的体现出参数包的概念,因此这里list容器中存储的元素类型是pair,我们是通过观察string对象的处理过程来判断pair的处理过程的。
既然这样,我们再看一下push_back把:
int main()
{
list<pair<int, JRH::string>> mylist;
pair<int, JRH::string> kv(1, "1234");
mylist.push_back(kv); // 传左值
std::cout << std::endl;
mylist.push_back(pair<int, JRH::string>(2, "12")); // 传右值
std::cout << std::endl;
mylist.push_back({ 50, "1222" }); // 传初始化列表
return 0;
}