目录
概念🤔
C++11 新增一员猛将就是可变参数模板,
他可以允许可变参数的函数模板和类模板来作为参数,使得参数高度泛化
。
在 C++11 之前类模板和函数模板中只能包含固定数量模板参数,而且也有可变参数的概念,比如 printf 函数就能够接收任意多个参数,但这是函数参数的可变参数,并不是模板的可变参数。可变模板参数无疑是一个巨大的改进,
但由于可变参数模板比较抽象,因此使用起来并不会太简单。
模板定义🤔
函数的可变参数模板定义方式如下:
template<class …Args>
返回类型 函数名(Args… args)
{
//函数体
}
比如:
template<class ...Args>
void ShowList(Args... args)
{}
注意这里的书写格式,模板参数Args前面有省略号,代表它是一个可变模板参数,
,参数包里面可以包含0到 N(N≥0) 个模板参数,
。
模板参数包 Args 和函数形参参数包 args 的名字可以任意指定,并不是说必须叫做 Args 和 args 。
那么现在函数传参就可以实不同类型了:
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 函数时匹配到函数模板,并不是直接匹配递归终止函数。
因此我们可以将展开函数和递归调用函数的函数名改为 ShowListArg,然后重新编写一个 ShowList 函数模板,在该函数模板的函数体中要做的就是调用ShowListArg 的展开参数包 :
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 ShowListArg(const T& t)
{
cout << t << endl;
}
//展开函数
template<class T, class ...Args>
void ShowListArg(T value, Args... args)
{
cout << value << " ";
ShowList(args...); //继续递归
}
//供外部调用的函数
template<class ...Args>
void ShowList(Args... args)
{
ShowListArg(args...);
}
但该方法有一个缺陷,在调用 ShowList 函数时至少要传入一个参数,否则就会报错,因为此时无论是调用递归终止函数还是展开函数,都需要至少一个参数,
那我们能不能先计算一下参数包中的参数个数呢
?
答案是:No!可能你会觉得 sizeof 这里也可以直接计算参数个数,来康康
:
template<class T, class ...Args>
void ShowList(T value, Args... args)
{
cout << value << " "; //打印传入的第一个参数
if (sizeof...(args) == 0)
{
return;
}
ShowList(args...); //继续递归
}
首先函数模板并不能调用,
函数模板需要在编译时根据传入的实参类型进行推演,生成对应的函数才能够被调用
,而这个推演过程是在编译时进行的,当推演到参数包 args 中参数个数为 0 时,函数不会停下会继续推演完毕,这时就会继续传入 0 个参数时的 ShowList 函数,此时就会报错 ShowList 函数没有参数。
这里编写的 if 判断是运行时才跑的逻辑,也就是运行时逻辑,而函数模板的推演是一个编译时逻辑
!
逗号表达式展开😎
我们知道数组可以通过列表进行初始化。如果参数包中各个参数类型都是整型,那么也可以把这个参数包放到列表中,初始化这个整型数组,此时参数包中参数就放到数组中了:
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);
return 0;
}
但 C++ 并不像 Python 一样激进敢秀,C++ 规定器中存储的数据类型是相同的,因此调用 ShowList 时传入的参数只能是整型,并且还不能传入 0 个参数,因为数组的大小不能为 0,
因此还需要在此基础上借助逗号表达式来展开参数包
逗号表达式规则是会从左到右依次计算各个表达式,并将最后一个表达式的值作为返回值返回,我们将最后一个表达式设为整型值,确保最后返回的是一个整型。
将处理参数个数的动作封装成一个函数,将该函数作为逗号表达式的第一个表达式
template<class T>
void PrintArg(const T& t)
{
cout << t << " ";
}
//展开函数
template<class ...Args>
void ShowList(Args... args)
{
int arr[] = { (PrintArg(args), 0)... }; //列表初始化+逗号表达式
cout << endl;
}
我们这里要做的就是打印参数包中的各个参数,因此处理函数当中要做的就是将传入的参数进行打印即可
可变参数的省略号需要加在逗号表达式外面,表示需要先将逗号表达式展开,如果直接加在 args 后面,那么参数包将会被展开后全部传入 PrintArg ,代码中会展开成 {(PrintArg(arg1), 0), (PrintArg(arg2), 0), (PrintArg(arg3), 0), etc…}
//支持无参调用
void ShowList()
{
cout << 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;
}
当然,我们也可以不使用逗号表达式,这里的问题是初始化整型数组时必须用整数,那我们可以将处理函数的返回值设为整型,然后用这个返回值去初始化整型数组也是可以的:
void ShowList()
{
cout << endl;
}
//处理函数
template<class T>
int PrintArg(const T& t)//返回值为int类型
{
cout << t << " ";
return 0;
}
//展开函数
template<class ...Args>
void ShowList(Args... args)
{
int arr[] = { PrintArg(args)... }; //列表初始化
cout << endl;
}
emplace🤔
C++11 给 STL 容器增加 emplace 的插入接口,比如 list 容器的 push_front、push_back 和insert 函数,都有了对应的 emplace_front、emplace_back 和 emplace 函数:
这些emplace版本的插入接口支持模板的可变参数,比如list容器的emplace_back函数的声明如下:
emplace 接口的可变模板参数类型都带有KaTeX parse error: Expected '}', got '&' at position 14: \color{red} {&̲&} ,这个表示的是
万能引用
,而不是
右值引用
。
使用方法😎
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 接口先通过
空间配置器
为新结点获取一块内存空间,注意这里只会开辟空间,不会自动调用构造函数对这块空间进行初始化。
然后调用
allocator_traits::construct
函数对这块空间进行初始化,调用该函数会传入这块空间的地址和用户传入的参数,注意要完美转发;在
allocator_traits::construct
中会使用定位 new 表达式,显示调用构造函数对这块空间进行初始化,调用构造函数时会传入用户传入的参数,这里同样需要完美转发
最后将初始化好的新结点插入到对应的数据结构中,比如 list 就是将新结点插入到底层的双链表中
意义😎
emplace 接口的可变参数模板类型都是万能引用,因此既可以接收左值,也可以接收右值,还可以接收参数包
如果调用 emplace 接口时传入的是左值
,首先需要先在此之前调用构造函数实例化出一个左值对象,最后使用定位 new 表达式调用构造函数对空间进行初始化时,会匹配到
拷贝构造函数
如果调用 emplace 接口时传入的是右值
,那么就需要在此之前调用构造函数实例化出一个右值对象,最终在使用定位new表达式调用构造函数对空间进行初始化时,就会匹配到
移动构造函数
如果调用 emplace 接口时传入的是参数包,就可以直接调用函数进行插入,并最终使用定位 new 表达式调用构造函数对空间进行初始化时,匹配到构造函数
一句话就是:
传入左值,调用构造函数+拷贝构造函数。
传入右值,调用构造函数+移动构造函数。
传入参数包,只需要调用构造函数
注意,这里前提是容器中存储的是一个需要深拷贝的类,并且该类实现了移动构造函数,否则传入左值和传入右值的效果是一样的,都会调用一次构造和一次拷贝构造
因为容器原有的 push_back、push_front 和 insert 也提供了右值引用的接口,所以 emplace 的部分功能和原有容器是重复的,如果调用时传入右值,那么最终也会调用对应的移动构造函数进行资源转移。
emplace 最大特点就是支持传入参数包,用这些参数包直接构造出对象,这样就能减少一次拷贝,这就是为什么有人说 emplace 系列接口更高效的原因
但 emplace 并不是在所有场景下都比原有的插入接口高效,如果传入的是左值对象或右值对象,那么 emplace 系列接口的效率其实和原有的效率是一样的
emplace 真正高效的情况是传入参数包的时候,
namespace cl
{
class string
{
public:
//构造函数
string(const char* str = "")
{
cout << "string(const char* str) -- 构造函数" << endl;
_size = strlen(str);
_capacity = _size;
_str = new char[_capacity + 1]; //开辟空间(多开一个用于存放'\0')
strcpy(_str, str); //将C字符串拷贝到已开好的空间
}
//交换两个对象数据
void swap(string& s)
{
std::swap(_str, s._str); //交换两个对象的C字符串
std::swap(_size, s._size); //交换两个对象的大小
std::swap(_capacity, s._capacity); //交换两个对象的容量
}
//拷贝构造函数(现代写法)
string(const string& s)
:_str(nullptr)
, _size(0)
, _capacity(0)
{
cout << "string(const string& s) -- 拷贝构造" << endl;
string tmp(s._str); //调用构造函数,构造一个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指向的空间
_str = nullptr; //置空,防止非法访问
_size = 0;
_capacity = 0;
}
private:
char* _str;
size_t _size;
size_t _capacity;
};
}
这里我们用模拟实现的 string 来验证 emplace 的机制:
int main()
{
list<pair<int, cl::string>> mylist;
pair<int, cl::string> kv(1, "one");
mylist.emplace_back(kv); //左值
cout << endl;
mylist.emplace_back(pair<int, cl::string>(2, "two")); //右值
cout << endl;
mylist.emplace_back(3, "three"); //参数包
return 0;
}
结果如下:
我们自己实现的 string 的拷贝构造函数复用了他的拷贝函数,所以在调用 string 的拷贝构造的时候会紧跟一次拷贝函数的调用。
当然,如果想要更加完美的体现 emplace 的作用,这里存的是 char 类型,为了体现参数包的概念,可以将 list 中更换成 pair 类型对象,这里不赘述了,有兴趣的可自行实现。
aqa 芭蕾 eqe 亏内,代表着开心代表着快乐,ok 了家人们。