C++进阶(4)——C++11右值引用和移动语义
目录
基本介绍
左值和右值
左值引用和右值引用
引用延长生命周期
左值和右值的参数匹配
右值引用和移动语义
左值引用使用场景的回顾
移动构造和移动赋值
右值引用和移动语义解决传值返回的问题
STL容器中的相关改变
类型分类
引用折叠
万能引用的使用
完美转发
完美转发的特性
基本介绍
其实我们在C++98的时候我们的语法里面就有了引用这一语法了,而我们在C++11中新增加了右值引用这一概念,C++11之后我们们之前所学习的引用统一都是所谓的左值引用。但是我们这里要清楚这两个引用的本质都是一样的,都是给对象取了一个别名。
左值和右值
左值
左值是一个表示数据的表达式(如变量名或是解引用的指针),一般是有持久状态的,存储在内存中。
特性:
1、我们可以获取它的地址,可以被赋值,但是当我们使用const修饰左值,我们就不能给他赋值了,但是仍然可以取出地址。
2、左值可以出现在赋值符号的两边。
示例代码:
#include <iostream>
using namespace std;
int main() {// 左值可以被取地址// 下面的变量都是常见的左值int* p = new int(1);int x = 1;const int y = x;*p = 2;string s("hello world!");s[0] = 'H';cout << &y << endl; // 可以取地址return 0;
}
右值
右值也是一个数据的表达式,要么是字面值常量,要么就是表达式求值过程中创建出来的临时对象等。
特性:
1、右值不能取地址,同时也不能修改。
2、右值只能出现在我们赋值符号的右边,不能是左边。
示例代码:
#include <iostream>
using namespace std;
int main() {// 右值不能取地址double x = 1.2, y = 3.4;// 下面就是几个比较常见的右值了1;x + y;fmin(x, y);string("hello world!");cout << &1 << endl; // 不能取地址,其他的也是,会报错return 0;
}
敲黑板:
1、有趣的是,我们的左值和右值在英文的简写是lvalue和rvalue,这就很容易理解成left value和right value,但是我们现代C++中,解释我们的lvalue是loactor vlaue的缩写(意指存储在内存中,有明确存储地址可以取地址的对象),解释rvalue是read的缩写(意指哪些可以提供数据值的,存储在寄存器中的变量等)。
2、右值本质就是临时变量或是常量值,比如1就是常量值,我们的x + y就是临时变量(临时变量就是常量),这些都是右值。
3、我们这里区分右值和左值主要还是看能不能取地址。
左值引用和右值引用
左值引用我们之前就在使用了,类似于Type& r1 = x;右值引用是C++11新增的特性,类似于Type& r2 = y;我们的这两个引用本质上都是取别名(重点)。
左值引用
左值引用顾名思义就是对左值的引用(bushi),通常我们都是使用一个‘&’这个符号来标明。
代码示例:
#include <iostream>
using namespace std;
int main() {// 这些都是我们常见的左值int* p = new int(1);int x = 1;const int y = x;*p = 2;string s("hello world!");s[0] = 'H';double m = 1.2, n = 3.4;// 左值引用int*& r1 = p;int& r2 = x;int& r3 = *p;string& r4 = s;char& r5 = s[0];return 0;
}
右值引用
右值引用顾名思义就是对于右值的引用了(bushi),通常我们都是使用两个‘&’这个符号来标明。
代码示例:
#include <iostream>
using namespace std;
int main() {double m = 1.2, n = 3.4;int&& rr1 = 1;double&& rr2 = m + n;double&& rr3 = fmin(x, y);string&& rr4 = string("hello world");return 0;
}
接下来就是两个比较重要的问题了:
左值引用可以引用右值吗?
这里我们其实是不可以直接引用的,但是有了const可以,说明如下:
1、因为我们的右值是不可修改的,直接使用我们的左值引用会产生权限被放大的问题,因为我们的左值引用是可以被修改的。
2、我们这里可以在左值引用前面加上我们的const关键字,这样就不会产生所谓的权限放大的问题,因为const左值引用是不能修改的。
示例代码如下:
#include <iostream>
using namespace std;
int main() {double m = 1.2, n = 3.4;int&& rr1 = 1;const int& rx1 = 10;const double& rx2 = n + m;const doubel& rx3 = fmin(n, m);const string& rx4 = string("hello world!");return 0;
}
右值引用可以引用左值吗?
这里其实也是不可以的,但是我们的右值引用可以引用move(左值)。说明如下:
1、右值引用只能引用右值,不能引用左值。
2、我们的右值引用可以引用我们move之后的左值。
示例代码如下:
#include <iostream>
using namespace std;
int main() {int* p = new int(1);int x = 1;const int y = x;*p = 2;string s("hello world!");s[0] = 'H';double m = 1.2, n = 3.4;int&& rrx1 = move(x);int*&& rrx2 = move(p);int&& rrx3 = move(*p);string&& rrx4 = move(s);return 0;
}
说明:
我们这里的move是库里面的一个函数模板,本质就是对类型进行了强制类型的转换。
引用延长生命周期
我们这里的右值引用可以用来给我们的临时对象延长其生命周期,const左值引用也是可以延长临时对象的生命周期的,但是这些对象都是不能修改的。
示例代码:
#include <iostream>
using namespace std;
int main() {string s1 = "hello";const string& r1 = s1 + s1; // 使用const左值引用延长了生命周期string&& r2 = s1 + s1; // 使用了右值引用延长了生命周期return 0;
}
左值和右值的参数匹配
在我们的C++98中,我们实现一个const左值引用作为参数的函数可以匹配实参传递是左值和右值。在我们的C++11之后,我们分别重载了左值引用、const左值引用、右值引用作为形参的函数f,那么我们的匹配规则就是:
1、左值匹配f(左值引用)
2、const左值匹配f(const 左值引用)
3、右值匹配f(右值引用)。
右值引用变量在我们的表达式中的属性是左值,这个设计非常的怪,但是后面我们就知道它的价值所在了。
示例代码:
#include <iostream>
using namespace std;
void f(int& x) {cout << "左值引用" << endl;
}
void f(const int& x) {cout << "const左值引用" << endl;
}
void f(int&& x) {cout << "右值引用" << endl;
}
int main() {int i = 1;const int j = 2;f(i);f(j);f(3); // 有f(int&& x)就调用f(int&& x),没有就调用f(const int& x)f(move(i));int && x = 1;f(x);f(move(x));return 0;
}
测试效果:
右值引用和移动语义
左值引用使用场景的回顾
左值引用主要是在函数中左值引用传参和左值引用传返回值来减少拷贝,同时还可以使用引用的特性来修改实参和返回对象的值。左值引用已经解决了绝大多数的拷贝效率问题,同时我们这里就不在说明左值引用的场景了,之前我们在实现一些容器的使用已经用的很多了,这里就不在赘述了,这里我们还是来介绍一些不能使用传入左值引用返回的栗子,比如我们的addStrings和generate函数:
addSTtrings函数:
class Solution {
public:// 传值返回需要拷贝string addStrings(string num1, string num2) {string str;int end1 = num1.size() - 1, end2 = num2.size() - 1;int next = 0;while (end1 >= 0 || end2 >= 0) {int val1 = end1 >= 0 ? num1[end1--] - '0' : 0;int val2 = end2 >= 0 ? num2[end2--] - '0' : 0;int ret = val1 + val2 + next;next = ret / 10;ret = ret % 10;str += ('0' + ret);}if (next == 1) str += '1';reverse(str.begin(), str.end());return str;}
};
generate函数:
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, 1);}for (int i = 2; i < numRows; ++i) {for (int j = 1; j < i; ++j) {vv[i][j] = vv[i-1][j-1] + vv[i-1][j];}}return vv;}
};
移动构造和移动赋值
移动构造
移动构造顾名思义就是一种特殊的构造函数(bushi),类似于我们的拷贝构造函数,移动构造函数要求第一个参数是该类型的右值引用,如果有了其他的参数则这些额外的参数必须要有缺省值才行。
我们只有对于像string/vector这样的深拷贝的类或是包含了深拷贝的成员变量的类,移动构造才有意义,因为我们的移动构造的第一个参数就是右值引用类型,实现的本质就是要“窃取”我们引用的右值对象的资源。
代码示例:
namespace xywl {class string {public:void swap(string& s) {::swap(_str, s._str);::swap(_size, s._size);::swap(_capacity, s._capacity);}string(string&& s) {cout << "string(string&& s) -- 移动构造" << endl;swap(s);}private:char* _str;size_t _size;size_t _capacity;};
}
移动构造和拷贝构造的区别:
1、之前我们实现的拷贝构造实现的是const左值引用作为参数,但是不论传入的是我们的右值还是左值都是调用我们的拷贝构造,但是有了移动构造之后,我们传入的值是右值的时候我摸就会使用移动构造了。
2、我们的拷贝构造函数实现的是深拷贝,我们的移动构造则是使用的资源的“窃取”,所以移动构造要比我们的拷贝构造的代价小。
移动赋值
移动赋值是一个赋值运算符的重载,它和我们的拷贝赋值函数类似,基本上和上面的移动构造雷同,要求第一个参数是该类型的右值引用。
同时也是对于像string/vector这样的深拷贝的类或者是包含深拷贝的成员变量的类,移动赋值才有了意义,这里也是讲我们本质也是讲我们右值对象的资源“窃取”过来。
代码示例:
namespace xywl {class string {public:void swap(string& s) {::swap(_str, s._str);::swap(_size, s._size);::swap(_capacity, s._capacity);}string& operator=(string&& s) {cout << "string& operator=(string&& s) -- 移动赋值" << endl;swap(s);return *this;}private:char* _str;size_t _size;size_t _capacity;};
}
移动赋值和拷贝赋值的区别:
1、我们这里在没有加入移动赋值之前,由于我们使用的拷贝赋值采用的是const左值引用,所以无论赋值传入的是左值还是右值,都是会调用原来的拷贝赋值的,但是有了移动赋值之后,我们在接收了传入的右值之后,我们就会调用移动赋值函数。
2、我们之前实现的拷贝赋值是深拷贝,但是我们的移动赋值操作只需要调用我们的swap函数进行资源的转移操作,所以代价更小。
右值引用和移动语义解决传值返回的问题
我们这里想要说的是关于编译器对于传值返回的优化问题。
示例代码如下:
#include <iostream>
#include <string>
#include <algorithm>
using namespace std;namespace xywl {string addStrings(string num1, string num2) {string str;int end1 = num1.size() - 1, end2 = num2.size() - 1;int next = 0;while (end1 >= 0 || end2 >= 0) {int val1 = end1 >= 0 ? num1[end1--] - '0' : 0;int val2 = end2 >= 0 ? num2[end2--] - '0' : 0;int ret = val1 + val2 + next;next = ret / 10;ret = ret % 10;str += ('0' + ret);}if (next == 1)str += '1';reverse(str.begin(), str.end());cout << "****************************" << endl;return str;}
}// 场景1
int main() {xywl::string ret = xywl::addStrings("11111", "2222");cout << ret.c_str() << endl;return 0;
}// 场景2
// int main() {
// bit::string ret;
// ret = bit::addStrings("11111", "2222");
// cout << ret.c_str() << endl;
// return 0;
// }
我们这里对于场景一和二主要是针对C++11之前和之后的区别进行说明:
场景一:
C++11之前:
右值对象构造,只有拷贝构造,没有移动构造的场景
针对于我们的场景一,如果不优化的话就是我们的左边的情况了,进行两次的拷贝构造;如果优化的话就是右边的情况,进行合并两次的拷贝构造,一次拷贝构造就可以完成。
C++11之后:
右值对象构造,有拷贝构造,也有移动构造的场景
我们这里有了C++11后,string类就支持了移动构造,所以我们这里在不进行优化的情况就是进行两次移动构造(左图),进行优化后就是合并两次的移动构造,只进行一次的拷贝构造即可(右图)。
但是如果我们的编译器比较的激进的话,我们这里就会直接讲我们的str对象的构造,str拷贝构造临时对象和临时对象拷贝构造ret对象直接合并成一个操作变成直接构造,具体流程如图(这里最好是结合图中的栈帧理解):
场景二:
场景二和场景一不同的是,场景一的ret对象是我们构造出来的,场景二中的ret像是现成的,我们用这个对象来接收返回值。
C++11之前:
右值对象赋值,只有拷贝构造和拷贝赋值,没有移动构造和移动赋值的场景
我们这里如果不进行优化的话就是进行一次拷贝构造和一次拷贝赋值操作(左图),如果进行优化的话(取决于编译器的激进程度)就是直接构造我们要返回的临时对象,也就是说我们的str就成了我们的临时对象的引用。
C++11之后:
右值对象赋值,既有拷贝构造和拷贝赋值,也有移动构造和移动赋值的场景
我们这里其实和上面差不多,也就是换成了移动赋值而已,左图是优化的,右图是没有优化的,这里不再赘述了。
STL容器中的相关改变
在我们的C++11出来之后,STL中的容器都增加了相关的移动构造和赋值的函数。
下面就是vector中的移动构造和移动赋值的相关函数:
移动构造:
移动赋值:
类型分类
在C++11之后,我们进一步对类型进行了分类处理,右值被划分成了纯右值(pure value,简称prvalue)和将亡值(expiring value,简称xvalue):
纯右值:指的是哪些字面值常量或是求值结果相当于字面值或是一个临时变量,比如1、true、nullptr或是类似于str.substr(1, 2)、str1 + str2传值返回函数的调用,或者是整型a、b、a++、a + b等,C++11中的纯右值等价于我们C++98中的右值。
将亡值:指的是返回右值引用的函数的调用表达式和转换为右值引用的转换函数的调用表达式,比如move(x)、static_cast<X&&>(x)。
泛左值(generalized value,简称glvalue),其中包括了将亡值和左值。
图示如下:
引用折叠
在我们的C++里面直接定义引用的引用形如int& && r = i,这样写就会报错,我们只能通过模板或是typedef中的类型操作才可以构成引用的引用,规则如下:
1、右值引用的右值引用折叠成右值引用。
2、其他的所有组合都是折叠成左值引用。
代码示例:
#include <iostream>
using namespace std;
template<class T>
void f1(T& x) {}
template<class T>
void f2(T&& x) {}
int main() {typedef int& lref;typedef int&& rref;int n = 1;lref& r1 = n; // 类型为int&lref&& r2 = n; // 类型为int&rref& r3 = n; // 类型为int&rref&& r4 = 1; // 类型为int&&// 没有折叠->实例化为 void f1(int& x)f1<int>(n);// f1<int>(0); // 报错// 折叠->实例化为 void f1(int& x)f1<int&>(n);// f1<int&>(0); // 报错// 折叠->实例化为 void f1(int& x)f1<int&&>(n);// f1<int&&>(0); // 报错// 折叠->实例化为 void f1(const int& x)f1<const int&>(n);f1<const int&>(0);// 折叠->实例化为 void f1(const int& x)f1<const int&&>(n);f1<const int&&>(0);// 没有折叠->实例化为 void f2(int&& x)f2<int>(n); // 报错f2<int>(0);// 折叠->实例化为 void f2(int& x)f2<int&>(n); // 报错f2<int&>(0);// 折叠->实例化为 void f2(int&& x)f2<int&&>(n); // 报错f2<int&&>(0);return 0;
}
敲黑板:
我们在调用的时候发现虽然我们的T&& x参数看上去是右值引用参数,但是由于引用折叠的规则,当我们传递的是左值的时候就是左值引用,当我们传递的是右值的时候就是右值引用,我们把这样的模板参数叫做是万能引用。
万能引用的使用
这里不再说明原理了,直接上代码:
#include <utility>
using namespace std;template<class T>
void Function(T&& t) {int a = 0;T x = a;// x++;cout << &a << endl;cout << &x << endl << endl;
}int main() {// 10是右值,推导出T为int,模板实例化为void Function(int&& t)Function(10); // 右值int a;// a是左值,推导出T为int&,引用折叠,模板实例化为void Function(int& t)Function(a); // 左值// std::move(a)是右值,推导出T为int,模板实例化为void Function(int&& t)Function(std::move(a)); // 右值const int b = 8;// b是左值,推导出T为const int&,引用折叠,模板实例化为void Function(const int& t)// 所以Function内部会编译报错,x不能++Function(b); // const 左值// std::move(b)右值,推导出T为const int,模板实例化为void Function(const int&& t)// 所以Function内部会编译报错,x不能++Function(std::move(b)); // const 右值return 0;
}
测试效果如图:
完美转发
我们在上面的Function(T&& t)函数模板的程序中传入左值实例化以后就是左值引用的Function函数,传入右值实例化以后就是右值引用的Function函数。
但是我们使用函数参数中的t再调用一层函数就会发现,我们会一直匹配到左值引用的函数上(右值被右值引用绑定后,右值引用表达式的属性就是左值了,因为这个时候的值有了存储位置),这个时候就需要我们的完美转发了。
代码示例:
#include <iostream>
#include <type_traits>
using namespace std;
void Fun(int& x) { cout << "左值引用" << endl; }
void Fun(const int& x) { cout << "const 左值引用" << endl; }
void Fun(int&& x) { cout << "右值引用" << endl; }
void Fun(const int&& x) { cout << "const 右值引用" << endl; }
template<class T>
void Function(T&& t)
{Fun(t);
}
int main()
{// 10是右值,推导出T为int,模板实例化为void Function(int&& t)Function(10); // 右值int a;// a是左值,推导出T为int&,引用折叠,模板实例化为void Function(int& t)Function(a); // 左值// std::move(a)是右值,推导出T为int,模板实例化为void Function(int&& t)Function(std::move(a)); // 右值const int b = 8;// b是左值,推导出T为const int&,引用折叠,模板实例化为void Function(const int& t)Function(b); // const 左值// std::move(b)是右值,推导出T为const int,模板实例化为void Function(const int&& t)Function(std::move(b)); // const 右值return 0;
}
测试效果如图:
完美转发的特性
完美转发forword本质就是一个函数的模板,主要也是通过引用折叠来实现,下面的示例中,我们传入Function的实参是右值的时候T被推导成了int,没有折叠,这个时候我们的forward内部将t强转成了右值引用返回;传递到Function的实参是左值的时候,我们的T被推导成了int&,引用折叠之后为左值引用,forward内部将t强转成了左值引用返回。
函数原型:
template <class _Ty>
_Ty& forward(remove_reference_t<_Ty>& _Arg) noexcept
{// forward an lvalue as either an lvalue or an rvaluereturn static_cast<_Ty&>(_Arg);
}
代码示例:
template<class T>
void Function(T&& t)
{Fun(forward<T>(t));
}
测试效果如图: