右值引用与移动语义是C++11非常重要的语法,我们在之前学的引用都是左值引用,而我们今天要学习的是右值引用。

右值引用与移动语义

一、左值与右值

  • 左值是一个表示数据的表达式(如变量名或解引用的指针),一般是有持久状态,存储在内存中,我们可以获取它的地址,左值可以出现赋值符号的左边,也可以出现在赋值符号右边。定义时const修饰符后的左值,不能给他赋值,但是可以取它的地址。
  • 右值也是一个表示数据的表达式,要么是字⾯值常量、要么是表达式求值过程中创建的临时对象等,右值可以出现在赋值符号的右边,但是不能出现出现在赋值符号的左边,右值不能取地址。

总的来说,左值与右值最本质区别是能不能取地址。

左值:

// 左值:可以取地址
// 以下的p、b、c、*p、s、s[0]就是常⻅的左值
int* p = new int(0);
int b = 1;
const int c = b;
*p = 10;
string s("111111");
s[0] = 'x';

右值:

// 右值:不能取地址
double x = 1.1, y = 2.2;
// 以下⼏个10、x + y、fmin(x, y)、string("11111")都是常⻅的右值
10;
x + y;
fmin(x, y);
string("11111");

二、左值引用与右值引用

  • Type& r1 = x; 就是左值引用,左值引用就是给左值取别名;Type& rr1 = y; 就是右值引用,同样的道理,右值引用就是给右值取别名。
  • 左值引用不能直接引用右值,但是const左值引用可以引用右值;右值引用不能直接引用左值,但是右值引用可以引用move(左值)。
    在这里插入图片描述

注意,右值引用只是引用了右值对象,但右值引用本身的属性是一个左值。

  • 语法层面看,左值与右值都是取别名,不开空间。从汇编层来看,左值引用与右值引用都是用指针来实现,没有区别。
int x=1;
int& r1=x;
int&& rr1=2;

反汇编:

	int& r1 = x;
00007FF7A6E918C5  lea         rax,[x]  
00007FF7A6E918C9  mov         qword ptr [r1],rax  
	int&& rr1 = 2;
00007FF7A6E918CD  mov         dword ptr [rbp+64h],2  
00007FF7A6E918D4  lea         rax,[rbp+64h]  
00007FF7A6E918D8  mov         qword ptr [rr1],rax  

可见两者底层都是用指针来实现的。

// 左值:可以取地址
// 以下的p、b、c、*p、s、s[0]就是常⻅的左值
int* p = new int(0);
int b = 1;
const int c = b;
*p = 10;
string s("111111");
s[0] = 'x';
double x = 1.1, y = 2.2;

// 左值引用给左值取别名
int& r1 = b;
int*& r2 = p;
int& r3 = *p;
string& r4 = s;
char& r5 = s[0];

// 右值引用给右值取别名
int&& rr1 = 10;
double&& rr2 = x + y;
double&& rr3 = fmin(x, y);
string&& rr4 = string("11111");

// 左值引用不能直接引用右值,但是const左值引用可以引用右值
const int& rx1 = 10;
const double& rx2 = x + y;
const double& rx3 = fmin(x, y);
const string& rx4 = string("11111");

// 右值引用不能直接引用左值,但是右值引用可以引用move(左值)
int&& rrx1 = move(b);
int*&& rrx2 = move(p);
int&& rrx3 = move(*p);
string&& rrx4 = move(s);
string&& rrx5 = (string&&)s;
// b、r1、rr1都是变量表达式,都是左值

// 这⾥要注意的是,rr1的属性是左值,所以不能再被右值引用绑定,除⾮move一下
int& r6 = r1;
// int&& rrx6 = rr1;
int&& rrx6 = move(rr1);

三、右值引用延长生命周期

右值引用可以为临时对象延长生命周期,虽然const左值引用也可以延长生命周期,但是它不能被修改。

std::string s1 = "Test";
// std::string&& r1 = s1; // 错误:不能绑定到左值

const std::string& r2 = s1 + s1; // OK:到 const 的左值引用延⻓⽣存期
// r2 += "Test"; // 错误:不能通过到 const 的引用修改

std::string&& r3 = s1 + s1; // OK:右值引用延⻓⽣存期
r3 += "Test"; // OK:能通过到⾮ const 的引用修改

要注意的是:这里值的延长生命周期是指上面右值对象的生命周期从一行延长到与r2,r3相同,这不能推广到函数中创建的对象,想通过右值返回值来延长生命周期是不可取的,因为函数的栈帧是开在栈区,函数结束立马销毁栈帧,该右值引用所指 的右值也被销毁,这个引用就成了“野引用”
我们以下面的代码为例:
在这里插入图片描述
可以看到没有任何输出。

四、右值引用的参数匹配

  • C++98中,我们实现一个const左值引用作为参数的函数,那么实参传递左值和右值都可以匹配。
  • C++11以后,分别重载左值引用、const左值引用、右值引用作为形参的f函数,那么实参是左值会匹配f(左值引用),实参是const左值会匹配f(const 左值引用),实参是右值会匹配f(右值引用)
void f(int& x)
{
	std::cout << "左值引用重载 f(" << x << ")\n";
} 
void f(const int& x)
{
	std::cout << "到 const 的左值引用重载 f(" << x << ")\n";
} 
void f(int&& x)
{
	std::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. 左值引用使用场景及作用

在我们以往使用左值引用返回值时,主要目的就是为了减少拷贝,同时还可以修改实参和返回对象的价值。左值引用已经解决了大多数问题,但是对于下面的addStrings与generate的场景,左值引用返回就无法很好地解决。那可以使用move(左值)将左值变成右值再返回吗?上面已经演示过了,显然是不行的。

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;
	}

	// 这⾥的传值返回拷⻉代价就太⼤了
	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] + vv[i - 1][j - 1];
			}
		} 
		return vv;
	}
};

输出型参数的解决方法就是在函数中再引入一个输出值的引用的参数,利用这个参数来返回。

2. 移动构造与移动赋值

部分string类代码:

class string
{
public:
	string(const char* str = "")
		:_size(strlen(str))
		,_capacity(_size)
	{
		cout << "string(char* str)-构造" << endl;
		_str = new char[_capacity + 1];
		strcpy(_str, str);
	}

	// 拷贝构造
	string(const string& s)
		:_str(nullptr)
	{
		cout << "string(const string& s) -- 拷贝构造" << endl;
		reserve(s._capacity);
		for(auto ch : s)
		{
			push_back(ch);
		}
	}

	void swap(string& ss)
	{
		::swap(_str, ss._str);
		::swap(_size, ss._size);
		::swap(_capacity, ss._capacity);
	}
	
	// 移动构造
	string(string&& s)
	{
		cout << "string(string&& s) -- 移动构造" << endl;
		// 转移掠夺你的资源
		swap(s);
	}
	
	string& operator=(const string& s)
	{
		cout << "string& operator=(const string& s) -- 拷贝赋值" <<
			endl;
		if (this != &s)
		{
			_str[0] = '\0';
			_size = 0;
			reserve(s._capacity);
			for (auto ch : s)
			{
				push_back(ch);
			}
		}
		return *this;
	}
	
	// 移动赋值
	string& operator=(string&& s)
	{
		cout << "string& operator=(string&& s) -- 移动赋值" << endl;
		swap(s);
		return *this;
	}
	
	~string()
	{
		//cout << "~string() -- 析构" << endl;
		delete[] _str;
		_str = nullptr;
	}
	const char* c_str() const
	{
		return _str;
	}
private:
	char* _str = nullptr;
	size_t _size = 0;
	size_t _capacity = 0;
};

我们来看看它的移动构造是怎么实现的,是怎么体现C++11的优势的。
我们知道,当实参为右值时,右值本身没有没有价值,生命周期即将结束,那么我们可以交换右值与原string的资源,完成构造与赋值,这也很好体现了右值引用为左值的作用:可以被修改,这种方式的效率明显比拷贝构造要高不少。

我们来看看以下代码:

int main()
{
	zzt::string s1("xxxxxx");
	//拷贝构造
	zzt::string s2 = s1;
	//构造+移动构造,优化为直接构造
	zzt::string s3 = zzt::string("114514");
	//移动构造
	zzt::string s4 = move(s1);
	cout << "***********************************" << endl;
	cout << s1.c_str() << endl;

	return 0;
}

请添加图片描述
我们可以看到s4用move后的s1进行移动构造,之后s1却变为空。
我们在之前实现移动构造时,我们默认的是右值对象不重要,直接掠夺右值对象的资源,因此,s1的资源被s4掠夺,变为空。因此我们不能随便move(左值)去移动构造对象。

3. 右值引用与移动语义解决传值返回问题

这里我们可以分为两个场景:右值对象构造与赋值

// 场景1
int main()
{
	zzt::string ret = bit::addStrings("11111", "2222");
	cout << ret.c_str() << endl;
	return 0;
} 
// 场景2
int main()
{
	zzt::string ret;
	ret = bit::addStrings("11111", "2222");
	cout << ret.c_str() << endl;
	return 0;
}

我们可以将它分成四种场景来探讨,这也对应了没有移动语义和有移动语义的场景:

  • 右值对象构造,只有拷贝构造,没有移动构造
  • 右值对象构造,有拷贝构造,也有移动构造
  • 右值对象赋值,只有拷贝构造,没有移动构造与移动赋值
  • 右值对象赋值,有拷贝构造,也有移动构造与移动赋值

3.1 右值对象构造,只有拷贝构造,没有移动构造

  • 我们可以将代码复制到test.cpp文件中,在linux环境下使用g++ test.cpp -fno-elide-constructors关闭编译器优化来运行。
    请添加图片描述
    可以看到没有优化的情况下,它是进行了两次拷贝构造
  • 在VS2019 debug环境下,编译器会将两次拷贝构造合为一次拷贝构造,将str直接拷贝给ret。
  • 在VS2019 release及VS2022以上版本中,编译器优化的更厉害,它会直接优化成一次构造,连拷贝构造都没有发生。这种情况下,函数中的str就是ret的别名,它们的地址是相同的。
    在这里插入图片描述

3.2 右值对象构造,有拷贝构造,也有移动构造

  • 无优化:两次移动构造;先移动构造给临时对象,再移动构造给ret。
  • VS2019 debug第一代优化:两次移动构造变为一次移动构造,与之前思路类似
  • VS2019 release及VS2022以上第二代优化:两次移动构造变为直接构造,没有移动构造
    在这里插入图片描述

3.3 右值对象赋值,只有拷贝构造,没有移动构造与移动赋值

  • 无优化:两次拷贝,先将str拷贝构造给临时对象,临时对象再拷贝赋值给ret。
  • VS2019 debug第一代优化:输出几乎没有变化,只是对传值做了些许优化。
  • VS2019 release及VS2022以上第二代优化:构造+拷贝赋值,先构造临时对象,str就是临时对象的引用,再将临时对象拷贝赋值给ret。这个我们可以通过运行结果来验证,str析构在拷贝赋值以后,因此可以知道str就是ret的别名。
    在这里插入图片描述

3.4 右值对象赋值,有拷贝构造,也有移动构造与移动赋值

  • 无优化:两次移动,先将str 移动构造给临时对象,再将临时对象移动赋值给ret。
  • VS2019 debug第一代优化:输出几乎无变化。
  • VS2019 release及VS2022以上第二代优化:构造+移动赋值,先构造临时对象,str就是临时对象的引用,再将临时对象移动赋值给ret。与上面的情况类似。
    在这里插入图片描述

3.5 总结整理

以下是对于上面情况的整理:

场景 不优化 第一次优化 第二次优化
① 右值对象构造
只有拷贝构造(C++98,无移动语义)
两次拷贝构造
① str → 临时对象
② 临时对象 → ret
两次拷贝优化为一次拷贝构造
str → ret
直接构造 ret
str 与 ret 可看作同一个对象(RVO/NRVO),没有任何拷贝
② 右值对象构造
有拷贝构造 + 移动构造(C++11)
两次移动构造
① str → 临时对象
② 临时对象 → ret
两次移动优化为一次移动构造
str → ret
直接构造 ret,没有移动构造(RVO/NRVO)
③ 右值对象赋值
只有拷贝构造,没有移动构造、移动赋值(C++98)
拷贝构造 + 拷贝赋值
① str → 临时对象(拷贝构造)
② 临时对象 → ret(拷贝赋值)
几乎没有变化
仍是拷贝构造 + 拷贝赋值
str 成为返回值对象(消除临时对象),只剩一次拷贝赋值
④ 右值对象赋值
有拷贝构造 + 移动构造 + 移动赋值(C++11)
移动构造 + 移动赋值
① str → 临时对象(移动构造)
② 临时对象 → ret(移动赋值)
几乎没有变化仍是移动构造 + 移动赋值 消除临时对象,返回对象直接作为移动源,只剩一次移动赋值

六、类型分类

  • C++11对类型进行了划分,右值(rvalue)被分为纯右值(prvalue)和将亡值(xvalue)。
  • 泛左值(glvalue)分为将亡值(xvalue)和左值(lvalue)。
  • glvalue = lvalue + xvalue
    rvalue = prvalue + xvalue
    xvalue 同时属于 glvalue 和 rvalue。
类别 有名字 有身份 能取地址 能移动资源
左值(lvalue)
纯右值(prvalue)
将亡值(xvalue)

在这里插入图片描述

七、引用折叠

  • C++11的引用折叠主要应用于出现引用的引用的场景。C++中不能直接定义引用的引用,如int& && r=i;,这样会报错,因此C++11引入了引用折叠。
  • 我们可以通过typedef来实现引用的引用操作。
  • 那我们怎么判断引用的引用是左值引用还是右值引用呢?

这里有一个规定:只有右值引用的右值引用才会折叠成右值引用;其余都是左值引用
如果传入const左值引用,那么折叠后依旧是const左值引用

我们来看几个案例:
定义两个函数f1f2

template<class T>
void f1(T& x)
{}

template<class T>
void f2(T&& x)
{}
  • f1中,当T为int&时,x为左值引用;T为int&&时,x也为左值引用。因此x始终为左值引用。
  • f2中,当T为int&时,x为左值引用;T为int&&时,x为右值引用。因此,我们可以利用传参来控制x为左值引用还是右值引用。

以上面f1,f2为函数:

int main()
{
	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&&
	
	// 没有折叠->实例化为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;
}

我们再看一个万能引用的示例:

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;
	// a是左值,推导出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)中,我们传左值t就是左值引用;传右值t就是右值引用。但是我们之前讲过,右值引用本身的属性是左值,因此,无论我们传什么进去,t都是左值,如果我们想要保持t原有的属性继续往下面Fun函数中传递,此时就要用到完美转发。
完美转发forward简介:
在这里插入图片描述
完美转发forward本质是⼀个函数模板,他主要还是通过引用折叠的方式实现,下⾯示例中传递给Function的实参是右值,T被推导为int,没有折叠,forward内部t被强转为右值引用返回;传递给Function的实参是左值,T被推导为int&,引用折叠为左值引用,forward内部t被强转为左值引用返回。

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-->t是int&&
	Function(10);
	//a是左值-->T为int-->t是int&
	int a;
	Function(a);
	//move(a)是右值-->T为int-->t是int&&
	Function(move(a));

	const int b = 8;
	// b是左值-->T为const int&-->t为const int&
	Function(b);
	//move(b)是右值-->T为const int-->t为const int&&
	Function(move(b));
	return 0;
}

结果:

左值引用
左值引用
左值引用
const 左值引用
const 左值引用

不用完美转发forward,结果全是左值引用或const左值引用。
接下来用上forward:

void Function(T&& t)
{
	Fun(forward<T>(t));
}

结果:

右值引用
左值引用
右值引用
const 左值引用
const 右值引用

恢复正常。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐