c++11特性——可变参数模板及emplace系列接口
文章目录
- 可变参数模板
- 基本语法和使用
- sizeof...运算符
- 从语法角度理解可变参数模板
- 包扩展
- 通过编译时递归解析参数包
- 直接对解析行为展开
- emplace系列接口
- 举例讲解
- emplace_back的实现
可变参数模板
可变参数模板是c++11新特性中极其重要的一节。前文我们提到过,c++11中对于个别容器引入了emplace系列的接口。可以在一定程度上提高一些效率。而emplace系列的接口又必须依靠可变参数模板来实现。
本篇文章将重点讲解关于可变参数模板这一个部分的内容。
基本语法和使用
先不看基本的语法。我们先来试着想一下,可变参数模板是变什么?参数可以变的东西也不多,无非就是类型,个数,顺序等。
但是我们已经学过模板了,模板在一定程度上就是泛化了参数的数据类型。模板就是为了泛化编程出现的。以往学的模板已经是做到了数据类型和顺序的泛化。现在进一步的泛化只能是泛化模板参数的个数了。这是非常有意义的,在了解意义之前,我们还是得先了解一下可变参数模板的基础语法和其底层原理。
首先我们先来看一下c++11引入的这个可变参数模板的基本语法:
C++11支持可变参数模板,也就是说支持可变数量参数的函数模板和类模板,可变数目的参数被称为参数包,存在两种参数包:模板参数包,表示零或多个模板参数;函数参数包:表示零或多个函数参数。
下面是可变参数模板的声名和定义:
template <class …Args> void Func(Args… args) {}
template <class …Args> void Func(Args&… args) {}
template <class …Args> void Func(Args&&… args) {}
这里和以往不同了。以往我们写模板参数的时候,是需要多少写多少。但是现在我们想要把模板参数的个数进行泛化,也就是说,在编写模板的时候并不知道有多少个。所以c++11引入了一个新的概念叫参数包。
写在模板参数声名的位置的叫做模板参数包。模板参数都被泛化成模板参数包了,那么函数参数列表也自然被泛化成了函数参数包。
我们用省略号…来指出一个模板参数或函数参数表示的一个包,在模板参数列表中,class…或typename…指出接下来的参数表示零或多个类型列表;在函数参数列表中,类型名后面跟…指出接下来表示零或多个形参对象列表;函数参数包可以用左值引用或右值引用表示,跟前面普通模板一样,每个参数实例化时遵循引用折叠规则。
这里的Args其实是参数英文arguments的缩写。本质上就是给传入的一系列不知道具体类型和个数的参数列表(可能有int,string,char,其他自定义类型,数量不知)取一个名字罢了。喜欢什么用什么。只不过大部分情况下更倾向于使用Args这个名字。
所以在这里我们可以浅显的认为,符号…代表的就是一个参数包。
sizeof…运算符
我们来试用一下可变参数模板并且讲解一下一个新的操作符sizeof…。
先来看下面一段代码:
//声名可变参数模板
template<class... Args>
void func(Args&&... args) {cout << sizeof...(args) << endl;
}#include<vector>int main() {func(2);func(1.1, string("123"));func(2, 2.2, 'a');int a = 10;func(&a, 10, 10.5, string("adab"));func(&a, 10, 30.5, string("abcd"), vector<int>());return 0;
}
我们先给出结论,操作符sizeof…是专门用来计算一个参数包有几个参数的。
比如上面的代码,我们将函数参数包args传给操作符sizeof…,就可以计算出args这个参数包内有几个参数。
我们试着验证一下,既然说可变参数模板是即可变参数类型,又可以变参数个数,我们就按照上面的测试方法进行参数的传递。
这里注意一下,我们采用的是万能引用去接收,对于深拷贝类型是可以调用移动构造从而不调用拷贝构造,节省效率。为什么这里可以使用左值引用和万能引用等一下讲解可变参数模板的原理的时候会细细道来。
我们来看一下上面那一段代码输出的结果是什么:
结果正好和上面说的一样。操作符sizeof…接收一个参数包,计算的是参数包里面参数的个数。注意这里的sizeof…和我们以前学的sizeof其实是两个不同的操作符。从功能上看确实是这样。因为sizeof计算的是数据类型占用的空间。
而且我们也可可看见,对于可变参数模板声名的函数,我们是可以传入任意类型和任意个数的参数进去的,最终都是被参数包接收了。
从语法角度理解可变参数模板
这种多可变参数的形式我们其实很早就接触过,就是在学习c语言时候学习的printf和scanf函数,我们称前面" "包起来的,以%开头的东西叫做占位符,如%d %f %c
而且在使用这两个函数的时候,前面占位符的个数是可以自己手动确定的。只需要后面传参数的时候一一对应就可以了。
可变参数模板其实就感觉和这个原理有点像。我们应该怎么去理解这个内容呢?
我们一步步推导,我们以一个实例来举例:
假设我们现在要写一个Print函数,功能是打印出函数参数列表中的每个参数
double x = 2.2;
Print();
Print(1);
Print(1, string("xxxxx"));
Print(1.1, string("xxxxx"), x);
在学习了函数重载的内容后,我们知道,函数名相同,参数不一样就可以构成函数的重载。在没有学习任何的模板的相关知识前,我们需要像下面这样写代码:
void Print() {cout << "" << endl;}
void Print(int x) {cout << x << endl;}
void Print(int x, const string& str) {cout << x << " " << str << endl;}
void Print(int x, const string& str, double y)
{cout << x << " " << str << " " << y << endl;}
是需要根据具体的传入的参数去写不同版本的Print函数的。
但是这样写是很麻烦的,所以c++引入了一个叫模板的概念,也就是在确定参数个数的情况下,泛化数据的类型,这样子就可以针对于不同的参数做出处理了,只不过说参数数据个数是确定的,于是可以写出下面这样的代码:
void Print() {cout << "" << endl;}template <class T1>
void Print(T1&& arg1) {cout << arg1 << endl;}template <class T1, class T2>
void Print(T1&& arg1, T2&& arg2) {cout << arg1 << " " << arg2 << endl;}template <class T1, class T2, class T3>
void Print(T1&& arg1, T2&& arg2, T3&& arg3)
{cout << arg1 << " " << arg2 << " " << arg3 << endl;}
这里使用了万能引用,这个需要注意一下。
使用了模板后,我们发现,针对于数据个数确定的情况下,类型是可以随意传的。只要数据类型有对应实现流插入提取运算符(能打印)就不会报错。
但是这个还是不够泛化。所以c++11引入了新的语法——可变参数模板。把模板参数和函数参数都泛化成了一个参数包。这个参数包可能由(0 ~ N)个不知道具体类型的参数集组成的。从语法的角度上来理解:
//可变参数模板写法
template <class ...Args>
void Print(Args&&... args)
{cout << sizeof...(args) << endl;}
假设当前调用了这个可变参数模板,把一系列的参数集传给这个函数参数包args,我们可以这样理解:
我们就以前面0个参数、1个参数、2个参数的来进行讲解。
首先,我们可以这么认为,之前的普通模板就是在参数个数确定的情况下,把数据类型泛化了。可变参数模板就是在参数类型已经泛化的前提下,再把参数个数进行泛化操作。
其次,当传入某个参数集的时候,在语法角度上,我们可以理解为,编译器先判断当前有几个参数。然后就会先生成一个对应的数据个数的函数模板。比如传入10个参数给可变参数模板的函数,那么我们可以认为编译器会自动生成带有10个模板参数的函数模板。
最后,传入的数据集每个参数的类型是确定的,所以又可以认为,编译器生成函数模板后,就会根据传入的参数集来自动推导每个模板参数的类型,实例化出对应的版本。
如果从上面的角度去理解的话,其实和以前学的函数模板差不多。只不过可以认为是再原有的函数模板的基础上又针对数量又套了一层函数模板。导致可变参数模板的步骤要多一步生成对应函数模板后再来推导。
但是需要注意的是,上面的阐述只是我们从语法角度去理解的。其实编译器可能并不是这样做的,很有可能编译器经过特殊处理后可以直接一步到位的。但是着并不妨碍我们从语法角度去理解它。就像引用和指针的关系一样,语法角度理解引用就是取别名。但是本质是一个指针。但是为了能够更好的掌握知识,有时候语法和底层实现是相背离的,需要我们灵活地去理解。
包扩展
但是细心的朋友们肯定发现了,前一个部分代码中定义的func函数中好像没有涉及到使用参数包里面的各个参数呢。以往在使用函数的,哪怕是模板函数,因为知道参数的名称的,所以可以直接使用参数。但是现在变成了一个参数包,应该怎么样通过这个参数包进行使用各个参数呢?这是需要我们进行思考的。
这个问题需要通过本部分讲的内容——包扩展来解决。包扩展其实就是将参数包中的参数解析出来,包的扩展十分重要。
我们先来讲一个很多人觉得可行的用法,但实际却不行:
假设我们想打印出参数包中的每一个参数(假设均已实现流插入和提取运算符):
template<class... Args>
void f(Args... args) {for (size_t i = 0; i < sizeof...(args); ++i) {cout << args[i] << endl;}
}int main() {int a = 10;f(1, 2.2, 'a', string("123"), &a);return 0;
}
很多人会觉得这样子是可以把每个参数取出来的。其实我也觉得这样子写很爽,特别好用。但是实际上不是这样的,编译会报错:
编译器是不支持这样子做的。报错的原因是args这个参数包必须要扩展。其实就是包扩展。c++在处理这种问题的时候是采取包扩展的方式,将参数包中的参数一个个解析出来的。至于怎么对包进行扩展,这是我们等一下要重点讲的内容。
现在我们来试着理解一下为什么c++不支持像图中那个用法。如果要这样子使用的话,有一个最大的问题是,每个参数的数据类型很可能是不同的。要支持上面那样解析参数包,就要把每个参数存起来。但是c++中支持的容器是只在后面新的版本才支持每个数据类型不一样。在c++11的时候,STL库中的所有容器中所有的数据类型都是一样的。而且上面那种方式用起来是很简单,但是也要考虑到对编译器的要求。也可能是这种解析方式对于编译器来讲实现难度太大了。很可能是这些原因导致c++不支持这种解析方式。
接下来将重点讲解两种包扩展的方式。
通过编译时递归解析参数包
第一种方法是通过编译时递归的方法进行参数包的解析。为什么需要特意强调是编译时的递归呢,这点我们先不讲,先来直接看用法:
//编译时递归终止条件
void Show() {cout << endl;
}//递归函数
template<class T, class... Args>
void Show(T x, Args&&... args) {cout << x << endl;Show(args...);
}template<class... Args>
void func(Args&&... args) {Show(args...);
}int main() {int a = 10;func(1, 2.2, 'a', string("123"), &a);return 0;
}
我们来看看结果:
发现输出结果是我们想要的,但是我们发现,这种方式其实很奇怪。但是别着急,我们一起来分析一下这个过程是如何进行的。
- 首先我们得知道这个是编译时递归
为什么说是编译时递归,难道以前用的递归不是编译时递归吗?
是的,以前我们使用的递归是运行时递归,是一个动态过程。而这里是一个静态过程。怎么样来判断呢?很简单,我们尝试着把递归终止条件删除看看:
这里报错了。但是以往的递归中,如果在没有语法错误的情况下,不写终止条件是编译是不会报错的。因为编译检查的是语法。但是运行的时候就会发现程序会因为栈溢出的问题崩溃了。所以正常来说,这里不应该报错。
但是在取消了终止条件后,编译器报错了。说明编译器做了一件事情:就是在编译的时候将递归展开了,但是没有终止条件,所以最后报错了。所以这里足以证明这里的包解析方式是通过编译时的递归进行展开包的。
- 了解编译时递归是如何进行的
首先,对于解析参数而言,要将参数包展开进行解析。然后需要通过另外一个函数来帮忙的。这个函数就是上面的Show函数(当然可以是其它的名字)。然后通过这个Show函数和对应的终止条件进行编译时的递归。
我们先来看看Show函数的声名,这也是一个函数模板,只不过模板参数比较特殊:
是一个模板参数T和可变模板参数的参数包。然后func函数中调用这个Show函数。
然后我们直接开始讲具体的流程:
首先,func函数调用Show函数,把参数包args传给Show函数的参数部分。但是需要注意一下这里的传参,会发现args后面加了…。这是为什么?因为要将参数包展开。我们可以理解在参数包名后面加…,这个行为就是告诉编译器要将这个包进行展开。
因为一个包肯定是没办法直接赋值给一个参数列表的。在Show函数中,参数列表是一个T类型和一个参数包。func中的args这个参数包肯定是没办法传给T x,Args…组成的参数列表的。所以在包后面加…就是告诉编译器,按照接收参数的部分进行包展开,以便能够匹配参数。不这样做编译器是会报错的:
然后我们就得知道,这里是如何做到递归的:
递归最重要的就是理解子进程和终止条件。我们发现上面的终止条件是参数列表为空的时候。我们大致就可以猜测,这种编译时递归的包扩展应该就是靠参数控制的。
实际确实是这样:首先main函数调用func函数的时候,传入了五个参数,这五个参数包会在func函数中展开传给Show函数。这里我们就得知道,将参数包展开传给子函数的时候是怎么展开的。
展开方式是:将参数包中的第一个参数给到参数T x,然后剩余的参数组成一个新的包。那么子进程的args包就会变成4个。然后再继续调用,那么又是重复上面的过程,把包中第一个参数给T x,然后再把剩余的3个参数组成一个包给子进程的args包。以此类推,直到编译器发现,参数包变成空的时候,就会调用参数列表为空的那个Show函数。这样子就停止这个递归展开的过程了。
可以画个图进行理解,为了画图方便,只画出三个参数二段情况。其实参数多少个原理都一样,只不过递归展开次数有变化而已:
就是在调用Show函数的时候,参数包展开。只不过需要匹配一下Show函数的参数列表。匹配的方式就是将第一个参数给Show的第一个参数,剩下的给到参数包。然后再将新的参数包展开传入调用,直到参数包为空就停止这个过程。
这就是第一种方式进行包的展开。
直接对解析行为展开
上面那种方法属于是编译时递归,现在这个方法我认为可以称为对解析行为的展开。
我们来直接看代码怎么写的:
template<class T>
const T& GetArg(const T& arg) {cout << arg << endl;return arg;
}template<class... Args>
void Argment(Args... args) {cout << "Hello" << endl;
}template<class... Args>
void Print(Args... args) {Argment(GetArg(args)...);
}int main() {Print(1, 2.5, string("123"));return 0;
}
在Print函数中,如果想要将参数包args进行参数的解析,还可以使用这种方法。
假设想要在Print调用Argment这个函数,正常来讲是可以直接这么调用的:
直接在Print中将args展开传给Argment函数就可以了。但是现在想要把args中的参数一个个解析出来,用到的方法就是使用语句:Argment(GetArg(args)…)
我们发现,这个展开符号…放在了GetArg这个调用的后面。我管这种行为叫:对解析行为的展开。因为我们发现,解析函数GetArg的参数列表中仅仅只有一个参数。我们是万万不能直接将args…传给它的参数列表进行接收的。因为GetArg函数的参数没有参数包,将args展开至少得参数包去接收。
假设外界调用Print(1, 2.5, string(“123”));,传给Print的函数包args就有三个参数。但是对于GetArg来说,一次只能接收一个参数。而Argment这个函数的参数部分是参数包,也就是说,想要通过GetArg解析出来的参数传给Argment函数,势必要对GetArg这个行为进行展开,要不然是匹配不上参数形式的。
传给Print函数的参数有三个,那么再把args传给GetArg函数,并对这个行为展开,也就是说,GetArg(args)…其实要调用三次。才能得出Print中参数包args的样子。
我们画个图来理解一下:
再说一次:因为Print参数包里面有三个参数,如果将这个参数包传给GetArg然后并且对这个行为进行展开,本质上就是args里有多少个参数,GetArg就会调用多少次,然后将这些解析出来的返回值传给下一层函数Argment。
当然这个GetArg的返回值也可以是其他,比如返回一个int。因为Argment的参数是一个参数包,可以随便接收。具体返回什么取决于使用场景,我们看看上面那段代码的输出结果:
我们发现是可以正常解析出来的。但是解析出来的顺序是反过来的。这点注意一下就可以了。
emplace系列接口
c++11后,有些容器(如list)引入了一个新的系列的接口:
在刚开始学习STL容器的时候,我们就说过,对于emplace系列的接口,其实和push_back和push_front两个插入接口基本上是一样的。只不过说对于某些场景下,emplace系列的接口可以更加高效一点。今天我们一起来探讨以下为什么emplace系列的接口会高效,且看看为什么c++11引入这个接口后很多人更倾向于使用emplace系列的接口。
举例讲解
我们就以自行实现的list< myspace::string >容器进行讲解。但是emplace系列接口涉及我们以前讲到的移动构造和移动赋值等知识,所以我们得改进一下以往实现的list和string:
改进的string
#include<iostream>
#include<assert.h>
#include<string.h>
using namespace std;namespace my {class string{
public:typedef char* iterator;typedef const char* const_iterator;iterator begin(){return _str;}iterator end(){return _str + _size;}const_iterator begin() const{return _str;}const_iterator end() const{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);}void swap(string& s){std::swap(_str, s._str);std::swap(_size, s._size);std::swap(_capacity, s._capacity);}string(const string& s):_str(nullptr){cout << "string(const string& s) -- 拷贝构造" << endl;reserve(s._capacity);for (auto ch : s){push_back(ch);}}// 移动构造string(string&& s){cout << "string(string&& s) -- 移动构造" << endl;swap(s);}string& operator=(const string& s){cout << "string& operator=(const string& s) -- 拷贝赋值" <<endl;if (this != &s){_str[0] = '\0';_size = 0;reserve(s._capacity);for (auto ch : s){push_back(ch);}}return *this;}// 移动赋值string& operator=(string&& s){cout << "string& operator=(string&& s) -- 移动赋值" << endl;swap(s);return *this;}~string(){//cout << "~string() -- 析构" << endl;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];if (_str){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;}size_t size() const{return _size;}
private:char* _str = nullptr;size_t _size = 0;size_t _capacity = 0;
};
}
对于string来讲,加多了几个接口。即移动构造和移动赋值。加上这两个接口是为了减少拷贝构造的调用。这点我们在上一篇文章就已经讲过的。
改进后的List:
namespace my{
#pragma once
#include<iostream>
#include<assert.h>
#include<string>
#include<vector>
#include<initializer_list>
using namespace std; namespace MyList {template<class T>struct list_node {T _data;list_node<T>* _prev = nullptr;list_node<T>* _next = nullptr;list_node(const T& Val = T()):_data(Val),_prev(nullptr),_next(nullptr){}list_node(T&& Val):_data(std::forward<T>(Val)),_prev(nullptr),_next(nullptr){}template<class... Args>list_node(Args... args) : _data(std::forward<Args>(args)...),_prev(nullptr),_next(nullptr){}};//模仿源码中写的方式 写迭代器template<class T, class Ref, class Ptr> //T代表数据类型(节点中存储的数据 Ref代表对数据的引用 区分const和非const Ptr为指向数据的指针 也是区分const和非conststruct list_iterator{typedef list_node<T> Node;typedef list_iterator<T, Ref, Ptr> Self;list_iterator(Node* node = nullptr){_node = node;}Ref& operator*() {return _node->_data;}Ptr operator->() {return &(_node->_data);}Self& operator++() {_node = _node->_next;return *this;}Self& operator--() {_node = _node->_prev;return *this;}Self operator++(int) {list_iterator tmp(*this);_node = _node->_next;return tmp;}Self operator--(int) {list_iterator tmp(*this);_node = _node->_prev;return tmp;}bool operator==(const Self& x) {return _node == x._node;}bool operator!=(const Self& x) {return _node != x._node;}Node* _node;};template<class T>class list {public:typedef list_node<T> Node; typedef list_iterator<T, T&, T*> iterator;typedef list_iterator<T, const T&, const T*> const_iterator;//创建哨兵位节点void HeadNode() {_head = new Node(T());_head->_prev = _head;_head->_next = _head;_size = 0;}list(int n, const T& value = T()) {HeadNode();for (int i = 0; i < n; ++i) {push_back(value);}}template <class T>list(const initializer_list<T>& x) {HeadNode();typename initializer_list<T>::iterator it = x.begin();while (it != x.end()) {push_back(*it);++it;}}template <class PushIterator>list(PushIterator first, PushIterator last) { HeadNode(); PushIterator it = first; while (it != last) { push_back(*it); ++it; }}list() {cout << "list() 构造" << endl;HeadNode();}list(list<T>& l) { //cout << "拷贝构造" << endl; HeadNode(); typename list<T>::iterator it = l.begin(); while (it != l.end()) { push_back(*it); ++it; }}list(list<T>&& l) {//cout << "移动构造" << endl;swap(l);}list<T>& operator=(list<T>& tmp) {//cout << "拷贝赋值" << endl;HeadNode();typename list<T>::const_iterator it = tmp.begin();while (it != tmp.end()) { push_back(*it); ++it; }return *this;}list<T>& operator=(list<T>&& tmp) {//cout << "移动赋值" << endl;swap(tmp);return *this;}//析构~list() {clear();delete _head;_head = nullptr;}void push_back(const T& x) {insert(end(), x);}void push_back(T&& x){insert(end(), forward<T>(x));}template<class... Args>void emplace_back(Args&&... args) {insert(end(), std::forward<Args>(args)...);}//iterator//iterator begin() {return _head->_next;}iterator end() {return _head;}const_iterator begin() const{return _head->_next;}const_iterator end() const{return _head;}////capacitysize_t size() {return _size;}bool empty() {return _size == 0;}////access//T& front() {assert(!empty());return _head->_next->_data;}T& back() {assert(!empty());return _head->_prev->_data;}const T& front() const{assert(!empty());return _head->_next->_data;}const T& back() const{ assert(!empty());return _head->_prev->_data;}////operation///void swap(list& lt) {std::swap(_head, lt._head);std::swap(_size, lt._size);}iterator insert(iterator pos, const T& x) {Node* newnode = new Node(x);Node* prev = (pos._node)->_prev, *next = pos._node;prev->_next = newnode;newnode->_prev = prev;newnode->_next = next;next->_prev = newnode;++_size;return newnode;//隐式类型转换返回}iterator insert(iterator pos, T&& x){Node* cur = pos._node;Node* newnode = new Node(forward<T>(x));Node* prev = cur->_prev;// prev newnode curprev->_next = newnode;newnode->_prev = prev;newnode->_next = cur;cur->_prev = newnode;return iterator(newnode);}template<class... Args>iterator insert(iterator pos, Args&&... args){Node* cur = pos._node;Node* newnode = new Node(std::forward<Args>(args)...);Node* prev = cur->_prev;// prev newnode curprev->_next = newnode;newnode->_prev = prev;newnode->_next = cur;cur->_prev = newnode;return iterator(newnode);}iterator erase(iterator pos) {assert(pos != end());Node* POS = pos._node;Node* prev = (pos._node)->_prev, *next = (pos._node)->_next;prev->_next = next;next->_prev = prev;delete POS; return next;}void clear() {list<T>::iterator it = begin();while (it != end()) {it = erase(it);}_size = 0;}void push_front(const T& val) {insert(begin(), val);}void pop_front(){erase(begin()); }private:Node* _head;size_t _size; };
}
注意:这里我直接实现了list对应的emplace_back接口。至于具体原理是什么等一下会说。这里就以尾插的方式来测试就好了。主要是理解原理,并能够较为熟练地使用这个系列的接口。
我们先来看第一个:在只使用push_back接口的情况下:
#include"List.h"
#include"String.h"
using namespace std;
int main() {//先调用list的默认构造//然后生成头结点的时候得生成一个string,所以调用构造//然后因为给的是右值进行构造 所以是再调用一次移动构造MyList::list<my::string> lt1;cout << "--------------------------------------------" << endl;//这个s1先调用构造生成s1 然后s1已存在 是个左值 所以再调用push_back就是走的拷贝构造my::string s1("123456");lt1.push_back(s1);cout << "--------------------------------------------" << endl;//这个是先生成临时变量 然后调用移动构造lt1.push_back("123456");cout << "--------------------------------------------" << endl;//构造临时对象 然后是个右值 还是走移动构造lt1.push_back(my::string("123456"));cout << "--------------------------------------------" << endl;return 0;
}
这些其实在上一节课已经讲过。但是为了和后面调用emplace_back的时候做对比,所以就再来回顾一下,我们一起看看结果:
事实确实是如此。
现在再来看一下emplace系列的接口的使用,我们先看结果,再来讲实现:
int main() {//先调用list的默认构造//然后生成头结点的时候得生成一个string,所以调用构造//然后因为给的是右值进行构造 所以是再调用一次移动构造MyList::list<my::string> lt2;cout << "--------------------------------------------" << endl;//会直接调用构造lt2.emplace_back("123456789");cout << "--------------------------------------------" << endl;//调用构造后再移动构造lt2.emplace_back(my::string("123456"));cout << "--------------------------------------------" << endl;//先构造出s 然后调用拷贝构造my::string s("hello");lt2.emplace_back(s);cout << "--------------------------------------------" << endl;return 0;
}
我们来看看输出:
结果和分析的一样。
调用emplace的接口,如果传入的是左值,那么就和push_back一样走的拷贝构造。但是传入右值的时候,就发现有一些区别了。如果传入的是匿名对象,那逻辑和尾插也差不多。但是如果只传字符串,emplace系列的接口优势就来了。直接就构造就好了,都不需要再调用任何的移动构造和拷贝构造。这效率肯定是高一点点的,但不会很多。
我们再来看一个例子:
int main() {MyList::list<pair<my::string, int>> lt3;cout << "--------------------------------------------" << endl;//这里先通过隐式类型转化 构造出一个pair的临时对象//然后再调用移动构造lt3.push_back({ "string1", 1 });cout << "--------------------------------------------" << endl;//先调用构造 构造出p1//然后p1是左值 所以调用push_back的时候还需要调用拷贝构造pair<my::string, int> p1("string2", 2);lt3.push_back(p1);cout << "--------------------------------------------" << endl;//p1是左值//所以和push_back的行为是一样的lt3.emplace_back(p1);cout << "--------------------------------------------" << endl;//要小心move左值 会把左值的内容掠夺走//右值 最后直接进行移动构造lt3.emplace_back(move(p1));cout << "--------------------------------------------" << endl;return 0;
}
总的来说,经过几个测试用例发现,其实emplace和push两个方式用法其实差不多。都可以接收左值和右值。区别就是在于,对于push系列,左值右值是分开实现的。但是emplace系列是通过可变参数模板进行接收的。对于emplace系列,如果接收左值,行为其实和push_back的左值调用差不多。
但是在右值的处理上就有一些差别了。对于某些情况下,emplace不需要进行移动构造,所以效率会略高一点。但是这限于需要深拷贝的数据类型。因为不需要深拷贝的类型没有移动构造这一说法。所以效率也差不多。
emplace_back的实现
我们先来看一个例子:
其实就是刚才的测试用例,发现当list内存的是一个pair<my::string, int>的时候,当我们想走隐式转换构造一个pair,然后再去调用移动构造的时候发现,emplace_back这个接口不支持这么干。反倒是把这个pair里面的内容分开来传进去倒是可以正常使用:
而且,分开来写效率还更高一点。这是为什么?这也太奇怪了。
别急,这关系到emplace_back底层的实现原理,我们先来讲emplace_back的实现:
实现emplace_back其实很简单,就是把参数变成可变模板参数的万能引用版本:
//迭代器是已经实现的了。
template<class... Args>
void emplace_back(Args&&... args) {//注意这里参数包的展开有点特别 先完美转发 再展开insert(end(), std::forward<Args>(args)...);
}
emplace_back其实就是尾插。所以直接调用insert接口,通过迭代器插入就好了。
前面我们讲过,插入的数据如果是一个参数包,就需要将其展开。而且接收这个展开参数包的接口的形参必须也是参数包。还有个问题就是,如果参数包中有传右值的,我们最好保持其特性。因为对于传右值的,是一些临时变量、匿名对象而已。肯定要去匹配右值版本的接口会更好。因为右值会调用移动构造和移动赋值。效率是比拷贝的高很多的。
但是传入左值的,我们也应该是希望保持其特性。因为左值一般是长期存储在内存中的值。如果让它变成右值就很危险了,很容易把它的资源给掠夺了。但是大部分情况下,传左值是不希望被修改的,所以也要保持左值的特性。
但是无论是左值引用还是右值引用本身,都是左值。所以在前面我们讲过,为了保持特性,应该使用完美转发,一直保持类型即可。
这里不需要对参数包解析。可以这么理解:把整个参数包当成一个参数进行插入。解析出来没用,我们又不需要使用里面的参数。
这里我们没有实现有参数包的insert接口,实现一个就好了。
template<class... Args>
iterator insert(iterator pos, Args&&... args)
{Node* cur = pos._node;Node* newnode = new Node(std::forward<Args>(args)...);Node* prev = cur->_prev;// prev newnode curprev->_next = newnode;newnode->_prev = prev;newnode->_next = cur;cur->_prev = newnode;return iterator(newnode);
}
这里也要使用完美转发。为什么?
一样的道理,new Node的时候会调用节点的构造函数。现在是要把参数包当成数据插入。所以还是要把参数包展开传给Node的构造函数。还是要完美转发。因为虽然前面完美转发过一次,但是在这个函数里面,右值引用的本身又是左值。所以还是得完美转发一次。
但是Node的构造函数里面又没有参数包版本的,所以又得实现一个参数包版本的:
template<class... Args>
list_node(Args... args) : _data(std::forward<Args>(args)...),_prev(nullptr),_next(nullptr)
{}
这里也是一样的,直接完美转发再展开。到这里就实现完了。
因为到了这一层,这个_data其实就是要插入的数据的类型。前面要一直不停的传是因为没有停止的条件。到这里就不一样了。这里参数包展开后,我们可以理解为在参数列表那变成了一个个的参数,这些参数其实就是这个数据类型_data所需要的构造条件。
就以刚刚的pair举例:
lt3.emplace_back(“12345678910”, 1);
这样子写,参数包里面第一个参数是const char*,第二个是int。
调用emplace_back接口,把参数包展开传给insert接口。由于insert接口没有匹配的,所以要实现一个对应的版本。
insert接口里面又调用Node的构造函数。Node的构造函数又是没有匹配的版本,所以得实现以恶搞版本。
所以前面一直只是把参数包展开,然后往下传。但是到了Node的构造这一层就不一样了,比如这个pair,展开后应该是这样的:
list_node(class... Args){
//相当于list_node(const char* pstr, int x): _data(std::forward<Args>(args)...),//相当于这个_data(pstr, x);_prev(nullptr),_next(nullptr)
}
这不正好是调用pair的构造吗?如果只有一个pstr在这,不正好构成了string用字符串来构造的默认构造函数吗?
所以传到最后一层发现,直接调用构造了。这也就是为什么emplace系列效率高的原因。因为传参数包进来直接调用构造了,而且写起来还很方便。
但是如果是调用push_back的接口,会先构造再来移动构造。所以差别就在这里。
现在再来回过头看开头提出的问题就很好理解了。:
因为lt3.emplace_back的时候,emplace_back的那个模板参数包Args还是没有确定的。emplace_back其实套了两层模板参数。第一层是T,也就是存储的数据类型。第二层是这个参数包Args。但是push_back并没有多套一层模板参数,push_back的参数列表中,数据类型是T。这个早在实例化出list的时候就被确定了。
所以在调用push_back的时候,就可以确认,插入的数据是一个pair。然后将{ }括起来的东西走隐式转化变成pair类再插入。
但是emplace_back在调用的时候,这个Args并不确定的,只是用来接收参数包的。但是直接写成这样{“12345678910”, 1} 传给参数包。参数包都不知道这个是什么。这个不是pair,不要给花括号隐式类型转化成pair搞迷糊了。这个也不是initializer_list,因为initializer_list要求里面每个数据的数据类型是一样的。所以这里这样子写是一定会报错的。
所以我们在这里总结:
emplace系列的接口在插入右值的时候效率确实高一些。而且在通过emplace系列操作的时候,都不需要走隐式转化呢,也不需要提前构造。直接准备好需要的参数就可以了。这写起来简单又明了。emplace接收左值的行为又基本和push系列的行为差不多。所以以后可以多使用以下这个系列的接口,确实高效一点,还好用。