C++11的新特性
1 左值与右值
C++中,一个表达式不是右值就是左值。C语言中:左值可以位于赋值对象的左边,右值则不能。在C++中就没有这么简单了。在C++中的左右值可以通过是否可以取地址来区分:
- 左值表示一个占据内存中可识别位置的一个对象,有可能是一个表达式。更进一步地,可以对左值取地址
- 右值即不能进行取地址的值或表达式。包括常量,加减乘除等表达式,临时对象。
PS:左值和右值在内存中都是有地址的,只有左值可以取地址!
左值包括变量名,解引用的指针的等。下面是比较经典的左值,他们都可以进行取地址操作!
注意左值引用和右值引用都是左值
int a = 1; int* p = &a; int** pp = &p; const int b = 2;
右值一般是常量,表达式,临时变量 ,对于一个常量肯定是无法取地址的!
&10; &(1 + 1); &string("111");
当一个对象被作为右值进行使用时,用的是对象的值(内容);用做左值时,实际使用的是对象的身份(在内存中的位置)
2 左值引用和右值引用
左值引用就是对左值进行取别名:
int a = 1; int& ra = a; int* p = &a; int*& rp = p
右值引用就是对右值进行取别名:
int&& rra = (1 + 1); string&& rrb = string("111"); string&& rrb = to_string(111);
左值引用不可以给右值取别名,但是const左值引用可以
const int& ra = 10; const string& rb = string("111");
右值引用不可以对左值取别名,但是可以给move后的左值取别名.move也是C++11新加入的特性,我们后面讲。
3 引用的意义
在之前,我们使用引用的目的是什么?是为了减少拷贝,提高性能。
void func1(const string& s);//传引用参数 string& func2(const string& s);//传引用返回
但是,引用返回也会出现一些问题,比如一个函数返回临时变量的引用,这时就会出错。临时变量的生命周期只在func2
函数,func2
函数返回一个临时变量的引用,在函数执行结束,临时变量就会进行销毁!右值引用也无法解决生命周期的问题!
那右值引用的意义在哪里呢???
我们来看一个情景:(bit::string是自己写的string类,方便看效果)
bit::string ret1 = bit::to_string(1234);
这个会进行几次深拷贝?
按理来说,应该会会进行两次拷贝构造,首先拷贝构造临时变量,然后ret1在拷贝构造。但是编译器进行了一个优化,实际上只进行了一次拷贝构造。
当我们把这个表达式分开写,就不会进行优化了,没有办法合二为一
bit::string ret1; ret1 = to_string(1234);
这两种情况的拷贝的代价都挺大,有没有一种简单的解决办法来避免进行深拷贝?
这里可不能使用左值引用,因为临时变量在该行函数结束就销毁,在主函数里会直接挂掉!使用右值引用会直接报语法错误,因为在to_string
中返回值是一个左值,左值是不能被右值引用的!
如果将该左值进行move()
他就可以被右值引用。
bit::string&& to_string(int val) { bit::string str; //... //处理 //... return move(str); }
这样运行依旧会挂掉,因为右值引用也是别名,**无法解决生命周期的问题!**这里左值引用和右值引用是没有区别的!
栈桢图是这样的:
编译器优化后会只进行一次拷贝构造ret1
,但还是进行了深拷贝!
所以这个深拷贝的问题无法通过左值引用或者右值引用来解决!所以就有了移动语义!
4 移动语义
4.1 移动构造与移动赋值
C++11中就加入了一个针对右值引用的拷贝构造 — 移动构造!
PS:左值引用
是拷贝构造 ,右值引用
是移动构造!— 构成函数重载
//拷贝构造 --- const 左值可以接收左值和右值 string(const string& s) : _str(new char[s._capacity + 1]) ,_size(0) ,_capacity(0) { cout << "string(const string& s = "") --- 深拷贝" << endl; strcpy(_str, s._str); _size = s._size; _capacity = s._capacity; } //移动构造 string(string&& s) :_str(nullptr) { cout << "string(string&& s) --- 移动构造" << endl; swap(s); }
上面我们已经知道了右值引用的的一般类型:匿名对象 ,传值返回的临时对象
int&& rra = (1 + 1); string&& rrb = string("111"); //匿名对象 string&& rrb = to_string(111);//传值返回的临时对象
这里进行一下跟细致的区分:纯右值和将亡值
- 纯右值:内置类型的右值引用是纯右值 :
int&& a == 10 ;int&& b = (1 + 1);
- 将亡值:自定义类型的右值引用,生命周期只有一行。
分析一下左值的拷贝构造,因为传入的是一个左值,其有自己的内存中的位置,不能修改!必须进行深度的拷贝。但是如果是一个右值,是一个临时变量的右值引用(将亡值),其生命周期就一行,就可以对其进行修改!
string(string&& s) :_str(nullptr) { swap(s);//直接将亡值的替换就可以 }
直接进行替换不就可以了?反正也是将亡的,不用他就直接销毁了,怪可惜的!
**移动构造就是用来解决这个问题的!**通过to_string
返回的对左值的move
的右值引用,就会调用到移动构造,就避免了深拷贝!(PS:不显式加上move
也会调用到移动构造,编译器的优化很强,会强行识别成右值进而进行移动构造!)
移动构造的代价是很低的,因为是使用的同一块地址,没有开辟新的空间!注意移动构造只能对右值进行处理!必须是将亡值才有可以进行移动构造!
再来看个例子
int main() { string s1("1111111111111111111"); string s2 = s1; string s3 = move(s1); }
s2是拷贝构造,开辟了新空间。s3进行移动构造,导致S1和空的s3进行了交换,s1变成了空!
对于分开书写的两行代码,编译器也无法进行优化,会进行两次深拷贝!而有了移动构造和移动赋值,都是对一块资源的移动,成本很低! 移动构造彻底解决传值返回的性能问题!!!
这里有点像拷贝的现代写法,但是拷贝的现代写法的本质还是开辟空间!
我们来看一个实际使用中的代吗:杨辉三角
class Solution { public: vector<vector<int>> generate(int numRows) { vector<vector<int>> vv(numRows) ; for(int i = 0;i < numRows ;i++){ vv[i].resize(i + 1); if(i == 0) { vv[i][0] = 1; } else if(i == 1){ vv[i][0] = vv[i][1] = 1; } else{ vv[i][0] = vv[i][i] = 1; for(int j = 1 ;j < i ;j++){ vv[i][j] = vv[i - 1][j - 1] + vv[i - 1][j]; } } } return vv; } }; int main() { vector<vector<int>> ret; ret = Solution.generate(1000); return 0; }
这段代码是很坑的,我们来分析一下:
- 首先杨辉三角中的核心是二维数组:
vector<vector<int>>
,这就注定其深拷贝的时间复杂度是O(n^2)。 - 然后返回值是传值返回?这在调用的时候很明显会创建临时变量,就会进行深拷贝!
这就导致使用的之后传入一个较大数1000
,会进行两次深拷贝,每次深拷贝的代价都很大!
所以实践中不能写这样的代码,可以改成:
class Solution { public: void generate(int numRows , vector<vector<int>>& vv) { } };
这样可以避免临时变量的深拷贝。在C++11之前都是这样保证效率,现在当然最好还是使用右值引用+移动赋值
来解决。直接进行资源的转移,避免不必要的深拷贝!编译器会自动将返回值识别成右值,进而进行移动辅助!
4.2 区分现代写法与移动语义
我们先来看赋值重载的现代写法:
string& operator=(const string& s) { //现代写法 string tmp(s); swap(tmp); return *this; }
现代写法并没有减小成本,本质还是进行了深拷贝,传入的还是左值。没有性能的提升。
再来看移动赋值
string& operator=(string&& s) { swap(s); return *this; }
因为传入的是这里只进行了一次资源的交换,没有进行深拷贝,对性能有很大的提升!
4.3 实践中落实移动语义
前面我们说过:左值引用和右值引用都是左值。我们可以来验证一下:
using namespace std; //函数重载 void func(string& s) { cout << "func(string& s) --- 左值引用" << endl; } void func(string&& s) { cout << "func(string& s) --- 右值引用" << endl; } int main() { string s("111"); string& s1 = s; string&& s2 = string("111"); func(s1); func(s2); }
我们运行看看:
左值引用和右值引用都匹配到了左值引用的函数,证明了左值引用和右值引用都是左值。只有这样才能逻辑自洽!
回看移动赋值,在里面to_string
的返回值成为string&& s
,里面进行了一步swap(s)
。我们又知道右值本身是不能被改变的,如果右值引用作为了一个右值的别名是右值的话,那么还是不能进改变,那如何进行资源的转移呢?
只有右值引用本身是左值,才能实现移动构造和移动赋值中的资源转移!!!
这实现了逻辑的自洽:右值引用的属性如果是右值,那么移动构造和移动赋值要进行转移资源的语法就是矛盾的,右值是不能进行改变的(可以理解为右值自带const
属性),右值是有空间存储的,只是语法不允许取地址,但是是可以想办法取到的!
强转和再次引用(先转换为左值)都可以取到
string&& s2 = string("111"); string& s3 = s2; string& s4 = (string&)string("111");
理解上述内容,接下来我们就来看List
中如何使用移动语义
我们来看push_back()
C++11中增添了右值引用版本的
我们来底层中来细细品味:
我们进行插入string是进行了一次构造和深拷贝,这是list内部push_back()
中创建新节点中会创建一个string,然后进行拷贝。里面s1是左值,所以进行的是深拷贝!!!我们换一种方式
这样在节点创建中创建string进行了一次构造,然后就是移动构造了,因为这里是匿名对象,是一个将亡值,编译器自动匹配了移动构造!所以我们在平时写代码中使用匿名对象会使效率更高!!!
然后我们来自己实现一下:
首先我们看到我们初始化一个list会进行一次构造和深拷贝,这是因为头结点的缘故。而STL库中的是使用内存池(没有初始化),所以不会打印出来。
我们赶紧来看push_back
,如果只有一个左值引用的版本,无论左值还是右值都只能调用这个。我们加一个右值版本:
void push_back(const T& x = T()) { //找尾 insert(end(), x); } void push_back(T&& x) { //找尾 insert(end(), x); }
我们试试
哎嘿!?怎么插入右值和插入左值都是深拷贝啊?其实我们看一下push_back
中调用的insert
就明白了。push_back
支持右值了,可是insert
还没有右值的版本啊!我们还要补充insert
的右值版本
//右值版本 void insert(iterator pos = begin(), T&& x = T()) { Node* prev = pos._node->_prev; Node* next = pos._node; Node* node = new Node(x); node->_prev = prev; node->_next = next; prev->_next = node; next->_prev = node; _size++; }
再来看看:
哎?还是这样?为什么呢?我们在分析分析insert
内部,其中的Node* node = new Node(x);
这一步会调用node
的构造函数,而node
还没有支持右值引用的移动构造啊!所以我们还要为节点增添一个移动构造
ListNode(T&& x ) : _next(nullptr), _prev(nullptr), _data(x) {}
这样移动构造内部会调用_data
的构造,而这里_data
是string
我们已经写好了移动构造,那这次应该就可以了吧?
答案是 NO!!!
还记得上面说过:左值引用和右值引用都是左值!
第一层的push_back()
是可以调用到右值引用的版本,第二次的insert()
的第二个参数无论是左值引用还是右值引用,都只会调用左值版本?这要怎么解决呢?我们可以进行move
!
void push_back(T&& x) { insert(end(), move(x)); } void insert(iterator pos = begin(), T&& x = T()) { Node* prev = pos._node->_prev; Node* next = pos._node; Node* node = new Node(move(x)); node->_prev = prev; node->_next = next; prev->_next = node; next->_prev = node; _size++; } //.... ListNode(T&& x ) : _next(nullptr), _prev(nullptr), _data(move(x)) {}
这样经过每一层的move
保证了每次传递的都是右值:
我们就得到了想要的结果!!!
PS:对于内置类型或者Data来中,左值和右值是没有区别的,他们不会涉及到深拷贝的问题!使用涉及深拷贝的自定义类型才会涉及移动构造和移动赋值!
5 万能引用和完美转发
这里在介绍一个新语法:完美转发。它也可以上述做到move
的效果,甚至更好!我们最好是使用完美转发!
template<typename T> void PerfectForward(T&& t) { Fun(t); } int main() { PerfectForward(10); // 右值 int a; PerfectForward(a); // 左值 PerfectForward(std::move(a)); // 右值 const int b = 8; PerfectForward(b); // const 左值 PerfectForward(std::move(b)); // const 右值 return 0; }
上面的PerfectForward(T&& t)
被叫做万能引用(引用折叠),虽然看上去是一个右值引用,但是他可以随机应变:传左值就是左值引用,穿右值就是右值引用。我们验证一下
可以看到,不是对应匹配的。因为还是那个原因:左值引用和右值引用都是左值!!!而引用折叠虽然会帮助我们实例化出:
void PerfectForward(int&& t) { Fun((int&&)t); } void PerfectForward(int& t) { Fun((int&)t); } void PerfectForward(const int&& t) { Fun((const int&&)t); } void PerfectForward(const int& t) { Fun((const int&)t); }
但是由于右值引用是左值,所以根本调用不到右值版本!!!
所以这里如果使用move
,行不行呢?我们试试:
move
也无法解决这个问题,因为加上move
之后,所以实例化的函数也都带有了func(move(t))
,所有的都变成了右值,而我们想要的是该左值就是左值,该右值就是右值,所以就有了完美转发 — std::forward<T>(t)
template<typename T> void PerfectForward(T&& t) { func(forward<T>(t)); }
完美解决:
完美转发就做到了:**完美转发在传参的过程中保留对象原生类型属性!**完美转发是在函数模版里面帮助辅助传参的!
- 实参传左值,就推导成左值引用
- 实参传右值,就推导成右值引用
完美转发本质上类似进行了一次强转!可以简单的这样理解:
template<typename T> void PerfectForward(T&& t) { func((T&&)t); }
上面的list
也可以将move
该成完美转发了!