右值引用的使用场景和意义
虽然使用const修饰的左值引用能够同时引用左值和右值 但是左值引用终究是存在一些缺陷的 而C++11提出的右值引用正是用来解决这些缺陷的
为了更好的暴露出左值引用的缺陷 我们写出一个简单string类出来
代码如下
namespace shy { class string { public: typedef char* iterator; // begin迭代器返回第一个元素 iterator begin() { return _str; } // end迭代器返回最后一个元素后一个元素的位置 iterator end() { return _str + _size; } // 构造函数 string(const char* str = "") { _size = strlen(str); // 初始化设置字符串大小 _capacity = _size; _str = new char[_capacity + 1]; // 这里要多开一个空间来存储/0 strcpy(_str, str); } // 交换两个对象的数据 void swap(string& s) { ::swap(_str, s._str); ::swap(_size, s._size); ::swap(_capacity, s._capacity); } // 拷贝构造函数 string (const string& s) :_str(nullptr) , _size(0) , _capacity(0) { cout << "string(const string& s)" << endl; string tmp(s._str); // 调用构造函数 swap(tmp); // 交换私有成员变量 } // 赋值运算符重载(现代写法) string& operator=(const string& s) { cout << "string& operator=(const string& s)" << endl; string tmp(s); //拷贝构造出一个临时变量 swap(tmp);// 交换这个两个对象 return *this; // 返回左值 } // 析构函数 ~string() { delete[] _str; // 释放str的空间 _str = nullptr; _size = 0; _capacity = 0; } //[]运算符重载 char& operator[](size_t i) { assert(i < _size); return _str[i]; // 返回左值引用 } // 改变容量 大小不变 void reserve(size_t n) { if (n > _capacity) { char* tmp = new char[n + 1]; strncpy(tmp, _str, _size + 1); // 这里不使用strcpy的原因是字符串中哟i可能出现/0 delete[] _str; ::swap(_str,tmp); _capacity = n; // 容量改变 } } // 尾插字符 void push_back(char ch) { // 首先判断容量是否足够 if (_size >= _capacity) { reserve(_capacity == 0 ? 4 : 2 * _capacity); } // 尾插到最后 最后加上\0 _str[_size] = ch; _str[_size + 1] = 0; _size++; } //+=运算符重载 string& operator+=(char ch) { push_back(ch); return *this; } // 返回c类型的字符串 const char* c_str() const //这里加const是修饰this指针 让它的权限变成只读 为了防止后面的只读对象调用这个函数 { return _str; } private: char* _str; size_t _size; size_t _capacity; }; }
左值引用的使用场景
在说明左值引用的缺陷之前 我们先来看它的使用场景
- 做参数 防止传参时进行拷贝构造
- 做返回值 防止返回时对返回对象进行拷贝构造
void func1(shy::string s) {} void func2(const shy::string& s) {} int main() { shy::string s("hello world"); func1(s); // 值传参 func2(s); // 左值引用传参 s += 'X'; // 左值引用返回 return 0; }
首先是 func1 它传递的参数是形式参数 他是实际参数的一份临时拷贝
再者是 func2 它传递的参数是s的别名 是左值引用
最后是+=的返回 它返回的也是一份左值引用
我们都知道string的拷贝是深拷贝 深拷贝的代价是很高的 所以说这里的左值引用效果很明显
左值引用的缺陷
左值引用虽然能避免不必要的拷贝操作 但是缺不能完全避免
- 左值引用做参数 能够完全避免传参时的拷贝操作
- 左值引用做返回值 不能完全避免函数对象返回时的拷贝操作
如果函数返回对象是一个局部变量 那么该变量出了局部作用域就会被销毁了
这种情况下不能使用左值引用作为返回值 只能传值返回 这就是左值引用的短板
比如说我们实现一个to_string函数 将字符串转化为int类型 此时它的返回值就必须要是值拷贝 如果使用左值引用返回就会返回一个销毁的局部变量
代码表示如下
namespace shy { 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 += (x + '0'); } if (flag == false) { str += '-'; } std::reverse(str.begin(), str.end()); return str; } }
我们在调用to_string函数返回的时候会调用拷贝构造函数
C++11提出右值引用就是为了解决左值引用的这个缺陷
但是它的解决方法并不是单纯的将右值引用作为返回值
右值引用和移动语义
右值引用和移动语句解决上述问题的方式就是增加移动构造和移动赋值方法
移动构造
移动构造是一个构造函数 它的参数是右值引用类型的
移动构造的本质就是将传入右值的资源窃取过来 占为己有
这样就避免进行深拷贝 所以叫他移动构造
我们在string类中增加一个移动构造函数 该函数要做的就是通过调用swap函数将传入右值的资源窃取过来
为了能够知道移动构造是否被调用 我们这里增加一条打印语句
代码表示如下
// 移动构造 string(string&& s) :_str(nullptr) , _size(0) , _capacity(0) { cout << "string(string&& s)" << endl; swap(s); }
移动构造和拷贝构造的区别:
- 在没有增加移动构造之前 由于拷贝构造使用的是const左值引用来接受参数 因此无论拷贝构造的是左值还是右值 都会调用拷贝构造函数
- 增加移动构造之后 由于移动构造采用的是右值引用来接受参数 因此如果拷贝构造对象时传入的是右值 那么就会调用移动构造
- 拷贝构造进行的是深拷贝 而移动构造只需要调用swap函数进行资源转移即可 因此移动构造的代价比拷贝构造的代价小很多
给string类增加移动构造之后 对于返回局部string类对象的函数 返回string类对象的时候会调用移动构造进行资源的转移 不会像原来一样进行深拷贝了
演示效果如下
int main() { shy::string s = shy::to_string(123); return 0; }
这里说明一下
- 对于to_string当中返回局部的string对象是一个左值 一个临时变量 由于它出了局部作用域就会被销毁 被消耗的值我们将它叫做 “将亡值” 匿名对象也可以被称为 “将亡值”
- 既然 “将亡值” 马上就要被销毁了 那还不如直接把它的资源转移给别人用 因此对待这种 “将亡值” 编译器会将它识别为右值 这样就可以匹配搭配参数为右值的移动构造函数
编译器优化
当一个函数在返回局部对象时 会先用局部对象拷贝出一个临时对象 然后再用这个临时拷贝的对象再来接受我们返回值的对象
效果图如下
在C++11标准出来之前 对于深拷贝的类会进行两次深拷贝 但是大部分编译器为了提高效率都对这种情况进行了优化 优化成了一次深拷贝 效果图如下
但是并不是所有编译器都做这个优化的
在C++11标准出来之后 编译器的这个优化仍然起到了作用
- 如果不进行优化 这里应该会调用两次移动构造
- 如果进行了优化 这里就只会进行一次移动构造了
- 但是实际上有了移动构造之后这里优化的作用就不大了 因为移动构造本质上就是资源转移 很轻松就能做到 资源消耗不大
但是我们如果不是用函数的返回值来构造出一个对象 而是用一个之前已经定义过的对象来接受函数的返回值 这里就无法进行优化了
示例图如下
我们来看看实际代码运行过程中是什么样子的
shy::string s; s = shy::to_string(123);
当函数返回局部对象的时候 会先用移动构造构造出一个临时对象 然后再调用赋值运算符重载函数将这个临时对象赋值给接收函数返回值的对象 而赋值运算符重载函数本质上是对于拷贝构造的复用 所以说最后还会调用一次拷贝构造函数
- 编译器并没有对这种情况进行优化 因此在C++11标准出来之前 对于深拷贝的类来说这里就会存在两次深拷贝 因为深拷贝的类的赋值运算符重载函数也需要以深拷贝的方式实现
- 但在深拷贝的类中引入C++11的移动构造后 这里仍然需要再调用一次赋值运算符重载函数进行深拷贝 因此深拷贝的类不仅需要实现移动构造 还需要实现移动赋值
这里需要说明的是 对于返回局部对象的函数 就算只是调用函数而不接收该函数的返回值 也会存在一次拷贝构造或移动构造 因为函数的返回值不管你接不接收都必须要有 而当函数结束后该函数内的局部对象都会被销毁 所以就算不接收函数的返回值也会调用一次拷贝构造或移动构造生成临时对象
移动赋值
移动赋值是对于赋值运算符重载的一个重载函数 该函数的参数是右值引用类型的
移动赋值和移动构造一样 都是将临时对象的资源窃取过来据为己有 这样就避免了深拷贝
在当前的string类中增加一个移动赋值函数 就是调用swap函数将传入右值的资源窃取过来
代码表示如下
// 移动赋值 string& operator= (string && s) { cout << "string& operatpr=(string&& s)" << endl; swap(s); return *this; }
移动赋值和赋值运算符重载的区别
- 在没有增加移动赋值之前 赋值运算符重载是使用const左值引用来接受参数 因为无论赋值时传入的是左值还是右值 都会调用拷贝构造函数
- 增加移动赋值之后 由于移动赋值采用的是右值引用来接受参数 因此如果移动赋值传入的是右值 那么就会调用移动赋值
- 原本赋值时是调用拷贝构造进行了深拷贝 而移动赋值只需要调用swap函数进行资源转移即可 因此移动赋值的代价比赋值运算符重载小的很多
写了移动赋值之后我们刚刚写的代码就会变成两次swap了
STL中的容器
C++11标准出来之后 所有的STL容器都增加了移动构造和移动赋值
以string容器为例
移动构造
移动赋值
右值引用引用左值
在上面我们也说过了 右值引用不能直接引用左值 但是可以通过move函数间接的引用
move函数这个名字很具有迷惑性 实际上它并不能移动过任何值 它唯一的功能就是将一个左值强制转化为右值引用 然后实现移动语义
move函数的定义如下
template<class _Ty> inline typename remove_reference<_Ty>::type&& move(_Ty&& _Arg) _NOEXCEPT { //forward _Arg as movable return ((typename remove_reference<_Ty>::type&&)_Arg); }
这里有两点需要说明一下
- move函数中_Arg参数的类型不是右值引用而是万能引用 万能引用和右值引用的形式一样 但是右值引用是需要确定的类型
- 一个左值被move之后它的资源有可能被转移给别的数据了 所以说慎用被move后的左值
右值引用的其他使用场景
插入函数
C++11标准出来之后 STL中的容器除了增加移动构造和移动赋值之外 STL容器插入接口函数也增加了右值引用版本
我们这里以List容器的push_back函数为例
右值引用版本插入函数的意义
如果list容器中储存的是string对象 那么在调用插入函数的时候 会有以下几种插入方式
list<shy::string> ls; shy::string s("1111"); ls.push_back(s); // 拷贝构造 ls.push_back("2222"); // 移动构造 ls.push_back(shy::string("3333")); // 移动构造 ls.push_back(std::move(s)); // 移动构造
我们可以发现 如果我们插入的是右值 那么就是调用移动构造进行资源转移
演示效果如下
在C++11之前list容器的push_back接口只有一个左值引用版本的插入 因此在构造节点的时候 左右值都只能匹配到string的拷贝构造函数进行深拷贝
- 在C++11版本之后 string类提出了移动构造函数 此时如果传入的值是右值就会采用移动构造 这时候就会进行一个资源转移而不是拷贝构造
- 我们上面的例子中 传入的第一个参数是左值 后面的参数全部是右值 所以说第一次是拷贝构造 后面的全部是移动构造了