【C++11】——右值引用、移动语义

目录

1. 基本概念

1.1 左值与左值引用

1.2 右值和右值引用

1.3 左值引用与右值引用

2. 右值引用实用场景和意义

2.1 左值引用的使用场景

2.2 左值引用的短板

2.3 右值引用和移动语义

2.3.1  移动构造

2.3.2 移动赋值

2.3.3 编译器做的优化

2.3.4 总结

2.4 右值引用引用左值

2.5 右值引用的其他场景(插入接口)

3. 完美转发

3.1 万能引用&&

3.2 forward完美转发在传参的过程中保留对象原生类型属性

3.3 完美转发的使用场景

1. 基本概念

传统的C++语法中就有引用的语法,而C++11中新增了的右值引用语法特性,所以从现在开始我们之前学习的引用就叫做左值引用。无论左值引用还是右值引用,都是给对象取别名。

1.1 左值与左值引用

左值:

左值是一个表示数据的表达式(如变量名或解引用的指针),有如下特性:

  1. 我们可以获取它的地址+可以对它赋值不一定能赋值,但一定能取地址);
  2. 左值可以出现赋值符号的左边,右值不能出现在赋值符号左边;
  3. 定义时const修饰符后的左值,不能给他赋值,但是可以取它的地址

左值引用:

  • 左值引用就是给左值的引用,给左值取别名
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;
}

1.2 右值和右值引用

右值:

右值也是一个表示数据的表达式,如临时变量字面常量、表达式返回值,函数返回值(这个不能是左值引用返回,要是传值返回)等等,有如下特性:

  1. 右值可以出现在赋值符号的右边,但是不能出现出现在赋值符号的左边,
  2. 右值不能取地址
  3. 综上左值和右值最大区别在于左值可以取地址,右值不可以取地址(因为右值是临时变量,没有实际被存储起来)。

补充:

C++里又把右值分为两类(纯右值和将亡值):

  1. 纯右值内置类型的对象):10、a + b……
  2. 将亡值自定义类型的对象):

          传值返回生成的拷贝:to_string(1234)、匿名对象:string("11111")、s1 + "hello"

右值引用:

  • 右值引用就是对右值的引用,给右值取别名。
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);
 
	/*
	这里编译会报错:error C2106: “=”: 左操作数必须为左值
	10 = 1; 
	x + y = 1; 
	fmin(x, y) = 1;
	*/
 
	/*
	这里编译会报错,右值不能取地址
	cout << &10 << endl;
	cout << &(x + y) << endl;
	cout << &fmin(x, y) << endl;
	*/
	return 0;
}
  • 右值是不能取地址的,但是给右值取别名后,会导致右值被存储到特定位置,且可以取到该位置的地址,也就是说例如:不能取字面量10的地址,但是rr1引用后,可以对rr1取地址,也可以修改rr1。如果不想rr1被修改,可以用const int&& rr1 去引用,这个了解一下实际中右值引用的使用场景并不在于此,这个特性也不重要。
int main()
{
	double x = 1.1, y = 2.2;
	int&& rr1 = 10;
	const double&& rr2 = x + y;
	rr1 = 20;
	rr2 = 5.5; // 报错
	return 0;
}

1.3 左值引用与右值引用

左值引用总结:

  1. 左值引用只能引用左值,不能引用右值;
  2. 但是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;
}

右值引用总结:

  1. 右值引用只能引用右值,不能引用左值;
  2. 但是右值引用可以引用move以后的左值;
int main()
{
	// 右值引用只能引用右值,不能引用左值。
	int&& r1 = 10;
	int a = 10;
	/*
	error C2440: “初始化”: 无法从“int”转换为“int &&”
	message : 无法将左值绑定到右值引用
	int&& r2 = a;
	*/
	// 右值引用可以引用move以后的左值
	int&& r3 = move(a);
	return 0;
}

总结:

  1. 左值引用只能引用左值,不能引用右值;
  2. 但是const左值引用既可以引用左值,也可以引用右值
  3. 右值引用只能引用右值,不能引用左值;
  4. 但是右值引用可以引用move以后的左值

右值引用是通过移动构造和移动赋值来极大提高深拷贝的效率,详情见下文:

2. 右值引用实用场景和意义

2.1 左值引用的使用场景

左值引用解决的是拷贝构造引发的深拷贝而带来的开销过大、效率低的问题:

  • 左值引用做参数,防止传值传参引发的拷贝构造问题(导致效率低)
  • 左值引用做返回值,防止返回对象发生拷贝构造的操作(导致效率低)
void func1(cpp::string s)
{}
void func2(const cpp::string& s)
{}
int main()
{
	cpp::string s1("hello");
	func1(s1);//值传参
	func2(s1);//传引用传参
 
    // string operator+=(char ch) 传值返回存在深拷贝
    // string& operator+=(char ch) 传左值引用没有拷贝提高了效率
	s1 += 'a';//左值引用作为返回值
	return 0;
}

总结:

我们都清楚string类的+=运算符是左值引用作为返回值,这样做避免了传值返回引发的拷贝构造,而这样做的原因在于string类的拷贝构造为深拷贝,要经历开空间等操作,开销太大了,导致效率低,传值传参同样也是会发生拷贝构造(深拷贝)这个问题,为了避免如此之大的开销,使用左值引用可以很好的解决此问题,因为左值引用就是取别名,无开销,提高了效率。

2.2 左值引用的短板

 左值引用可以避免一些不必要的拷贝构造操作,但是并不是所有情况都是可以避免的:

  • 左值引用做参数,能够完全避免传参时不必要的拷贝操作;
  • 左值引用做返回值并不能完全避免函数返回对象时不必要的拷贝操作

当函数返回的是一个临时对象时,不能使用引用返回,因为临时对象出了函数作用域就销毁了只能使用传值返回,而传值返回难免会引发拷贝构造带来的深拷贝问题,但是无法避免,这就是左值引用的短板,示例:

namesapce cpp
{
	cpp::string to_string(int value)
	{
		bool flag = true;
		if (value < 0)
		{
			flag = false;
			value = 0 - value;
		}
		cpp::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 str;
	}
}

因为这里的to_string是传值返回,所以在调用to_string的时候一定会调用拷贝构造,而拷贝构造实现的又是一个深拷贝,效率低:

int main()
{
	cpp::string ret = cpp::to_string(1234);//string(const string& s) -- 深拷贝
	return 0;
}
  • 如果强硬的把上面的to_string实现成左值引用返回,那么又会出现一个问题,我str是临时对象,因为是左值引用返回,所以返回的是str的别名,把别名作为返回值再区拷贝构造ret对象,但是临时对象str出了作用域就调用析构函数销毁了,即使能够访问对象的值,但是空间已经不存在了,此时就发生了内存错误。(不能返回局部变量的引用!

综上所述,为了解决左值引用的短板,C++11引出了右值引用,但并不是简单的把右值引用作为返回值,要对string进行改造,详情见下文:

2.3 右值引用和移动语义

移动构造:

string拷贝构造的const左值引用会接收左值和右值,但是编译器遵循最匹配原则,如果我们单独增加一个右值引用版本的拷贝构造函数,使其只能接收右值,根据最匹配原则,遇到右值,传入右值引用版本的拷贝构造函数,遇到左值传入左值引用版本的拷贝构造函数,这样就能解决了左值引用带来的弊端,而上述单独增加的函数就是我们的移动构造!!!

移动赋值:

operator=函数采用的是const左值引用接收参数,因此无论赋值时传入的是左值还是右值,都会调用原有的operator=函数。增加移动赋值之后,由于移动赋值采用的是右值引用接收参数,因此如果赋值时传入的是右值,那么就会调用移动赋值函数(最匹配原则)。string原有的operator=函数做的是深拷贝,而移动赋值函数中只需要调用swap函数进行资源的转移,因此调用移动赋值的代价比调用原有operator=的代价小。

2.3.1  移动构造

为了解决左值引用的短板,我们需要在cpp::string中增加移动构造,移动构造的本质是将参数右值(将亡值)的资源窃取过来,占位已有,那么就不用做深拷贝了,所以它叫做移动构造,就是窃取别人的资源来构造自己。因为将亡值的特点就是很快就要被销毁了,在你销毁之前还不如把你的资源通过移动构造传给别人。

  • 该移动构造函数要做的就是调用swap函数将传入右值的资源窃取过来,为了能够更好的得知移动构造函数是否被调用,可以在该函数当中打印一条提示语句。
namespace cpp
{
	class string
	{
	public:
		//移动构造
		string(string&& s)
			:_str(nullptr)
			, _size(0)
			, _capacity(0)
		{
			cout << "string(string&& s) -- 移动构造,资源转移" << endl;
			swap(s);
		}
	private:
		char* _str;
		size_t _size;
		size_t _capacity;
	};
}
int main()
{
	cpp::string ret = cpp::to_string(1234);//转移将亡值的资源
	cpp::string s1("hello");
	cpp::string s2(s1);//深拷贝,左值拷贝时不会被资源转移
	cpp::string s3(move(s1));//转移将亡值的资源
	return 0;
}

移动构造与拷贝构造的区别:

  1. 在没有添加移动构造之前,拷贝构造采用的是const左值引用接收参数,所以无论左值还是右值都会被传进去,势必会引发一系列左值引用的短板
  2. 添加移动构造后,由于移动构造采用右值引用接收参数,只能接收右值
  3. 根据编译器的最匹配原则,左值传入左值引用的拷贝构造,右值传入右值引用的移动构造

2.3.2 移动赋值

移动赋值是一个赋值运算符重载函数,该函数的参数是右值引用类型的,移动赋值也是将传入右值的资源窃取过来,占为己有,这样就避免了深拷贝,所以它叫移动赋值,就是窃取别人的资源来赋值给自己的意思。

  • 在当前的string类中增加一个移动赋值函数,该函数要做的就是调用swap函数将传入右值的资源窃取过来,为了能够更好的得知移动赋值函数是否被调用,可以在该函数中打印一条提示语句。
namespace cpp
{
	class string
	{
	public:
		// 移动赋值
		string& operator=(string&& s)
		{
			cout << "string& operator=(string&& s) -- 移动赋值" << endl;
			swap(s);
			return *this;
		}
	private:
		char* _str;
		size_t _size;
		size_t _capacity;
	};
}
int main()
{
	cpp::string ret;//string(string&& s) -- 移动构造,资源转移
	ret = cpp::to_string(1234);//string& operator=(string&& s) -- 移动赋值,资源转换
	return 0;
}

来区分下移动赋值和operator=:

  1. 在没有增加移动赋值之前,由于原有operator=函数采用的是const左值引用接收参数,因此无论赋值时传入的是左值还是右值,都会调用原有的operator=函数。
  2. 增加移动赋值之后,由于移动赋值采用的是右值引用接收参数,因此如果赋值时传入的是右值,那么就会调用移动赋值函数(最匹配原则)。
  3. string原有的operator=函数做的是深拷贝,而移动赋值函数中只需要调用swap函数进行资源的转移,因此调用移动赋值的代价比调用原有operator=的代价小。

总结:

  • 这里运行后,我们看到调用了一次移动构造和一次移动赋值。因为如果是用一个已经存在的对象接收,编译器就没办法优化了。cpp::to_string函数中会先用str生成构造生成一个临时对象,但是我们可以看到,编译器很聪明的在这里把str识别成了右值,调用了移动构造。然后在把这个临时对象做为cpp::to_string函数调用的返回值赋值给ret1,这里调用的移动赋值。
  • 这里虽然调用两次函数,但都只是资源的移动,不需要进行深拷贝,大大提高了效率

2.3.3 编译器做的优化

int main()
{
	cpp::string s = cpp::to_string(1234);
	return 0;
}

1、先来看下没有移动构造编译器做的优化:

不优化:

  • 如果没有移动构造,那我们先前实现的to_string只能够传值返回,传值返回会先拷贝构造出一个临时对象,再用这个临时对象再拷贝构造我们接收返回值的对象。如图所示:

【C++11】——右值引用、移动语义_第1张图片

 优化:

  • C++11标准出来之前,也就是C++98的情况,本来应该是两次拷贝构造,但是编译器对其进行了优化,连续两次的拷贝构造函数最终被优化成一次,直接拿str拷贝构造s。

【C++11】——右值引用、移动语义_第2张图片

 2、再来看看有移动构造编译器做的优化:

不优化:

  • C++11出来后,我们假设它不优化,根据先前的了解,不优化的话,左值str会拷贝构造给一个临时对象,这个临时对象就是一个右值将亡值),随后进行移动构造,也就是先拷贝构造再移动构造:

【C++11】——右值引用、移动语义_第3张图片

 优化:

  • C++11这里编译器进行优化后,左值str会被优化成右值(通过move把左值变为右值),再移动构造给一个临时对象,此临时对象再移动构造给s,但是编译器还会再进行一次优化,把左值str识别出右值后直接移动构造给s。也就是只进行一次移动构造

【C++11】——右值引用、移动语义_第4张图片

 3、来看看编译器对移动赋值的处理:

  • 当我们不是用函数的返回值来构造一个对象,而是用一个之前已经定义出来的对象来接收函数的返回值,测试代码如下:
int main()
{
	cpp::string ret;
	ret = cpp::to_string(1234);
	return 0;
}

此时编译器会把左值str会被优化成右值(通过move把左值变为右值),再移动构造给一个临时对象,此临时对象再通过移动赋值传给之前已经定义出来的对象。

【C++11】——右值引用、移动语义_第5张图片

这里编译器并没有对这种情况进行优化,因为如果是用一个已经存在的对象接收,编译器就没办法优化了。cpp::to_string函数中会先用str生成构造生成一个临时对象,但是我们可以看到,编译器很聪明的在这里把str识别成了右值,调用了移动构造。然后在把这个临时对象做为cpp::to_string函数调用的返回值赋值给ret1,这里调用的移动赋值。

2.3.4 总结

  1. 左值引用的深拷贝 -- 拷贝构造 / 拷贝赋值
  2. 右值引用的深拷贝 -- 移动构造 / 移动赋值

C++11后STL中的容器都是增加了移动构造和移动赋值。

2.4 右值引用引用左值

move函数

按照语法,右值引用只能引用右值,但右值引用一定不能引用左值吗?因为:有些场景下,可能真的需要用右值去引用左值实现移动语义。当需要用右值引用引用一个左值时,可以通过move函数将左值转化为右值。C++11中,std::move()函数位于头文件中,该函数名字具有迷惑性,它并不搬移任何东西,唯一的功能就是将一个左值强制转化为右值引用,然后实现移动语义
move函数的定义:

template
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后的左值。

测试如下:

int main()
{
	cpp::string s1("hello world");
	// 这里s1是左值,调用的是拷贝构造
	cpp::string s2(s1);//string(const string& s) -- 深拷贝
	// 这里我们把s1 move处理以后, 会被当成右值,调用移动构造
	// 但是这里要注意,一般是不要这样用的,因为我们会发现s1的
	// 资源被转移给了s3,s1被置空了。
	cpp::string s3(std::move(s1));//string(string&& s) -- 移动构造
	return 0;
}

2.5 右值引用的其他场景(插入接口)

C++11后STL容器中的插入接口函数也增加了右值引用的版本:

 

注意:

  • C++98的时候,push_back函数只有const左值引用版本,所以这就会导致无论是左值还是右值都会被传入这个左值引用版本的push_back,势必会引发后续的深拷贝而带来的开销过大等问题。
  • C++11出来后,push_back函数增加了右值引用版本,如果传入push_back函数的是一个右值,那么在push_back函数构造节点时,这个右值就可以匹配到容器的移动构造函数进行资源的转移,这样就避免了深拷贝,提高了效率。
int main()
{
	list lt;
	cpp::string s1("1111");
	// 这里调用的是拷贝构造
	lt.push_back(s1);//string(const string& s) -- 深拷贝
	// 下面调用都是移动构造5
	lt.push_back("2222");//string(string&& s) -- 移动构造
	lt.push_back(std::move(s1));//string(string&& s) -- 移动构造
	return 0;
}

上述代码中的插入第一个元素s1就会匹配到push_back的左值引用版本,在push_back函数内部就会调用string的拷贝构造函数进行深拷贝,而后面插入的两个元素时由于传入的是右值,因此会匹配到push_back的右值引用版本,此时在push_back函数内部就会调用string的移动构造函数进行资源的转移。

3. 完美转发

3.1 万能引用&&

&&应用在模板中时,不代表右值引用,而是万能引用,万能引用既能接收左值,也能接收右值。

template
void PerfectForward(T&& t)//万能引用
{
	//……
}

万能引用的作用:

  1. 模板中的&&不代表右值引用,而是万能引用,其既能接收左值又能接收右值。
  2. 模板的万能引用只是提供了能够接收同时接收左值引用和右值引用的能力,
  3. 但是引用类型的唯一作用就是限制了接收的类型,后续使用中都退化成了左值

示例:

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
void PerfectForward(T&& t)
{
	Fun(t);
}
int main()
{
	PerfectForward(10);//右值
	int a;
	PerfectForward(a);//左值
	PerfectForward(std::move(a));//右值
	const int b = 8;
	PerfectForward(b);//const左值
	PerfectForward(std::move(b));//const右值
	return 0;
}

【C++11】——右值引用、移动语义_第6张图片

注意看上面的Fun函数我写了四个,分别是左值引用、const左值引用、右值引用、const右值引用。main函数中我把左值、右值、const左值、const右值均作为参数传入了函数模板PerfectForward里头,因为其参数类型是万能引用&&,所以既可以接收左值也可以接收右值,可是最终的测试结果却全为左值引用了:

  • 实际传入PerfectForward函数模板的左值和右值均匹配到了左值引用版本的Fun函数,而传入PerfectForward函数模板的const左值和const右值均匹配到了const左值引用版本的Fun函数。
  • 造成此现象的根本原因在于右值被引用后会导致右值被存储到特定位置,这时这个右值可以被取到地址,并且可以被修改,所以在PerfectForward函数中调用Func函数时会将t识别成左值。

这也就是万能引用限制了接收的类型,在后续使用中均退化成了左值,但是我们希望能够在传递过程中保持它的左值或者右值的属性, 就需要用我们下面学习的完美转发。

3.2 forward完美转发在传参的过程中保留对象原生类型属性

我们想要在传参的过程中保留对象的原生类型属性,就需要用到forward函数:

template
void PerfectForward(T&& t)
{
    //完美转发
	Fun(std::forward(t));
    //std::forward(t)在传参的过程中保持了t的原生类型属性。
}

【C++11】——右值引用、移动语义_第7张图片

 完美转发后,左值、右值、左值引用、右值引用就可以被传入到理想状态下的函数接口了。

3.3 完美转发的使用场景

这里把先前模拟实现的list拖过来做测试案例,先前实现的list是没有对push_back函数和insert函数写一个右值引用版本的,所以这就会导致无论数据是左值还是右值都会传入左值引用的版本,势必在构建节点的时候引发深拷贝,测试代码如下:

int main()
{
	cpp::list lt;
	cpp::string s1("1111");//右值
	lt.push_back(s1);//左值
	lt.push_back("2222");//右值
	lt.push_back(std::move(s1));//右值
}

为了避免深拷贝带来的开销过大,我们对push_back和insert函数单独写一个右值引用的版本,同样也要对构造函数写一个右值引用的版本,因为创建节点需要用到节点类的构造函数:

//节点类
template
struct list_node
{
	//……
	//右值引用节点类构造函数
	list_node(T&& val)
		:_next(nullptr)
		, _prev(nullptr)
		, _data(val)
	{}
};
template
class list
{
public:
	//……
	//右值引用版本的push_back
	void push_back(T&& xx)
	{
		insert(end(), xx);
	}
	//右值引用版本的insert
	iterator insert(iterator pos, T&& xx)
	{
		Node* newnode = new Node(xx);//创建新的结点
		Node* cur = pos._node; //迭代器pos处的结点指针
		Node* prev = cur->_prev;
		//prev newnode cur
		//链接prev和newnode
		prev->_next = newnode;
		newnode->_prev = prev;
		//链接newnode和cur
		newnode->_next = cur;
		cur->_prev = newnode;
		//返回新插入元素的迭代器位置
		return iterator(newnode);
	}
private:
	Node* _head;
}

虽然这里实现了右值引用版本,但是实际的运行结果依然是深拷贝的,和没写之前的运行结果一模一样,原因如下:

  • 根据先前的了解我们得知:&&应用在模板中时,不代表右值引用,而是万能引用,万能引用既能接收左值,也能接收右值。但是在后续的使用中,会把接收的类型全部退化成左值,既然退化成左值,那么自然会进入后续的深拷贝

此情况就是典型的完美转发的使用场景,解决办法如下:

  • 我们需要在传参的过程中保留对象的原生类型属性,就需要用到forward函数:
//右值引用节点类的构造函数
list_node(T&& val)
	:_next(nullptr)
	, _prev(nullptr)
	, _data(std::forward(val))//完美转发
{}
//右值引用版本的push_back
void push_back(T&& xx)
{
	//完美转发
	insert(end(), std::forward(xx));
}
//右值引用版本的insert
iterator insert(iterator pos, T&& xx)
{
	//完美转发
	Node* newnode = new Node(std::forward(xx));
	//……
	return iterator(newnode);
}

【C++11】——右值引用、移动语义_第8张图片 

你可能感兴趣的:(c++,数据结构,开发语言)