前往小程序,Get更优阅读体验!
立即前往
发布
社区首页 >专栏 >【C++】右值引用全面揭秘:解锁 C++11 的性能革命与移动语义奥秘!

【C++】右值引用全面揭秘:解锁 C++11 的性能革命与移动语义奥秘!

作者头像
HZzzzzLu
发布2024-12-26 09:02:42
发布2024-12-26 09:02:42
12400
代码可运行
举报
文章被收录于专栏:codingcoding
运行总次数:0
代码可运行
C++11 引入了右值引用,这是C++语言的一个重要特性,目的是为了提高程序的性能,尤其在对象的传递和资源管理方面。 右值引用和左值引用相比,解决了左值引用在传返回值的不足,显著减少了不必要的拷贝,提高效率。

右值和左值的基本概念

在 C++ 中,表达式的值可以分为左值和右值两种类型:

  • 左值:表示一个持久存在的对象或者内存位置,通常在赋值语句的左侧出现,以及有可以取地址的特性。例如:变量、数组元素、解引用等都是左值。
代码语言:javascript
代码运行次数:0
复制
//以下均是左值
//变量
int a = 3;
int* pa = &a;
const int b = a;
int* ptr = new int(3);

//解引用
*ptr = 4;

//数组元素
string str("abcdef");
str[0];
  • 右值:表示临时对象、字面量常量或者表达式的结果,通常只能出现在赋值语句的右侧,有不可取地址的特性。右值是没有名称的、即将被销毁的对象。
代码语言:javascript
代码运行次数:0
复制
int a = 4, b = 5;

//以下均是右值
100;
a + b;
fmin(x, y);
string("qwer");

左值引用和右值引用

引用就是给对象取别名,右值引用就是给右值取别名左值引用就是给右值取别名右值引用左值引用在语法形式上是类似的:

代码语言:javascript
代码运行次数:0
复制
Type& ref = x;  //左值引用

Type&& rref = y;  //右值引用

可以看到,左值引用是用 & ,而右值引用是用 &&

代码语言:javascript
代码运行次数:0
复制
//左值
int a = 3;
int* pa = &a;
const int b = a;
int* ptr = new int(3);

//左值引用
int& ra = a;
int*& rpa = pa;
const int& rb = b;
int* rptr = ptr;
代码语言:javascript
代码运行次数:0
复制
int a = 4, b = 5;
//右值
//100;
//a + b;
//fmin(x, y);
//string("qwer");

//右值引用
int&& rr1 = 100;
int&& rr2 = a + b;
int&& rr3 = fmin(a, b);
string&& rr4 = string("qwer");

对右值引用的理解:右值本质上是一种生命周期很短的对象(将亡值),而右值引用实际上是将该对象的地址保存,该对象就不会立即销毁,延长了生命周期。

注意,通过右值引用创建出来的对象的属性是左值,这一点非常的重要,涉及到下面提及的完美转发。


一般而言,右值引用只能引用右值,左值引用只能引用左值,但在特殊情况下,右值引用可以引用左值,左值应用也可以引用右值 。

  • 左值引用去引用右值:需要在前面加 const 修饰。
  • 右值引用去引用左值:需要对左值进行 move
代码语言:javascript
代码运行次数:0
复制
//左值引用去引用右值,需要加const
const int& r1 = 10;
const string& r2 = string("abcd");

//右值引用求引用左值,需要对左值move
int x = 3;
int&& rr1 = move(x);
string str("1234");
string&& rr2 = move(str);

左值引用在特定条件下可以引用右值,这一点在前面其实也有所涉及,之前模拟实现容器(如 vector、list等)的 push_back 函数: void push_back (const T& x),加 const 是为了让 x 既能接收左值也能接收右值。

move 本质上就是强制类型转换,不会改变左值对象本身的属性。

代码语言:javascript
代码运行次数:0
复制
template <class T>
typename remove_reference<T>::type&& move (T&& arg) noexcept;
{
	return static_cast<remove_reference<decltype(arg)>::type&&>(arg)
}

右值引用的主要用途

在右值引用出现之前,左值引用还是无法解决在某些场景下需要传值返回的问题,而右值引用的出现,实现了移动语义完美转发,显著提高C++程序在对象的的拷贝和传递的性能。

移动语义

移动语义可以分为移动构造移动赋值,其实就可以“移动”资源而不是复制资源。从而避免不必要的资源拷贝。右值引用允许了资源从一个对象转移到另一个对象,而不是创建一个新的副本。

接下来,我们就以一个自定义 string 类,来看看移动语义的作用是多么强大,还有在没有移动语义之前VS的设计者如何跟冗余构造斗智斗勇。

代码语言:javascript
代码运行次数:0
复制
class string
{
public:

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

	//析构
	~string()
	{
		delete[] _str;
		_str = nullptr;
		_size = _capacity = 0;
	}

	void swap(string& s)
	{
		std::swap(_str, s._str);
		std::swap(_size, s._size);
		std::swap(_capacity, s._capacity);
	}

	//拷贝构造
	string(const string& s)
	{
		cout << "string(const string& s) -- 拷贝构造" << endl;
		string tmp(s._str);
		swap(tmp);
	}

	//赋值重载
	string& operator=(const string& s)
	{
		cout << "string& operator=(string s) -- 赋值重载" << endl;
		if (this != &s)
		{
			_str[0] = '\0';
			if (s._capacity > _capacity)
			{
				char* tmp = new char[s._capacity + 1];
				if (_str)
					delete[] _str;
					
				_str = tmp;
				_capacity = s._capacity;
			}

			strcpy(_str, s._str);
			_str[s._capacity] = '\0';
			_size = s._size;
		}

	return *this;
}

private:
	char* _str;
	size_t _size;
	size_t _capacity;
};
只有拷贝构造和赋值重载而没有移动语义的传值返回

正常对一个自定义类传值返回是需要进行3次构造的,函数体内将构造需要返回 str 对象,在返回 str 时先对其拷贝构造出一个临时对象 tmp ,函数体外的用于接收返回值的 ret 再去拷贝构造这个 tmp 对象,很明显,这样多次构造消耗很大,效率很低。如下图:

聪明的编译器设计师一想,这样不慢了啊,干脆不构建临时对象,直接将 str 拷贝构造给 ret 不就行了。如下图:

另一位设计师看了,不行啊,你这样还是慢,看我的,直接将3次构造合三为一。如下图:

我们可以运行一下来验证结果,如下图:

结果就是编译器真的做出了 “合3为1”的极致优化来提高效率,这里真的不得不感叹下设计编译器的设计师能力是真的强👍。

如果用于接收的 ret 是已经存在的变量,那么走的就是就是赋值重载,跟上面的情况类似。

这里编译器就不能做合3为1 的优化了,因为在赋值重载之前可能会对 ret 进行其他的操作。

增加移动构造和移动赋值的传值返回

移动构造和移动赋值本质上就是掠夺资源,即使在函数体内的 str 对象是左值,但是它是临时对象,出了作用域就销毁了,所以可以将其视作特殊的右值,这里隐式地调用 move j将 str 转成右值,从而调用移动构造或者移动赋值。

代码语言:javascript
代码运行次数:0
复制
void swap(string& s)
{
	std::swap(_str, s._str);
	std::swap(_size, s._size);
	std::swap(_capacity, s._capacity);
}

//移动构造
string(string&& s)
{
	cout << "string(string&& s) -- 移动构造" << endl;
	swap(s);
}

//移动赋值
string& operator=(string&& s)
{
	cout << "string& operator=(string&& s) -- 移动赋值" << endl;
	swap(s);
	return *this;
}

可以看出,移动构造移动赋值的效率对比拷贝构造赋值重载来说是非常高的,因为是利用 swap 函数交换右值的资源。


有了移动语义后,上面的传值返回的情况就会变成如下的情况:

VS2022 上,上面的情况哪怕是有了移动构造,还是会被极致优化成一个构造,测不出走移动构造的结果。

而对于移动赋值,编译器优化没那么厉害,可以测得出移动赋值的结果。

注意:知道了移动语义后,这里有必要提一个点,那就是不要轻易使用 move 函数将左值强制转换成右值,因为这样可能会触发移动构造导致原本左值的自资源被转移走,这是十分危险的。

举个例子:

可以看到,将 str1 进行 move 之后再赋值给 str2 , 触发了 移动构造str2 的资源被转移到了 str1 ,这显然是非常不合理的,所以对于 move 要慎重使用。

引用折叠与完美转发

引用折叠是C++11引入的重要概念,与右值引用和模板的结合密切相关。引用折叠的目的是为了帮助解决模板中出现的多重引用类型,使得代码更加简洁和一致。

当我们再模板中使用右值引用 && 时,可能会遇到多重引用类型的问题:

代码语言:javascript
代码运行次数:0
复制
template<class T>
void func(T&& x)
{}

int main()
{
   int a = 0;
   int& b = a;
   int&& c = 10;
   
   //多重引用的问题
   func(a);       //-> int& &
   func(b);       //-> int& &&
   func(c);	   //-> int&& &&	
}

在上面的例子中,由于传入的值的类型各不一样,导致多重引用类型的问题。

有人可能会疑惑,为什么右值引用的函数能传入左值a? 因为这里的 && 其实不代表右值引用,当你传左值时,函数会将其识别成左值的引用 T& ,然后触发引用折叠,成为一个左值引用。换句话说 && 并不是右值引用,而是万能引用,这种函数既能接收左值也能接收右值

引用折叠的规则如下:

  1. T& & → T&:左值引用与左值引用折叠为一个左值引用。
  2. T& && → T&:左值引用与右值引用折叠为一个左值引用。
  3. T&& & → T&&:右值引用与左值引用折叠为一个右值引用。
  4. T&& && → T&&:右值引用与右值引用折叠为一个右值引用。

为了进一步理解引用折叠,我们可以通过一个简单的例子来观察它是如何工作的。

代码语言:javascript
代码运行次数:0
复制
#include <iostream>

template <typename T>
void f(T&& arg) {
    cout << "T&&" << endl;
}

template <typename T>
void g(T& arg) {
    cout << "T&" << endl;
}

int main() {
    int x = 10;
    f(x);         // T& & -> T&,输出 "T&"
    f(20);        // T&& && -> T&&,输出 "T&&"
    g(x);         // T& & -> T&,输出 "T&"
}

总结一下,对于函数 func(T&& x) 来说,只有传入的值是右值引用类型的 x ,才能是右值引用其余情况均被引用折叠成左值引用


对于这种 Func (T&& x) 函数模板,无论传入什么类型的值,x 的值都是左值,因为左值引用的值是左值,右值引用创建出来的值也是左值,这一点我们在上面提到过。

如果我们在函数内再次调用其他函数,可能会因为参数属性退化成左值导致不能正确调用其他函数。

代码语言:javascript
代码运行次数:0
复制
void func(int& x) {
	cout << "左值引用" << endl;
}

void func(const int& x) {
	cout << "const 左值引用" << endl;
}

void func(int&& x) {
	cout << "右值引用" << endl;
}

void func(const int&& x) {
	cout << "const 右值引用" << endl;
}

template <class T>
void forwarder(T&& arg) {
	func(arg);  
}

int main() {
	//参数属性会退化成左值,一下输出均是左值引用输出
	int a = 10;
	forwarder(a);        
	forwarder(20);       

	const int b = 10;
	forwarder(b);        
	forwarder(move(b));
}

想要解决这种问题,就需要用到完美转发 std::forward ,他会自动处理参数的类型,确保传递给下一层的函数的参数保持器原有的属性(左值或者右值)。

代码语言:javascript
代码运行次数:0
复制
template <typename T>
T&& forward(typename std::remove_reference<T>::type& arg) noexcept {
    return static_cast<T&&>(arg);
}

有了完美转发 std::forward 后,上面的例子就能正确调用了。

代码语言:javascript
代码运行次数:0
复制
void func(const int& x) {
	cout << "const 左值引用" << endl;
}

void func(int&& x) {
	cout << "右值引用" << endl;
}

void func(const int&& x) {
	cout << "const 右值引用" << endl;
}

template <class T>
void forwarder(T&& arg) {
	func(forward<T>(arg));  // 完美转发,确保转发时引用类型正确
}

int main() {
	int a = 10;
	forwarder(a);        // 传入左值,输出 "左值引用"
	forwarder(20);       // 传入右值,输出 "右值引用"

	const int b = 10;
	forwarder(b);        // 传入const 左值,输出 "const 左值引用"
	forwarder(move(b));  // 传入const 右值,输出 "const 右值引用"
}

在这个例子中,forwarder(T&& arg) 是一个函数模板,使用了 T&& ,可以接受任何类型的参数(无论左值和右值)。std::forward<T>(arg) 会根据传入的参数类型,自动选择是转发为左值引用还是右值引用。引用折叠在这里的作用是,确保当我们在模板中使用右值引用时,最终传递给func 的参数是合适的引用类型。

拜拜,下期再见😏

摸鱼ing😴✨🎞

本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2024-12-26,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • C++11 引入了右值引用,这是C++语言的一个重要特性,目的是为了提高程序的性能,尤其在对象的传递和资源管理方面。 右值引用和左值引用相比,解决了左值引用在传返回值的不足,显著减少了不必要的拷贝,提高效率。
  • 右值和左值的基本概念
  • 左值引用和右值引用
  • 右值引用的主要用途
    • 移动语义
      • 只有拷贝构造和赋值重载而没有移动语义的传值返回
      • 增加移动构造和移动赋值的传值返回
    • 引用折叠与完美转发
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档