【C++】C++11右值引用
樊梓慕 2024-07-16 08:05:03 阅读 50
👀樊梓慕:个人主页
🎥个人专栏:《C语言》《数据结构》《蓝桥杯试题》《LeetCode刷题笔记》《实训项目》《C++》《Linux》《算法》
🌝每一个不曾起舞的日子,都是对生命的辜负
目录
前言
1.什么是左值&&什么是右值
左值
右值
2.什么是左值引用&&什么是右值引用
左值引用
右值引用
3.左值引用与右值引用的比较
左值引用总结
右值引用总结
4.右值引用的使用场景和意义
传值返回场景分析
移动构造
移动赋值
总结
容器的插入场景分析
move的简单解释
右值被右值引用后,该右值引用是左值
那为什么这样设计呢?
5.完美转发
万能引用
完美转发保持值的属性不变
前言
今天我们正式进入C++11的学习,C++11引入的一个非常重要的语法就是右值引用,在C++11之前的C++版本我们所提的引用都是左值引用,那么右值引用与左值引用又有什么区别呢?什么是左值?什么是右值?右值引用的价值体现在哪里?以及完美转发和万能引用的相互配合?那么接下来我们就来学习有关右值引用的相关知识。
欢迎大家📂收藏📂以便未来做题时可以快速找到思路,巧妙的方法可以事半功倍。
=========================================================================
GITEE相关代码:🌟樊飞 (fanfei_c) - Gitee.com🌟
=========================================================================
1.什么是左值&&什么是右值
左值
左值是一个表示数据的表达式,如变量名或解引用的指针。
我们可以获取左值的地址,一般情况下也可以被修改(const修饰的左值除外)。左值既可以出现在赋值符号的左边,也可以出现在赋值符号的右边。
{
//以下的p、b、c、*p都是左值
int* p = new int(0);
int b = 1;
const int c = 2;
return 0;
}
右值
右值也是一个表示数据的表达式,如字母常量、表达式的返回值、函数的返回值(不能是左值引用返回)等等。
不可以获取右值的地址。右值可以出现在赋值符号的右边,但是『 不能』出现在赋值符号的左边。
int main()
{
double x = 1.1, y = 2.2;
//以下几个都是常见的右值
10;
x + y;
fmin(x, y);
//错误示例(右值不能出现在赋值符号的左边)
//10 = 1;
//x + y = 1;
//fmin(x, y) = 1;
return 0;
}
其实右值一般都是一个临时变量或常量值,比如代码中的10就是常量值,表达式x+y和函数fmin的返回值就是临时变量,这些都叫做右值,而我们知道这些临时变量和常量值实际上并没有被存储起来,当然也就不存在地址。
//这里x是左值
int func1()
{
static int x = 0;
return x;
}
//这里x是左值
int& func2()
{
static int x = 0;
return x;
}
当返回值没有引用标记时,返回的是临时拷贝x的一份临时变量;当返回值有引用标记时,返回的是x本身(注意销毁的问题)。
2.什么是左值引用&&什么是右值引用
传统的C++语法中就有引用的语法,而C++11中新增了右值引用的语法特性,为了进行区分,于是将C++11之前的引用就叫做左值引用。但是无论左值引用还是右值引用,本质都是给对象取别名。
左值引用
左值引用就是对左值的引用,给左值取别名,通过“&”来声明。比如:
int main()
{
//以下的p、b、c、*p都是左值
int* p = new int(0);
int b = 1;
const int c = 2;
//以下几个是对上面左值的左值引用
int*& rp = p;
int& rb = b;
const int& rc = c;
int& pvalue = *p;
return 0;
}
右值引用
右值引用就是对右值的引用,给右值取别名,通过“&&”来声明。比如:
int main()
{
double x = 1.1, y = 2.2;
//以下几个都是常见的右值
10;
x + y;
fmin(x, y);
//以下几个都是对右值的右值引用
int&& rr1 = 10;
double&& rr2 = x + y;
double rr3 = fmin(x, y);
return 0;
}
很多人到这里就有疑惑了,引用的本质就是起别名,但是右值我们知道是没有地址的,如果一个引用可以标记在右值上,那又有什么意义呢?
是的,既然有右值引用存在,那么右值引用一定是将这个临时变量存放到了某个确定的地址上,让这个右值可以被取到地址,并且可以被修改,当然如果不想让被引用的右值被修改,可以用const修饰右值引用。比如:
int main()
{
double x = 1.1, y = 2.2;
int&& rr1 = 10;
const double&& rr2 = x + y;
rr1 = 20;
rr2 = 5.5; //报错
return 0;
}
3.左值引用与右值引用的比较
左值引用总结
左值引用只能引用左值,不能引用右值。但是const左值引用既可引用左值,也可引用右值。
int main()
{
// 左值引用只能引用左值,不能引用右值。
int a = 10;
int& ra1 = a; // ra为a的别名
//int& ra2 = 10; // 编译失败,因为10是右值
// const左值引用既可引用左值,也可引用右值。
const int& ra3 = 10;
const int& ra4 = a;
return 0;
}
右值引用总结
右值引用只能右值,不能引用左值。但是右值引用可以move以后的左值。
int main()
{
// 右值引用只能右值,不能引用左值。
int&& r1 = 10;
// error C2440: “初始化”: 无法从“int”转换为“int &&”
// message : 无法将左值绑定到右值引用
int a = 10;
int&& r2 = a;
// 右值引用可以引用move以后的左值
int&& r3 = std::move(a);
return 0;
}
4.右值引用的使用场景和意义
在探究右值引用的使用场景和意义之前,我们来回忆以下左值引用给我们带来的优点:
左值引用可以避免一些没有必要的拷贝操作,比如传参或函数返回值。
但是左值引用在修饰函数返回值时却容易出现问题,因为函数返回值是一个的局部变量,出了函数作用域就被销毁了,如果给加上了左值引用,就会导致左值引用出现问题,所以这种情况下不能使用左值引用作为返回值,只能以传值方式返回,这就是『 左值引用的短板』。
既然是右值,我们就可以使用右值引用,但是右值引用解决这里的问题是『 间接解决的』。
什么叫间接解决??
右值引用不能直接加到返回类型上直接解决么,答案当然是不能的,因为不管你给返回值加左值引用还是右值引用,都改变不了它即将被销毁的事实。
所以我们只能间接解决,怎么间接解决呢?
传值返回场景分析
移动构造
我们想要避免拷贝构造的发生,那就要设法让编译器在遇到右值引用时调用其他构造方式,这里采用的就是『 移动构造』。
而移动构造说白了就是利用swap函数将『 将亡值』与当前对象进行交换,获得『 将亡值』的数据,通过一个swap即可得到数据,不需要调用拷贝构造既节省了时间也节省了空间。
这种swap其实是一种非常危险的行为,只能适用于『 将亡值』,可以理解为是一种资源的掠夺。
将亡值:即将销毁的变量,比如返回值x这种。
增加移动构造之后,由于移动构造采用的是右值引用接收参数,因此如果拷贝构造对象时传入的是右值,那么就会调用移动构造函数(编译器最匹配原则)。
比如:
// 拷贝构造 -- 左值
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(string&& s)
{
cout << "string(string&& s) -- 移动拷贝" << endl;
swap(s);
}
给string类增加移动构造后,对于返回局部string对象的这类函数,在返回string对象时就会调用移动构造进行资源的移动,而不会再调用拷贝构造函数进行深拷贝了。比如:
F::string to_string(int value)
{
bool flag = true;
if (value < 0)
{
flag = false;
value = 0 - value;
}
F::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 move(str);//move函数可以理解为是将左值转换成右值的
}
int main()
{
F::string s = F::to_string(1234);//调用移动构造
return 0;
}
之前我们在学习类和对象部分的时候,曾经提到过编译器会对连续的构造、拷贝构造等进行优化,这部分内容需要回顾的可以戳链接->【C++】类和对象(下) ——樊梓慕
那对于移动拷贝来说,编译器也会对其进行优化:
首先在引入移动拷贝后,如果编译器不优化的过程是这样的:
但是我们之前讲过『 将亡值』,很明显str就是『 将亡值』,所以我们不需要那么小心翼翼地拷贝构造他,反正str马上就要被销毁了,我们就直接移动构造swap掠夺资源了就行了,但是str此时是左值,左值可不敢随意掠夺容易出问题,所以我们要通过move函数将str转化为右值,当然为了兼容存量代码(语言都是向下兼容的),这里编译器自动做了处理,不需要我们手动move:
参考双拷贝构造合二为一的例子,这里双移动构造编译器也进行了优化。
移动赋值
之前的场景是:
{
F::string ret = F::to_string(1234);
return 0;
}
那如果是下面这种情况呢?
int main()
{
F::string ret ;
ret = F::to_string(1234);
return 0;
}
很明显这里就不是构造的问题了,这里是先要拷贝构造一个临时对象,然后再用临时对象赋值给ret,所以我们需要重载赋值操作符来达到移动赋值的效果,在这个过程中需要避免深拷贝的发生。
//移动赋值
string& operator=(string&& s)
{
swap(s);
return *this;
}
这样的话如果赋值时传入的是右值,那么就会调用移动赋值函数(最匹配原则)。
而且string原有的operator=函数做的是深拷贝,而移动赋值函数中只需要调用swap函数进行资源的转移,因此调用移动赋值的代价比调用原有operator=的代价小。
总结
到这里,之前我们讲左值引用无法解决的传值返回的问题被右值引用解决了,深拷贝对象传值返回只需要移动资源,代价很低。
在C++11标准出来后,所有的STL容器都增加了移动构造和移动赋值。
比如:
容器的插入场景分析
在C++11后,容器的插入函数提供了右值的插入方法:
也就是说当push_back的参数为右值时,会调用对应的右值插入函数。
move的简单解释
我们之前说move可以让一个对象从左值变成右值,这是不准确的。
实际上move一个对象后,该对象本身属性还是左值不会改变,只不过move这个表达式的返回值为右值。
比如:
我们知道s1是左值,那传参时调用的是普通的构造深拷贝一个临时对象尾插。
move(s1)执行完成后,调用push_back函数,构造参数仍然是深拷贝,也就是说s1仍然为左值,move不会改变s1的属性。
注意:不要轻易move左值,除非你确定要转移这个左值资源。
move(s1)这个表达式的属性为右值,所以构造参数时调用的是移动构造。
观察发现,STL库中的List在push_back时,左值传参调用拷贝构造,右值传参调用移动构造:
右值被右值引用后,该右值引用是左值
未实现右值传参时的现象:
均为拷贝构造,证明此时不管左值还是右值传参调用的构造都是普通的拷贝构造。
我们进行逐步探究,首先为了实现push_back识别右值传参,我们就要提供一个右值引用的重载版本:
执行代码发现没有起效果:
发现原来push_back复用insert实现的,那么我们就给insert也提供一个右值引用版本:
补充:STL库中也提供了insert的右值引用版本:
执行代码,发现仍然没有起效果:
再又发现insert内部new了一个Node,那这里会存在构造,list的构造我们并没有提供右值引用版本,那就加上:
执行后发现还是没效果:
那么我们只能进行调试,看看到底有没有按照我们的想法进入右值引用的版本呢?
我们不看第一个左值,直接看第二个匿名对象的右值,发现:
为什么呢,我们不是实现了insert的右值引用么?
这里我直接说结论:
右值被右值引用,该右值引用的属性是左值。
所以当进入push_back后,此时:
那为什么这样设计呢?
我们看下之前讲移动构造的例子:
之前说,编译器进行了优化,讲str隐式地move变成右值(注意这里比较特殊,我们之前说move不能改变对象的属性,这里其实不是真的move,这样写是为了更好的让大家理解),然后直接移动资源swap,可是你有没有想过,如果这里的str变成了右值,交换资源是需要修改的,右值可以被修改么??
或者说,右值传参也传不给swap呀:
当然是不可以,这是我们最开始『 什么是左值&&什么是右值』就提到过的基本概念。
那既然不可修改那还移动啥啊。
所以这里才有了右值被右值引用后,该右值引用是左值的概念。
左值是可以被修改的,之后才能被移动,资源才可以进行转移,到这才完成了逻辑闭环。
那么我们来验证一下吧,利用move(),move(x)这个表达式的返回值是右值,通过这样的方式进入insert的右值引用版本:
并且注意,insert内部new了一个Node,这里构造的参数x是右值的右值引用是左值需要move:
构造这里的data需要传参val,val是右值的右值引用也是左值,也需要move:
成功实现移动拷贝!
5.完美转发
万能引用
模板中的&&不代表右值引用,而是万能引用,其既能接收左值又能接收右值。比如:
<code>template<class T>
void PerfectForward(T&& t)
{
//...
}
万能引用顾名思义,就是既可以接收左值也可以接受右值,并且根据传入的参数进行推导得出具体的类型,所以这里必须是在模板类中使用 。
如果传入的实参是一个左值,那么这里的形参t就是左值引用,如果传入的实参是一个右值,那么这里的形参t就是右值引用。
但是这里就会有一个问题,在上面我们提到过右值被右值引用后,该右值引用的属性是左值,这样设计的目的主要是为了可以进行移动拷贝,允许修改,但我们后来不得不进行特殊处理move,将这个属性为左值的右值引用又转化回了右值,这样才能调用对应的右值引用的函数。
上面这段文字都是我们上面『 容器插入场景分析』部分逐步进行实现得到的结论。
那么如果我们要实现所谓的『 万能引用』,就必须要处理这块的问题,就不能进行所谓的特殊处理了,那怎么办呢?
C++11引入了『 完美转发』的概念。
完美转发保持值的属性不变
要想在参数传递过程中保持其原有的属性,需要在传参时调用forward函数。比如:
template<class T>
void PerfectForward(T&& t)
{
Func(std::forward<T>(t));
}
经过完美转发后:
如果t本身是左值,就保持该左值属性;如果t本身是右值,右值引用后,属性退化为左值,这里经完美转发会重新转化成右值,相当于move了一下,保持了t的原生属性。
万能引用与完美转发互相配合,体现了泛型编程的思想,有了他们,我们就避免了冗余代码,针对各种类型的引用也能轻松应对,无他,编译器承担了一切。
=========================================================================
如果你对该系列文章有兴趣的话,欢迎持续关注博主动态,博主会持续输出优质内容
🍎博主很需要大家的支持,你的支持是我创作的不竭动力🍎
🌟~ 点赞收藏+关注 ~🌟
=========================================================================
声明
本文内容仅代表作者观点,或转载于其他网站,本站不以此文作为商业用途
如有涉及侵权,请联系本站进行删除
转载本站原创文章,请注明来源及作者。