1. 右值引用和移动语义
1.1 左值引用和右值引用
在C++11之前,我们只有引用的概念,没有接触到所谓的左值引用或者是右值引用这种概念,从C++11开始,增加了右值引用的概念,那么现在我们将对引用进行一个概念上的区分。在此之前我们所说的引用都是左值引用,对于左值引用相关的内容,可以去看一看博主之前写的文章C++引用。
不管是左值引用还是右值引用,本质上都是给对象取别名
那么,怎么区别左值引用和右值引用呢?
左值是一个表示数据的表达式(一个变量名或者是解引用的指针),我们可以获取到他的地址+可以对它进行赋值,左值可以在等号的左边,右值不能在等号的左边,const修饰的变量不能被赋值,但是能够取地址。
右值也是一个表达式,如:字面量,表达式的返回值,函数的返回值,右值能够出现在赋值符号的右边,不能出现在赋值符号的左边,右值不能取地址。
左值引用就是对左值进行取别名操作,右值引用就是对右值取别名
void Test1() { //左值 int a = 1; double x = 1.1, y = 2.2; int* pb = new int(10); const int c = 2; //左值引用 int& ra = a; int*& rpb = pb; const int& rc = c; int& pvalue = *pb; //右值 10; x + y; min(x, y); //右值引用 int&& rr1 = 10; int&& rr2 = x + y; int&& rr3 = min(x, y); }
一个有趣的现象:
我们知道,右值是不能被赋值的,但是看下面这段代码
void Test2() { int x = 1, y = 2; int&& rr1 = x + y; cout << "x + y:" << x + y << endl; cout << "rr1:" << rr1 << endl; rr1 = 10; cout << "rr1" << rr1 << endl; }右值
x+y
在被右值引用之后就可以被赋值了,即变成了左值这是因为在给右值取别名之后,会被存储在一个特定的位置,然后就能取到该位置的地址,因此也就能更改次地址存放的值。如果不想让它能被能改就可以使用const修饰右值引用。当然实际应用中不会使用到这个特性,所以这个特性也就不重要
1.2 左值引用和右值引用的比较
左值引用的总结:
- 左值引用只能引用左值,不能引用右值
- const左值引用既能引用左值,也能引用右值(这个跟我们之前所说的临时变量具有常性可以对照,那个临时变量就是右值)
void Test3() { //左值引用只能引用左值,不能引用右值 int a = 10; int& ra1 = a; //int& ra2 = 10;//右值引用不能引用左值,因此这行代码报错 //const左值引用既能引用左值,也能引用右值 const int& ra3 = 10;//引用右值 const int& ra4 = a;//引用左值 }
右值引用的总结:
- 右值引用只能引用右值,不能引用左值
- move函数可以将左值变为右值,因此右值引用可以引用move之后的左值
void Test4() { //右值引用只能引用右值,不能引用左值 int&& r1 = 10; int a = 10; //int&& r2 = a;//右值引用引用左值报错 //Xcode报错内容:Rvalue reference to type 'int' cannot bind to lvalue of type 'int'(无法将左值绑定到右值引用) //右值引用能引用move之后的左值 int&& r3 = std::move(a); }
1.3右值引用的使用场景和意义
既然在C++11之前已经有了左值引用,为什么还要加上右值引用这种概念呢?不是“画蛇添足”吗?
首先我们来总结一下左值引用的好处
- 做函数参数:能够减少拷贝,提高效率,可以作为输出型参数
- 做返回值:能够减少拷贝,提高效率
虽然左值引用有以上优点,但是,如果遇到下面的状况:
//状况一 template<class T> T func(const T& val) { T ret; //... return ret; } //状况二 void Test5() { zht::string str; str = zht::to_string(-1234); }
此时ret和str出了作用域之后就会销毁,如果T是类似string的对象,就需要进行深拷贝,所以就会造成效率降低。
这里我们采用自己实现的string,能够更清晰的看到调用情况:在这个string中增加了一些输出信息
namespace zht { 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(const char* str = "") -- 构造函数" << endl; _str = new char[_capacity + 1]; strcpy(_str, str); } // s1.swap(s2) 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; _str = new char[s._capacity + 1]; strcpy(_str, s._str); _size = s._size; _capacity = s._capacity; } // 赋值重载 string& operator=(const string& s) { cout << "string& operator=(string s) -- 深拷贝 " << endl; if (this != &s) { delete[] _str; _str = new char[s._capacity + 1]; strcpy(_str, s._str); _size = s._size; _capacity = s._capacity; } 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 }; string to_string(int value) { bool flag = true; if (value < 0) { flag = false; value = 0 - value; } string str; while (value > 0) { int x = value % 10; value /= 10; str += ('0' + x); } if (flag == false) { str += '-'; } std::reverse(str.begin(), str.end()); return str; } }
运行上述情况二的代码可以看到:在过程中进行了深拷贝,这里深拷贝的代价就非常的大,也就是左值引用的短板所在,因此提出了右值引用的概念。
使用右值引用和移动语义解决上述问题:
在此之前,我们明确一个概念:在C++11中,对右值引用进行了一个分类,将其分为纯右值和将亡值两种,其中纯右值指的是内置类型表达式的值,将亡值是指自定义类型的表达式的值,所谓的将亡值也就是指生命周期将要结束的值,一般来说,匿名对象、临时对象、move后的自定义类型对象都是将亡值。
让我们思考一下,在上述情景过程中,进行深拷贝之后,原来的局部变量str是会被析构掉的,相当于我们先构造一个一摸一样的变量,然后再析构掉这个局部变量,那么我们不如直接将这个变量的资源交给另一个变量管理,这样就能够提高效率。
于是就有了移动构造这个接口:移动构造也是一个构造函数,它的参数是类型的右值引用,实际上就是把传入右值的资源转移过来,避免了深拷贝,所以称为移动构造,就是移动别人的资源来进行构造。
接下来我们来实现一下上面string的移动构造和移动赋值
// 移动构造 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中加入了这两个函数之后,再次运行刚刚的程序,就会发现函数调用改变了:深拷贝变成了移动拷贝
下面,我们来分析一下为什么会变成这样:
加入移动语义之前:
加入移动语义之后:
注:有种说法是右值引用延长了变量的生命周期,事实上这种说法是不准确的,他只是将一个变量的资源转移给了另一个变量,此变量本身的生命周期是没有变化的。如果一定要这样说的话,那可以理解成延长了这个资源的生命周期(但是资源没有生命周期这一说)。
在C++11之后,STL容器中都增加了移动构造和移动复制的接口
1.4 左值引用和右值引用的深入使用场景分析
根据上文,我们知道右值只能引用右值,但是右值一定不能引用左值吗?
在有些场景下,我们可能真的需要右值去引用左值,从而实现移动语义。
当需要右值引用一个左值的时候,可以通过move函数将左值转变为右值。在C++11中,std::move()函数在<utility>头文件中,这个函数只有一个唯一的作用就是将左值强制转换成右值。
可以看到使用move将s1从左值变成右值之后,再次插入编译器就将s1识别成将亡值,匹配到移动构造然后未查到lt中。
根据上述的例子我们知道库里面的list是支持移动构造的,我们之前也模拟实现过list,那么现在能否对之前的list进行一个改造,让其也能支持移动语义呢?
首先在这里附上之前实现的list的源码
namespace zht { template<class T> struct __list_node { __list_node* _prev; __list_node* _next; T _data; __list_node(const T& data = T()) :_data(data) , _prev(nullptr) , _next(nullptr) {} }; template<class T, class Ref, class Ptr> struct __list_iterator { typedef __list_node<T> node; typedef __list_iterator<T, Ref, Ptr> Self; node* _pnode; __list_iterator(node* p) :_pnode(p) {} Ptr operator->() { return &operator*(); } Ref operator*() { return _pnode->_data; } Self& operator++() { _pnode = _pnode->_next; return *this; } Self operator++(int) { Self tmp(*this); _pnode = _pnode->_next; return tmp; } Self& operator--() { _pnode = _pnode->_prev; return *this; } Self operator--(int) { Self tmp(*this); _pnode = _pnode->_prev; return tmp; } bool operator!=(const Self& it) { return _pnode != it._pnode; } bool operator==(const Self& it) { return _pnode == it._pnode; } }; template<class T> class list { typedef __list_node<T> node; public: typedef __list_iterator<T, T&, T*> iterator; typedef __list_iterator<T, const T&, const T*> const_iterator; iterator begin() { return _head->_next; } iterator end() { return _head; } const_iterator begin() const { return _head->_next; } const_iterator end() const { return _head; } void empty_initialize() { _head = new node(T()); _head->_next = _head; _head->_prev = _head; _size = 0; } list() { empty_initialize(); } list(size_t n, const T& val = T()) { empty_initialize(); for (size_t i = 0; i < n; ++i) { push_back(val); } } template<class InputIterator> list(InputIterator first, InputIterator last) { empty_initialize(); while (first != last) { push_back(*first); ++first; } } list(const list<T>& lt)//经典写法 { empty_initialize(); for (const auto& e : lt) { push_back(e); } } void swap(list<T>& lt) { std::swap(_head, lt._head); } list<T>& operator=(list<T>& lt) { if (this != *lt) { clear(); for (auto& e : lt) { push_back(e); } } } bool empty() { return _head->_next == _head; } void clear() { while (!empty()) { erase(--end()); } } ~list() { clear(); delete _head; _head = nullptr; } void push_back(const T& val = T()) { insert(end(), val); } void push_front(const T& val = T()) { insert(begin(), val); } iterator insert(iterator pos, const T& val = T()) { node* newnode = new node(val); node* prev = pos._pnode->_prev; node* cur = pos._pnode; prev->_next = newnode; newnode->_prev = prev; newnode->_next = cur; cur->_prev = newnode; ++_size; return iterator(newnode); } size_t size() { return _size; } iterator erase(iterator pos) { assert(pos != end()); node* prev = pos._pnode->_prev; node* next = pos._pnode->_next; prev->_next = next; next->_prev = prev; delete pos._pnode; --_size; return iterator(next); } void pop_back() { erase(--end()); } void pop_front() { erase(begin()); } void resize(size_t n, const T& val = T()) { while (n < size()) { pop_back(); } while (n > size()) { push_back(val); } } private: node* _head; size_t _size; }; }
使用我们自己实现的list执行1.4中的Test6,将会得到如下结果:
为了让我们的list也能像库里面的list一样,我们首先考虑到的就是实现push_back的右值引用版本,由于在push_back中调用了insert,所以insert也需要增加右值引用的版本,同样的在node的构造中也需要增加右值引用版本的构造函数,所以增加的函数如下:
//template<class T> struct __list_node __list_node(T&& data) :_data(data) , _prev(nullptr) , _next(nullptr) {} //template<class T> class list void push_back(T&& val) { insert(end(), val); } iterator insert(iterator pos, T&& val) { node* newnode = new node(val); node* prev = pos._pnode->_prev; node* cur = pos._pnode; prev->_next = newnode; newnode->_prev = prev; newnode->_next = cur; cur->_prev = newnode; ++_size; return iterator(newnode); }
那么现在再来尝试执行以下,发现没有变化,这是为什么呢?
(这里大家可以自己去实践一下)通过调试可以发现确实调用了右值引用版本的push_back,但是继续往下面调试就发现调用的是左值版本的insert,这是什么原因呢??
因为右值引用之后变量本身是左值,所以这里val的属性就是一个左值,所以当然会匹配到左值版本的insert,所以这里在传参的时候需要将这个val的属性变成右值,这里可以使用move来改一下传参之后的属性,然后再编译运行,发现还是和原来的结果一样,这是因为函数套函数,每一层都需要将参数属性改为右值,非常的麻烦,如果我们将所有的参数都用move修改一下之后:
__list_node(T&& data) :_data(move(data)) , _prev(nullptr) , _next(nullptr) {} void push_back(T&& val) { insert(end(), move(val)); } iterator insert(iterator pos, T&& val) { node* newnode = new node(move(val)); node* prev = pos._pnode->_prev; node* cur = pos._pnode; prev->_next = newnode; newnode->_prev = prev; newnode->_next = cur; cur->_prev = newnode; ++_size; return iterator(newnode); }
再次运行之前的代码,可以看到已经实现了库里面的效果,这里多了一个string的构造和移动构造是因为初始化lt的时候调用的,由于我们string和list的实现和库里面还是有些许不同点的。
至此,我们就完成了自己的list中对移动语义的支持了。