深入篇【C++】剖析C++11中右值引用与左值引用的区别以及应用价值

一.左值引用和右值引用

在介绍右值引用和左值引用之前,我们需要理解什么是左值,什么是右值。

一.什么是左值?左值引用?

1.左值就是一个表示数据的表达式,比如变量,或者解引用的指针。
2.左值是可以获取它的地址的。
3.左值是可以修改的。
4.左值可以出现在赋值符号的左边,右值不能出现在赋值符号的左边。
【问题①】用const修饰后的变量是左值吗?
答案:是!用const修饰的变量的内容无法修改,但是可以获取它的地址!
【问题②】什么是左值引用呢?
答案:左值引用就是对左值取别名。

int main()
{
	// 以下的p、b、c、*p都是左值
	int* p = new int(0);
	int b = 1;
	const int c = 2;

	//因为p,b,c,*p的地址都可以获取到
	
	// 以下几个是对上面左值的左值引用
	int*& rp = p;
	int& rb = b;
	const int& rc = c;
	int& pvalue = *p;
	return 0;
}

二.什么是右值?右值引用?

1.右值也是一个可以表达数据的表达式,比如:字面常量,表达式返回值,函数返回值(传值返回)。
2.右值是无法获取到它的地址的。
3.右值是无法修改的。
4.右值可以出现在赋值符号的右边,但不能出现在赋值符号的左边。
【问题①】什么是右值引用?
右值引用就是对右值取别名。右值引用写法上就比左值引用多了一个&符号。


int fmin(int x, int y)
{
	return x < y ? x : y;
}
int main()
{
	
		int x = 1.1, y = 2.2;
		// 以下几个都是常见的右值
		10;
		//字面常量,无法获取地址,字面常量是存在常量区的。
		x + y;
		//表达式返回值,无法获取表达式的地址的。
		fmin(x, y);
		//函数返回值,也是无法获取这个表达式的地址的
		
		// 以下几个都是对右值的右值引用
		int&& rr1 = 10;

		int&& rr2 = x + y;

		int&& rr3 = fmin(x, y);
}

二.左值引用与右值引用的比较

在没有引入右值引用之前,难道代码里没有右值吗?这肯定是不可能的,那么这些右值是如何像左值一样正常处理的呢?
左值引用无法引用右值,这是肯定的,但const修饰后的左值引用就可以引用右值了。

int main()
{
    // 左值引用只能引用左值,不能引用右值。
    int a = 10;
    int& ra1 = a;   // ra为a的别名---->左值引用
    //int& ra2 = 10;   // 编译失败,因为10是右值
    
    // const左值引用既可引用左值,也可引用右值。
    const int& ra3 = 10;
    //加上const修饰的左值引用后,就可以引用右值了。
    const int& ra4 = a;
    return 0;
}

而对于右值引用来说,只能引用右值,不能引用左值。但是C++库里提供了一个函数move。它可以将左值转换位右值,也就是使用move后它会传回一个右值。

int main()
{
 // 右值引用只能右值,不能引用左值。
 int&& r1 = 10;

 int a = 10;
 int&& r2 = a;这个是错误的
  // error C2440: “初始化”: 无法从“int”转换为“int &&”
 // message : 无法将左值绑定到右值引用

 // 但右值引用可以引用move以后的左值
 int &&r2 =std::move(a)
 //move会返回一个右值,这里只是返回一个右值,但a还是左值。

总结:
1.左值引用只能引用左值,但const修饰后的左值引用既可以引用左值也可以引用右值。
2.右值引用只能引用右值,但可以引用move后的左值。

看到这里可能觉得右值引用好像没有啥用处嘛,const修饰的左值引用就可以引用右值了,那干脆直接全写const修饰的引用呗。
右值引用从上面的方面来看确实没啥用处,但从某些方面来说,用处极大。这里一言半语说不出来,接下来我将从三个场景来解释右值引用的魅力!

三.应用场景及价值

左值引用和右值引用都是为了提高效率的!
一.左值引用都应用在哪呢?

1.作为函数参数 2.作为函数返回值 3.减少拷贝

左值引用的核心价值就是减少拷贝,提高效率。但也有局限场景:
①当作为函数参数时,用左值引用非常好,可以减少拷贝。
②当作为函数返回值时,这里的前提就是变量要么是静态变量要么是全局变量,反正在函数结束后,该引用变量仍然存在,这个场景下才可以使用左值引用。减少拷贝。
③所以当变量是局部变量时,就无法使用左值引用作为函数返回值了,必须使用传值返回!

1.你不要问我为什么传值返回会拷贝哈,这都不懂吗?
因为传值返回时,函数结束后变量就销毁了,所以会拷贝一个临时变量存储返回值。这里就存在拷贝。
2.当返回值是内置类型,拷贝代价低,当返回值是自定义类型,那么拷贝的代价就很大了。因为拷贝都是深拷贝,需要开空间。

二.右值引用都应用在哪呢?
右值引用的核心也是为了减少拷贝,并且是进一步减少拷贝,弥补左值引用中没有解决的场景:比如上面所说的函数传值返回需要拷贝。那么右值引用是如何解决的呢?这里就说一句:转移资源!直接将资源转移。

Ⅰ.场景一:函数传值返回拷贝

该场景进一步讲就是对于那些自定义类型中需要深拷贝的类,并且需要传值返回的类。

【问题】为什么是那些自定义类型中需要深拷贝的类呢?
①如果对象是内置类型那么拷贝的代价很低,因为最大的也减少double类型,也就不用在乎拷贝的消耗了。所以主要考虑的是自定义类型。
②而如果在自定义类型中又不存在深拷贝的操作。那么也不需要考虑,这些操作的消耗不是很大。但是如果是自定义类型中深拷贝的话,那么这个消耗就巨大了,不仅需要开跟对象一样大的空间,最后还要释放空间,
③关键是不知道这个对象是什么自定义类型,是一颗树?链表?还是什么呢?想想都恐怖。而且左值引用在这个场景下是无法起作用,因为这里是传值返回。

在这里插入图片描述
这里我采用自定义类型string来演示:这个string类是自己手搓的,在手搓string类那篇文章写过,这里直接复制过来进行演示。将不重要的部分都删掉。

#pragma once
#define _CRT_SECURE_NO_WARNINGS 1
#include <assert.h>
#include <string.h>

namespace tao
{
	
	class string
	{
 	  public:
 	     //构造函数
		  string(const char* str="")
		  {
		       cout << "string(const string& s) -- 深拷贝" << endl;
			  _size = strlen(str);
			  _capacity = _size;
			  _str = new char[_capacity + 1];
			  memcpy(_str, str,_size+1);
	       }
	       //拷贝构造
		  string (const string& s)//深拷贝
		  {
		      cout << "string(const string& s) -- 深拷贝" << endl;
			  _str = new char[s._capacity+1];
			  memcpy(_str, s._str,s.size()+1);
			  _size = s._size;
			  _capacity = s._capacity;
		  }
		  //赋值运算符重载
		  string& operator=(const string& s)
		  {
		     cout << "string& operator=(string s) -- 深拷贝" << endl;
			  if (*this != s)
			  {
				  char* tmp = new char[s._capacity + 1];
				  memcpy(tmp, s.c_str(), s._size);
				  delete[] _str;
				  _str = tmp;
				  _size = s.size();
				  _capacity = s._capacity;
				
			  }
			  return *this;
		  }
		  ~string()
		  {
			  delete[] _str;
			  _str = nullptr;
			  _size = _capacity = 0;
		  }
		 size_t size() const //一般只读,不给修改
		 {
			 return _size;
		 }
		 char& operator[](int pos)//可以引用返回,因为出了函数值还在
		 {
			 assert(pos < _size);
			 return _str[pos];
		 }
		 //有两种重载类型,一种是上面的另一种是const修饰的对象,只读,不给修改的
		 const char& operator[](int pos) const
		 {
			 assert(pos < _size);
			 return _str[pos];
		 }
		 iterator begin()//begin返回的是指向开头位置的迭代器
		 {
			 return _str;
		 }
		 iterator end()//end返回的是指向最后一个字符的下一个位置
		 {
			 return _str + _size;
		 }
		 const_iterator begin()const
		 {
			 return _str;
		 }
		 const_iterator end()const
		 {
			 return _str + _size;
		 }

		 void reserve(size_t n)
		 {
			 if (n > _capacity)
			 {
				 char* temp = new char[n + 1];
				 memcpy(temp, _str,_size+1);
				 delete[] _str;
				_str = temp;
				_capacity = n;
			 }
		 }
//增
		 void push_back(char ch)//尾插首秀按需要考虑是否需要扩容--->扩容最好用reserve来扩容
		 {
			 if (_size >= _capacity)
			 {
				 //可以直接扩容2倍,但要注意一种情况,当为空串时
				 reserve(_capacity == 0 ? 4 : 2 * _capacity);
				
			 }
			 _str[_size++] = ch;
			 _str[_size] = '\0';
		 }

		 void insert(size_t pos, size_t n, char ch)
		 {
			 //第一步检查pos的合法性
			 assert(pos <= _size);
			 //检查是否需要扩容---》直接用reserve扩容
			 if (_size+n > _capacity)
			 {
				 reserve(_size + n);
			 }
			 //第三步挪动数据
			 size_t end = _size;
			 while (end >= pos&&end!=npos)
			 {
				 _str[end + n] = _str[end];
				 end--;
			 }
			 for (int i = 0; i < n; i++)
			 {
				 _str[pos + i] = ch;
			 }
			  _size += n;

		 }

		 void insert(size_t pos, const char* str)
		 {
			 //第一步检查pos的合法性
			 assert(pos <= _size);
			 //检查是否需要扩容---》直接用reserve扩容
			 size_t len = strlen(str);
			 if (_size + len > _capacity)
			 {
				 reserve(_size + len);
			 }
			 //挪动数据
			 size_t end = _size;
			 while (end >= pos && end != npos)
			 {
				 _str[end + len] = _str[end];
				 end--;
			 }
			 for (int i = 0; i < len; i++)
			 {
				 _str[pos + i] = str[i];
			 }
			 _size += len;
		 }

//删
		 void erase(size_t pos, size_t len=npos)
		 {
			 assert(pos <= _size);
			 if (len == npos || pos + len > _size)//删除完
			 {
				 _str[pos] = '\0';
				 _size = pos;
				 _str[_size] = '\0';
			 }
			 else
			 {
				 size_t end = pos + len;
				 while (end <= _size)
				 {
					 _str[pos++] = _str[end++];
					
				 }
				 _size -= len;
			 }
			
		 }
		 void clear()
		 {
			 _str[0] = '\0';
			 _size = 0;
		 }
//查/改
		 size_t find(char ch, size_t pos = 0)
		 {
			 assert(pos <= _size);
			 for (size_t i = pos; i < _size; i++)
			 {
				 if (_str[i] == ch)
					 return i;
			 }
			 return npos;

		 }

		 void resize(size_t n,char ch='\0')
		 {
			 if (n < _size)
				 _size = n;
			 else
			 {
				 reserve(n);//不管n是否大于capacity都给他扩容到n即可
				 for (size_t i = _size; i < n; i++)
				 {
					 _str[i] = ch;
				 }
				 _size = n;
				 _str[_size] = '\0';
			 }
		 }
	  private:
		  char* _str;
		  size_t _size;
		  size_t _capacity;
		  public:

		  size_t static npos;
	};

	size_t string::npos = -1;

};

在这里插入图片描述

①.移动赋值

这里我们再对右值进行深入介绍一下,右值也称为将亡值。为什么叫将亡值呢?一般有的右值的生命周期只有一行,下一行,这个右值就销毁了,所以称为将亡值,就比如函数的返回值就是将亡值。对于内置类型呢,右值称呼为纯右值,对于自定义类型,称为将亡值。
在这里插入图片描述
介绍完后再看上面的代码会发生什么呢?
在这里插入图片描述
所以上面的代码会进行两次深拷贝,第一次调用拷贝构造创建临时对象,第二次调用赋值重载。两次深拷贝代价太大了。但没有办法。
不过大佬注意到了一个细节:记不记得这个func函数的地址是无法获取到的,也就是说func函数的返回值是右值,而func函数的返回值又是自定义类型,所以这个右值是个将亡值。生命周期就在这一行,大佬就利用这个将要销毁的将亡值的特性,对它使用"吸星大法"将这个将亡值的资源全部吸走,再将自己的不要的给它,最后没有开辟空间,没有深度拷贝,ret这个变量就获取到了想要的资源。
所以大佬就按照这样的想法写出了移动赋值。在这里插入图片描述

当要赋值的对象是右值时,就调用移动赋值,当拷贝的对象是左值时,就调用普通重载赋值。
在这里插入图片描述

有人可能会想:啊?右值引用这么香吗?那我们全用右值引用吧!
这可不行喔!右值引用的出现就是为了区别左值和右值,因为当拷贝/赋值的对象是左值时,左值人家就不会立刻销毁,人家的生命周期还长着呢,你一下把它的资源全部抢走,这合理吗?当然不合理了,右值引用的目标就是那些将亡值,对于左值,只能老老实实的按照深拷贝进行。
在这里插入图片描述
在这里插入图片描述

所以在搞出移动赋值后会发生什么呢?
在这里插入图片描述

结果:
1.在写出移动赋值后,虽然拷贝的次数没有减少,但减少了一次深度拷贝的空间消耗!
2.当赋值的对象是右值就调用移动赋值,当赋值的对象是左值就调用普通重载赋值,当是右值时,就大大的提高了效率啦!

②.移动拷贝

我们不仅可以重载赋值运算符的移动赋值,还可以重载拷贝构造的移动拷贝,因为重载后,对整体来说是没有问题的,当拷贝的对象是左值那么就调用拷贝构造,当拷贝的对象是右值那么就调用移动拷贝。
/接下来我们再分析分析下面类似的场景:
在这里插入图片描述
我们要理解编译器对于连续的构造会进行优化成一个构造的,而假设上面的场景是没有优化的,那如果编译器进行优化了呢?会发生什么呢?
对于这样的场景:连续的构造+传值返回的函数表达式
编译器会进行优化:

1.连续的构造/拷贝构造,会合二为一。
2.编译器会将str识别成右值,即将亡值。
【问题①】如何合二为一的?
在函数结束之前,就让str作为拷贝对象,ret调用拷贝构造。而不是在函数结束之后再赋值,因为函数结束后,str就销毁了,所以需要在函数结束之前拷贝,也就是在函数结束之前将str返回,再将str看成将亡值,这一步是编译器做的,我们看不到。
【问题②】为什么将str识别成将亡值?
因为将str识别成将亡值更符合概念,编译器不优化的话,func函数的返回值也是将亡值,编译器优化后,func返回值是str,那这样一对,str理论上就应该被识别成将亡值,并且将str看成将亡值并没有什么问题,反正str也快销毁了。

这样的话最后的过程就只调用了移动拷贝。由原来的会调用拷贝构造进行深拷贝变成了现在的只调用移动拷贝,你说牛不牛吧。
在这里插入图片描述

这里移动拷贝直接就将str的资源转载到了ret。中间没有开辟空间,直接就是将str的空间转移到了ret上。

更甚,编译器对于那些不是连续的构造/拷贝+传值返回函数表达式的场景也进行了优化:
在这里插入图片描述
所以在C++11后,所有的容器都添加上了移动拷贝和移动赋值等移动语义。

Ⅱ.场景二:容器插入接口

在这里插入图片描述
你知道会发生什么过程吗?
在这里插入图片描述
可能现在的你很迷惑,咦?哪来的深拷贝哇?在这里插入图片描述
2Fb81bec301b214f34bb96429734d0ef1a.png&pos_id=img-fnIPVwt2-1695891388131)所以插入一个string类对象,就会进行一次深拷贝。那这咋行哇!太消耗了吧!也不是单指string类对象,面向那些自定义类型,需要深拷贝的对象。
这时右值引用就又上场了!
如果插入的对象是右值,会发生什么?最后一步调用的就是移动拷贝!
在这里插入图片描述

在这里插入图片描述

以前如果没有右值引用那么,插入的是左值调用拷贝构造,插入的是右值,(由const左值引用接收)照样是调用拷贝构造。
所以在右值引用使用后,插入都变香了。
在这里插入图片描述
在这里插入图片描述
所以C++11出来后,所有的容器的插入接口都加上了右值引用

Ⅲ.场景三:完美转发

int main()
{
	int a = 10;
	int& r = a;
	int&& rr = move(a);

	int&& rrr = 10;

	cout << &r << endl;
	cout << &rr << endl;
	cout << &rrr << endl;
}


我们一开始就介绍了右值的特性:1.不能取地址2.无法修改。
那么上面的r的地址和rr地址能打印出来吗?
rr和rrr是右值引用,r是左值引用,r的地址是可以获取到的,那rr呢?
根据右值特性来说是不可以的,但结果是:
在这里插入图片描述

都可以获取到,为什么呢?不是说右值无法获取地址并且无法修改吗?
我们可以这样理解:
右值是不能修改的,就比如字面常量10,我们是无法修改它的。
但是右值引用可以修改哇!,可以想象右值引用开了一块空间,将右值的数据存进去,我们没有说要直接改右值,我们该的是右值引用。

需要注意的是右值是不能取地址的,但是给右值取别名后,会导致右值被存储到特定位置,且可以取到该位置的地址,也就是说例如:不能取字面量10的地址,但是rr1引用后,可以对rr1取地址,也可以修改rr1。如果不想rr1被修改,可以用const int&& rr1 去引用,是不是感觉很神奇。

所以重点来了:右值引用的属性是左值,因为我引用了别人,我改我自己。不直接当面改别人的值。
右值是无法传给左值引用的,但可以传给const修饰的左值引用,但右值引用可以传给左值引用!
(因为右值引用的属性是左值)

本质:
编译器会将右值引用变量的属性识别成左值,为什么要这样呢?因为你本质上还是引用啊!
如果引用不能修改那要引用干什么呢?如果不能修改,那么在移动构造的场景下,就无法实现资源的转移了,所以右值引用必须可以修改!这是编译器规定的。不然下面的操作如何进行呢?
在这里插入图片描述

所以右值引用的默认属性是左值。

好啦上面的问题被解决后,又出现了一个问题:
那如果非要让右值引用的属性是右值呢?有没有什么方法?为什么会出现这样的问题呢?
大佬们发明出一个叫完美转发的函数:可以让右值引用的变量保持右值属性。

什么叫完美转发呢?
1.当t是左值引用,保持左值属性。
2.当t是右值引用,保持右值属性。

还发明了一个叫万能引用的东西:既可以接收左值,又可以接收右值。
当实参是左值时,它就是左值引用(会自动折叠一个&)
当实参是右值时,它就是右值引用。

tempalte <typename T>
void PerfectForward(T&& t)
{
	Fun(forward<T>(t));
}

那为什么会有人想让右值引用保持右值属性呢?
我们就拿刚刚链表插入string对象来说。

int main()
 {
	list<tao::string> lt;
	
	tao::string s("小陶来咯");
	lt.push_back(s);//插入左值,调用拷贝构造-->深拷贝
	cout << endl;

	lt.push_back(move("小陶走咯"));//插入move后的右值,调用的是移动拷贝,转移资源。
	cout << endl;

	lt.push_back("小陶最帅");//这里是隐式类型转换,中间会生成临时变量,也就是将亡值,调用移动拷贝。
}

链表里已经存在push_back(string&& str)移动语义,当传右值就会调用移动语义版本的插入,当传左值就会调用普通插入。当我们传右值时,会进行右值引用,但最后由于右值引用的默认属性是左值,所以走的还是深拷贝,这里就是因为右值引用的属性不是右值导致的,所以我们得需要右值引用保持它的右值属性,才可以调用移动拷贝!

在这里插入图片描述
而这里如何让右值引用变量保存右值属性,上面已经告诉啦,就是使用forward()函数,只要涉及到右值引用的地方都要加上这个完美转发!不然到下一层函数栈帧就失效了。
所以所有的容器中还添加了完美转发操作。

  • 2
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

小陶来咯

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值