【C++11】右值引用和移动语义:万字总结
📝前言:
这篇文章我们来讲讲右值引用和移动语义
🎬个人简介:努力学习ing
📋个人专栏:C++学习笔记
🎀CSDN主页 愚润求学
🌄其他专栏:C语言入门基础,python入门基础,python刷题专栏,Linux
文章目录
- 一,左值和右值
- 二,左值引用和右值引用
- 1 对比异同
- 2 延长生命周期
- 3 参数匹配
- 三,右值引用和移动语义
- 1 左值引用主要使用场景
- 2 移动构造和移动赋值
- 3 不同优化下的拷贝构造优化效果
- 没有优化
- 优化1
- 优化2
- 4 不同优化下的拷贝赋值优化效果
- 没有优化
- 优化1
- 优化2
- 5 总结及移动语义的重要性
- 四,类型分类
- 五,引用折叠
- 六,完美转发
一,左值和右值
左值和右值最重要的区别:能不能取地址,能取地址的是左值,不能的是右值!
左值:
- 左值:一个表示数据的表达式,是一个具名的、有明确内存地址的对象,如:变量名,解引用的指针
- 可以出现在赋值符号的左边,也可以出现在右边。(如果有
const
修饰,就无法修改,但是可以取地址)
右值:
- 右值:也是⼀个表示数据的表达式,常见的有:字面量,表达式返回时生成的临时变量,函数传值返回时的临时变量,你们对象…
- 不能出现在赋值符号的左边,不能取地址
示例:
常见左值,可以在=
左边,可以取地址:
int* p = new int(0);int a = 10;const int c = a;*p = 10;string s{ "111111" };s[0] = 'x'; // 函数返回值为引用,也是左值
常见右值,不能在=
左边,不能取地址:
10; // 字面量x + y; // 表达式返回中间生成临时变量fmin(x, y); // 传值返回中间生成临时变量string("11111"); // 匿名对象(具有常性)
二,左值引用和右值引用
1 对比异同
相同点:
- 左值引用是给左值取别名,右值引用是给右值取别名,语法层面都不开空间
- 从底层汇编上看,都是用指针实现的
不同点:
左值引用:
- 语法:
Type &r1 = x;
,x
是左值 const
左值引⽤可以引用右值
右值引用:
- 语法:
Type &&rr1 = y;
,y
是右值 - 右值引用可以引用
move(左值)
(move
本质内部是进行强制类型转换,但是不改变原来变量的属性【这个属性指:左值 / 右值】) - 右值引用本身是左值,即:在上面的语法中:
rr1
是左值
示例:
左值引用:
// 左值引用
int& ra = a;
const int& rc = c;
string& rs = s;
const int& rc = 10; // const左值引用 引用右值
右值引用:
const int&& rr1 = 10;
double&& rr2 = x + y;
double&& rr3 = fmin(x, y);
string&& rr4 = string("11111");
string&& rr5 = move(s); // 引用move(左值)
double&& rr6 = move(rr2); // rr2是右值引用,属性是左值,要使用move才能被右值引用
2 延长生命周期
右值引用和const
左值引用,可以延长临时变量/匿名对象的生命周期(普通的左值引用不行,因为临时变量具有常性)
示例:
double x = 1.1, y = 2.2;
// double& r1 = x + y; 报错:无法从“double”转换为“double &
const double& rr2 = x + y;
double&& rr3 = x + y;
rr3++; // 并且非 const 的右值引用可以修改
说明:x+y
表达式计算的结果会先储存在临时变量中(这个临时变量存储在这个表达式所在的栈帧中),但是生命周期只有这一行。使用右值引用和const
左值引用以后可以延长这个临时变量的生命周期,即:这一行结束以后不销毁,而是跟着rr2
/ rr3
3 参数匹配
- 对于不同的引用类型,以及带
const
和非const
,都属于不同的类型,可以构成函数重载 - 根据实参传入的类型不同,编译器会选择最匹配的函数
const
左值引用作为参数的函数,实参传递左值和右值都可以匹配
示例:
void f(int& x)
{cout << "左值引用重载 f(" << x << ")\n";
}
void f(const int& x)
{cout << "到 const 的左值引用重载 f(" << x << ")\n";
}
void f(int&& x)
{cout << "右值引用重载 f(" << x << ")\n";
}int main()
{int i = 1;const int ci = 2;f(i); // 调用 f(int&)f(ci); // 调用 f(const int&)f(3); // 调用 f(int&&),如果没有 f(int&&) 重载,则会调用 f(const int&)f(std::move(i)); // 调用 f(int&&)// 右值引用变量在用于表达式时是左值int&& x = 1;f(x); // 调用 f(int& x)f(std::move(x)); // 调用 f(int&& x)return 0;
}
运行结果:
三,右值引用和移动语义
1 左值引用主要使用场景
我们都知道:函数传值传参、函数传值返回、以及表达式计算结果后赋值…都需要先把原来的数据拷贝给临时变量,然后再由临时变量拷贝回给接收值。这样的效率是非常低的。
之前,我们为了减少这种拷贝就已经开始运用左值引用了,如:
- 使用cosnt 左值引用作为形参(直接使用别名,减少传值的拷贝)
- 使用左值引用作为函数返回值
但是,当函数内部,返回的是局部变量且函数结束后要被销毁时,我们无法通过左值引用来接收。
错误示例(注意如果要进行测试:这里是string
不要用库里面的,因为库里面的已经实现了移动构造和移动赋值):
const tr::string& addStrings(tr::string num1, tr::string num2) {tr::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;
}int main()
{tr::string num1{ "2222222222" };tr::string num2{ "1111111111" };tr::string num3 = addStrings(num1, num2);
}
代码为 -1073741819 (0xc0000005)
程序会崩掉,为什么呢?
因为,在addStrings
函数里面,str
是一个局部对象,即使被引用绑定了,但是函数结束后,存储str
的栈帧被销毁,str
也会被销毁(无法达到延长生命周期的效果)
那难道就没有解决方法了吗?有的兄弟,有的!
在C++11之前,可以通过传输出型参数解决:
// 输出型参数
void addStrings(tr::string num1, tr::string num2, tr::string& num3) {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;num3 += ('0' + ret);}if (next == 1)num3 += '1';reverse(num3.begin(), num3.end());
}int main()
{tr::string num1{ "2222222222" };tr::string num2{ "1111111111" };tr::string num3;addStrings(num1, num2, num3);cout << num3.c_str() << endl;
}
说明:
num3
就是输出型参数,形参为num3
的引用,直接在函数内部修改num3
。但是这样的做法牺牲了一定的可读性。
还有其他做法吗?
有的兄弟,有的,那就是C++11的右值引用+移动语义!
2 移动构造和移动赋值
首先,我们先来讲讲移动构造和移动赋值的语法和写法:
- 移动构造:是⼀种构造函数,类似拷贝构造函数。要求第⼀个参数是该类类型的右值引用,如果还有其他参数,额外的参数必须有缺省值
- 移动赋值:是⼀个赋值运算符的重载,他跟拷贝赋值构成函数重载。移动赋值函数要求第⼀个参数是该类类型的右值引用。它们两个的关系就类似移动构造和拷贝构造的关系。
话不多说,直接上代码理解:
我们先看一个普通的拷贝构造:
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=(string&& s)
{cout << "string& operator=(string&& s) -- 移动赋值" << endl;swap(s);return *this;
}
说明:
在这里,s
是一个将要销毁的右值,直接swap
把s
的资源交换给了this
(这里的代价比拷贝构造小的多,可能只是一个指针的交换等),然后s
带着原来刚初始化的this
的资源被销毁。
3 不同优化下的拷贝构造优化效果
首先我们要理解,什么时候,拷贝构造会被使用?
没有优化
当没有任何优化的情况下:
但是这里我调不出来,现在的编译器都优化的太好了。
优化1
优化版本1:
省掉临时变量,直接用str
构造num3
,{"1111111111"}
这是同理,省掉了临时变量。
我们可以在Linux下用g++编译器来观察:g++ test.cpp -fno-elide-constructors
(看别人都说:这是取消所有优化,但是我测试的效果任然是保留了这优化1的效果的)
测试结果:
说明:
- 前两个构造分别对应:用
{"1111111111"}
和{"2222222222"}
来构造num1
和num2
。优化掉了中间的临时变量:没有优化时是:先用{"1111111111"}
构造临时变量,然后再用临时变量拷贝构造num1
- 第三个构造是定义函数内
str
的时候的构造(即函数内第一条语句) - 第四个拷贝构造,就是优化1 的效果,直接省掉了临时变量,用
str
直接拷贝构造num3
优化2
新版编译器都是到了优化2的程度:
运行效果:
说明:
- 前两个和优化1 一样,不解释了
- 可见这里少了一个拷贝构造:编译器直接优化成:用把
str
当num3
的引用了,在函数内部直接就改了num3
,减少了拷贝
4 不同优化下的拷贝赋值优化效果
刚刚我们看到了编译器的超强优化,大大避免了拷贝的出现。但是,能优化所有情况吗?答案是否定的,当出现拷贝赋值的时候,编译器不敢优化用临时变量拷贝赋值的那一步。
我们把代码改成这样:
tr::string num3;
num3 = addStrings({ "1111111111" }, { "2222222222" });
没有优化
如果没有优化:
优化1
优化1:
没错,你没看错,和没有优化的效果是一样的,只是在传参那里会优化掉临时变量,但是传值返回并调用拷贝赋值这里一点也不会省
我们在Linux下运行优化1的效果:
说明:
- 前四个构造分别对应:add外部
num3
,{"1111111111"}
,{"2222222222"}
,add内部str
- 拷贝构造:用返回值
str
拷贝构造临时对象 - 拷贝赋值:用临时对象拷贝赋值外部的
num3
优化2
优化2以后:
优化掉了,用str
拷贝构造临时对象这一步(可以理解为,这时候str
就是临时对象的引用)
但是,即使最大优化:临时对象到拷贝赋值这一步也不能省
我们在优化2的情况下运行代码,运行结果:
5 总结及移动语义的重要性
通过上面的优化我们可以发现:
在最强优化的情况下,如果只有构造操作,则两个拷贝构造会被直接优化掉。但是,如果是构造+赋值操作,则最多只能把拷贝构造给优化掉(即:和构造合并),但是用临时变量来拷贝赋值这一步无法优化。
所以这时候,右值引用+移动语义的意义就非常大了。因为移动构造和移动赋值的代价都特别小。
当我们实现了移动构造和移动赋值以后,对应的拷贝构造和拷贝赋值就会被替换成我们的移动构造和移动赋值。
示例(优化1 下:构造 + 赋值运行结果)
示例(优化2 下:构造 + 赋值运行结果):
移动构造和移动赋值的使用场景:
对于像string/vector
这样的深拷贝的类或者包含深拷贝的成员变量的类,移动构造和移动赋值才有
意义,因为移动构造和移动赋值的第⼀个参数都是右值引用的类型,他的本质是要“窃取”引用的
右值对象的资源,而不是像拷贝构造和拷贝赋值那样去拷贝资源,从提高效率
在C++11以后,STL里面的容器也对此做了调整
比如vector
的push_back
- 当实参是⼀个左值时,容器内部继续调用拷贝构造进行拷贝,将对象拷贝到容器空间中的对象
- 当实参是⼀个右值时,容器内部则调用移动构造,右值对象的资源到容器空间的对象上
四,类型分类
- C++11以后,进⼀步对类型进⾏了划分,右值被划分纯右值(pure value,简称prvalue)和将亡值
- 存右值和C++98中右值的概念相同
- 将忘值是指:返回右值引用的函数的调用表达式 或 转换为右值引用的转换函数的调用表达,如:
move(x)
、static_cast<X&&>(x)
【可以理解为:x
是左值,且一直是左值属性,但是move(x)
这一下,变成了将亡值,当右值】 static_cast<type>(x)
是类型转换操作,在这里:type
如果是左值引用,static_cast<type>(x)
该将亡值就是左值属性,反之就是右值属性。而x
是保留原来的属性- 范左值:左值和将亡值
五,引用折叠
C++中不能直接定义引用的引用如 int& && r = i
; ,这样写会直接报错,通过模板或 typedef
中的类型操作可以构成引用的引用
引用折叠:当出现引用的引用时,只要有左值引用,那类型就是左值引用
示例(typedef
):
typedef int& lref;typedef int&& rref;int n = 0;lref& r1 = n; // r1 的类型是 int&(左+左 = 左)lref&& r2 = n; // r2 的类型是 int& (左 + 右 = 左)rref& r3 = n; // r3 的类型是 int& (右 + 左 = 左)rref&& r4 = 1; // r4 的类型是 int&& (右 + 右 = 右)
示例(模板实例化):
函数f1
和f2
// 由于引用折叠,且T& 是左值引用,所以:f1实例化以后总是左值引用
template<class T>
void f1(T& x)
{}// 由于引用折叠限定,且T&& 是右值引用,则f2实例化后可以是左值引,也可以是右值引用
template<class T>
void f2(T&& x)
{
}
实例化分析:
// 没有折叠->实例化为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);
所以,当我们的模板写成右值引用的时候,这时候是万能引用模板。编译器可以根据我们传入的引用的类型不同,实例化成左值引用或者右值引用。(折叠的是:T& / T&&
,但对于T
本身而言,这种显式实例化:T
传入什么就是什么)
一个万能模板的例子:
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 = 10;// 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;// a是左值,推导出T为const int&,引⽤折叠,模板实例化为void Function(const int&t)// 所以Function内部会编译报错,x不能++Function(b); // const 左值// std::move(b)右值,推导出T为const int,模板实例化为void Function(const int&&// 所以Function内部会编译报错,x不能++Function(std::move(b)); // const 右值return 0;
}
但是这里要注意的是:T
的类型由编译器推导。
因为T&&
是右值引用,当传左值的时候,为了匹配左值,T会被推导成左值引用
六,完美转发
完美转发:让参数保持原有的属性传递。
通常使用在解决这样的问题:一个为右值的参数,在传递时变成了左值。
例如:
template<class T>
void Function(T&& t)
{Fun(t);
}
Function(10)
假如,传入的实参10
是一个右值,形参t
就是一个右值引用。
但是右值引用本身的属性是左值,即:Function
里面的具名对象t
是一个左值,再传入Fun
的时候,传入的就是一个左值了。出现了属性的变化。
完美转发:
template<class T>
void Function(T&& t)
{Fun(forward<T>(t));
}
forward
的作用是根据模板参数 T
的推导结果,将参数以原始的左值或右值属性转发给其他函数。
forward
是一个函数模板,原型是(本质是通过:引用折叠 + 强制类型转换实现的):
template <class _Ty>
_Ty&& forward(remove_reference_t<_Ty>& _Arg) noexcept
{ // forward an lvalue as either an lvalue or an rvalue
return static_cast<_Ty&&>(_Arg);
}
即:
- 当
Function
传入10
:则T
被推导成int
,forward
返回的static_cast<int&&>(t)
属性是右值,所以是以右值的属性转发给Fun
- 传入一个左值
int x;
:则T
被推导成int&
,forward
返回的static_cast<int& &&>(t)
发生引用折叠,变成static_cast<int&>(t)
,属性是左值,以左值的属性转发给Fun
- 注意:
static_cast<_Ty&&>(_Arg)
这玩意是一个整体,这是一个将亡值。
🌈我的分享也就到此结束啦🌈
要是我的分享也能对你的学习起到帮助,那简直是太酷啦!
若有不足,还请大家多多指正,我们一起学习交流!
📢公主,王子:点赞👍→收藏⭐→关注🔍
感谢大家的观看和支持!祝大家都能得偿所愿,天天开心!!!