<C++11> 右值引用与移动语义

文章目录

目录

文章目录

一、左值与右值

1. 左值 

2. 右值

3. 左值引用

4. 右值引用

二、右值引用使用场景和意义

1. 左值引用的使用场景

2. 左值引用的短板

3. 右值引用和移动语义

移动构造

移动赋值 

4. STL容器

5. 右值引用引用左值

6. 右值引用的其他使用场景

完美转发

1. 万能引用

2. 完美转发保持值的属性

3. 完美转发使用场景 


一、左值与右值

1. 左值 

什么是左值?

左值是一个表示数据的表达式,如变量名或解引用的指针。

  • 左值可以被取地址,一般情况也可以被修改(const修饰的左值除外)。
  • 左值可以出现在赋值符号的左边,也可以出现在赋值符号的右边。
int main()
{
	//以下的p、b、c、*p都是左值
	int* p = new int(0);
	int b = 1;
	const int c = 2;
	return 0;
}

2. 右值

什么是右值?

右值也是一个表示数据的表达式,如字母常量、表达式的返回值、函数的返回值(不能是左值引用返回)等等。

  • 右值不能被取地址,也不能被修改。
  • 右值可以出现在赋值符号的右边,但是不能出现在赋值符号的左边。
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的返回值就是临时变量,这些都叫做右值。
  • 这些临时变量和常量值并没有被实际存储起来,这也就是为什么右值不能被取地址的原因,因为只有被存储起来后才有地址。
  • 但需要注意的是,这里说函数的返回值是右值,指的是传值返回的函数,因为传值返回的函数在返回对象时返回的是对象的拷贝,这个拷贝出来的对象就是一个临时变量。

而对于左值引用返回的函数来说,这些函数返回的是左值。比如string类实现的[]运算符重载函数:

namespace cl
{
	//模拟实现string类
	class string
	{
	public:
		//[]运算符重载(可读可写)
		char& operator[](size_t i)
		{
			assert(i < _size); //检测下标的合法性
			return _str[i]; //返回对应字符
		}
		//...
	private:
		char* _str;       //存储字符串
		size_t _size;     //记录字符串当前的有效长度
		//...
	};
}
int main()
{
	cl::string s("hello");
	s[3] = 'x';    //引用返回,支持外部修改
	return 0;
}

这里的[]运算符重载函数返回的是一个字符的引用,因为它需要支持外部对该位置的字符进行修改,所以必须采用左值引用返回。之所以说这里返回的是一个左值,是因为这个返回的字符是被存储起来了的,是存储在string对象的_str对象当中的,因此这个字符是可以被取到地址的。 

3. 左值引用

传统的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;
}

4. 右值引用

右值引用

右值引用就是对右值的引用,给右值取别名,通过“&&”来声明。比如:

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

左值引用可以引用右值吗?

  • 左值引用不能引用右值,因为这涉及权限放大的问题,右值是不能被修改的,而左值引用是可以修改。
  • 但是const左值引用可以引用右值,因为const左值引用能够保证被引用的数据不会被修改。

因此const左值引用既可以引用左值,也可以引用右值。比如:

template<class T>
void func(const T& val)
{
	cout << val << endl;
}
int main()
{
	string s("hello");
	func(s);       //s为左值

	func("world"); //"world"为右值
	return 0;
}

右值引用可以引用左值吗?

  • 右值引用只能引用右值,不能引用左值。
  • 但是右值引用可以引用move以后的左值。

move函数是C++11标准提供的一个函数,被move后的左值能够赋值给右值引用。比如:

int main()
{
	int a = 10;

	//int&& r1 = a;     //右值引用不能引用左值
	int&& r2 = move(a); //右值引用可以引用move以后的左值
	return 0;
}

二、右值引用使用场景和意义

左值引用与右值引用的出现都是为了提高性能,而右值引用是在左值的基础上再次提高了一个档次。

左值引用核心价值是减少拷贝,提高效率,右值引用核心价值是进一步减少拷贝弥补左值引用没有解决的场景,例如:函数传值返回、自定义类型中深拷贝的类必须传值返回的场景(后置++)

虽然const左值引用既能接收左值,又能接收右值,但左值引用终究存在短板,因为函数中的临时对象不能返回引用,而C++11提出的右值引用就是用来解决左值引用的短板的。

为了更好的说明问题,这里需要借助一个深拷贝的类,下面模拟实现了一个简化版的string类。类当中实现了一些基本的成员函数,并在string的拷贝构造函数和赋值运算符重载函数当中打印了一条提示语句,这样当调用这两个函数时我们就能够知道。

代码如下:

namespace test_string
{
	class string
	{
	public:
		typedef char* iterator;
		iterator begin()
		{
			return _str; //返回字符串中第一个字符的地址
		}
		iterator end()
		{
			return _str + _size; //返回字符串中最后一个字符的后一个字符的地址
		}
		//构造函数
		string(const char* str = "")
		{
			_size = strlen(str); //初始时,字符串大小设置为字符串长度
			_capacity = _size; //初始时,字符串容量设置为字符串长度
			_str = new char[_capacity + 1]; //为存储字符串开辟空间(多开一个用于存放'\0')
			strcpy(_str, str); //将C字符串拷贝到已开好的空间
		}
		//交换两个对象的数据
		void swap(string& s)
		{
			//调用库里的swap
			::swap(_str, s._str); //交换两个对象的C字符串
			::swap(_size, s._size); //交换两个对象的大小
			::swap(_capacity, s._capacity); //交换两个对象的容量
		}
		//拷贝构造函数(现代写法)
		string(const string& s)
			:_str(nullptr)
			, _size(0)
			, _capacity(0)
		{
			cout << "string(const string& s) -- 深拷贝" << endl;

			string tmp(s._str); //调用构造函数,构造出一个C字符串为s._str的对象
			swap(tmp); //交换这两个对象
		}
		//赋值运算符重载(现代写法)
		string& operator=(const string& s)
		{
			cout << "string& operator=(const string& s) -- 深拷贝" << endl;

			string tmp(s); //用s拷贝构造出对象tmp
			swap(tmp); //交换这两个对象
			return *this; //返回左值(支持连续赋值)
		}
		//析构函数
		~string()
		{
			delete[] _str;  //释放_str指向的空间
			_str = nullptr; //及时置空,防止非法访问
			_size = 0;      //大小置0
			_capacity = 0;  //容量置0
		}
		//[]运算符重载
		char& operator[](size_t i)
		{
			assert(i < _size); //检测下标的合法性
			return _str[i]; //返回对应字符
		}
		//改变容量,大小不变
		void reserve(size_t n)
		{
			if (n > _capacity) //当n大于对象当前容量时才需执行操作
			{
				char* tmp = new char[n + 1]; //多开一个空间用于存放'\0'
				strncpy(tmp, _str, _size + 1); //将对象原本的C字符串拷贝过来(包括'\0')
				delete[] _str; //释放对象原本的空间
				_str = tmp; //将新开辟的空间交给_str
				_capacity = n; //容量跟着改变
			}
		}
		//尾插字符
		void push_back(char ch)
		{
			if (_size == _capacity) //判断是否需要增容
			{
				reserve(_capacity == 0 ? 4 : _capacity * 2); //将容量扩大为原来的两倍
			}
			_str[_size] = ch; //将字符尾插到字符串
			_str[_size + 1] = '\0'; //字符串后面放上'\0'
			_size++; //字符串的大小加一
		}
		//+=运算符重载
		string& operator+=(char ch)
		{
			push_back(ch); //尾插字符串
			return *this; //返回左值(支持连续+=)
		}
		//返回C类型的字符串
		const char* c_str()const
		{
			return _str;
		}
	private:
		char* _str;
		size_t _size;
		size_t _capacity;
	};
}
  • 右值引用的出现是为了区分左值与右值,并且右值引用的价值在于“将亡”的自定义类型的移动拷贝、移动赋值,因为自定义类型一般情况下所占用的字节数较大(例如,vector<vector<string>>、map),而内置类型最大也不过long long为8字节,所以右值引用是为了自定义类型的拷贝效率出现的。
  • 对于只需要浅拷贝的类(Date类),移动构造不需要实现,他的大小一般稍大于内置类型,但是因为没有需要转移的资源,所以直接使用const左值引用版本的拷贝构造即可满
  • 注意:只有将亡值(右值)才能进行移动构造,一些函数的返回值(例如string)会被编译器视为右值,此时才会进行swap即移动构造,如果是一个在main函数内的string对象,那么他在拷贝构造一个新的string时,不会进行swap,因为第一个string是一个左值

1. 左值引用的使用场景

在说明左值引用的短板之前,我们先来看看左值引用的使用场景:

  • 左值引用做参数,防止传参时进行拷贝操作,提高参数传递效率。
  • 左值引用做返回值,防止返回时对返回对象进行拷贝操作,提高返回效率。
void func1(test_string::string s)
{}
void func2(const test_string::string& s)
{}
int main()
{
	test_string::string s("hello world");
	func1(s);  //值传参
	func2(s);  //左值引用传参

	s += 'X';  //左值引用返回
	return 0;
}

因为我们模拟实现是string类的拷贝构造函数当中打印了提示语句,因此运行代码后通过程序运行结果就知道,func1值传参时调用了string的拷贝构造函数。

此外,因为string的+=运算符重载函数是左值引用返回的,因此operator+=返回时不会调用拷贝构造函数,但如果将+=运算符重载函数改为传值返回,那么重新运行代码后就会发现多了一次拷贝构造函数的调用了,拷贝构造了一个临时变量,返回在了 s += 'X' 一行。

我们都知道string的拷贝是深拷贝,深拷贝的代价是比较高的(如果遇见容器套容器,那么返回值的效率就会更低,代价也就更高),所以我们应该尽量避免不必要的深拷贝操作,这里左值引用起到的作用还是很明显的。

2. 左值引用的短板

左值引用虽然能避免不必要的拷贝操作,但左值引用并不能完全避免。

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

如果函数返回的对象是一个局部变量,该变量出了函数作用域就被销毁了,这种情况下不能用左值引用作为返回值,只能以传值的方式返回,这就是左值引用的短板。

C++11提出右值引用就是为了解决左值引用的这个短板的,但解决方式并不是简单的将右值引用作为函数的返回值。

3. 右值引用和移动语义

右值引用和移动语句解决上述问题的方式就是,给当前模拟实现的string类增加移动构造和移动赋值方法。

移动构造

移动构造是一个构造函数,该构造函数的参数是右值引用类型的,移动构造本质就是将传入右值的资源窃取过来,占为己有,这样就避免了进行深拷贝,所以它叫做移动构造,就是窃取别人的资源来构造自己的意思。

在当前的string类中增加一个移动构造函数,该函数要做的就是调用swap函数将传入右值的资源窃取过来,也就是swap成员变量,为了能够更好的得知移动构造函数是否被调用,可以在该函数当中打印一条提示语句。

代码如下:

// 移动构造
string(string&& s)
	:_str(nullptr)
	, _size(0)
	, _capacity(0)
{
	cout << "string(string&& s) -- 移动构造" << endl;
	swap(s);			// 交换这两个对象
}
// 移动赋值
string& operator=(string&& s)
{
	cout << "string& operator=(string&& s) -- 移动赋值" << endl;

	swap(s);		// 交换这两个对象
	return *this;	// 返回左值(支持连续赋值)
}

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

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

例如,当函数重载了两个func函数

  • 一个函数参数为可接收右值引用的 const int& 类型
  • 一个函数参数为只能接受右值引用的 int&& 类型

那么我们调用右值时,编译器会调用哪一个函数?

运行结果为: 

解释:很显然,编译器选择了最为匹配的参数--右值引用,如果我们将右值引用的重载函数注释,那么编译器会选择const int&函数,这就是编译器遵循函数重载最匹配原则

来看添加了移动构造后的效果,编译器优化(连续的构造 / 拷贝构造会直接优化为单次的构造或拷贝构造)+ 移动构造

 测试函数func1

说明一下:

  • 虽然func1当中返回的局部string对象是一个左值,但由于该string对象在当前函数调用结束后就会立即被销毁,我可以把这种即将被消耗的值叫做“将亡值”,比如匿名对象也可以叫做“将亡值”。
  • 既然“将亡值”马上就要被销毁了,那还不如把它的资源转移给别人用,因此编译器在识别这种“将亡值”时会将其识别为右值,这样就可以匹配到参数类型为右值引用的移动构造函数。
  • 内置类型的右值:纯右值
  • 自定义类型的右值:将亡值

这里其实应该是普通的拷贝构造(编译器优化了),因为str是左值,但是另一方面str又是一个将亡值,所以编译器自主的将 str 识别为右值或直接 move为右值(强行识别为右值),然后调用移动构造,这是编译器优化+移动构造的效果,我们来看看没有这两者的效果。

未被编译器优化、没有移动构造时:

实际当一个函数在返回局部对象时,会先用这个局部对象拷贝构造出一个临时对象,然后再用这个临时对象来拷贝构造我们接收返回值的对象。如下: 

因此在C++11标准出来之前,对于深拷贝的类来说这里就会进行两次深拷贝,所以大部分编译器为了提高效率都对这种情况进行了优化,这种连续调用构造函数的场景通常会被优化成一次。比如:

因此按道理来说,在C++11标准出来之前这里应该调用两次string的拷贝构造函数,但最终被编译器优化成了一次,减少了一次无意义的深拷贝。(并不是所有的编译器都做了这个优化)

未被编译器优化、有移动构造时:

如果没有编译器优化,那么真正的过程应该是先进行拷贝构造出一个临时对象,此时编译器不会将str识别为右值,所以是拷贝构造,然后临时对象拷贝str3时调用移动构造。(移动构造是浅拷贝,因为是直接swap成员变量)

 被编译器优化 + 移动构造:

此时编译器不仅将str视为右值,而且将连续的两次拷贝构造优化为一次拷贝构造,两者结合调用移动构造

从结果来看,这三种方式效率完全不同,有编译器优化 + 移动构造的方案,在函数返回值的情景效率最高。

如果没有编译器对 str 视为右值的优化,那么C++11也相当于被砍了半个手臂,因为移动构造的形参是右值引用,而str是一个左值,那么此时必须使用函数返回值时产生的临时变量,因为临时变量属于右值,所以这里会发生一次深拷贝,然后 str3 拷贝临时变量时才会调用移动拷贝,这就对C++11的移动构造、移动赋值特性进行了削弱,所以我们需要将 str 手动 move 为右值,这样才能最高效的只用进行一次移动构造——浅拷贝,就可以构造出str3.

那么如果没有编译器对 str 视为右值的优化,而一个公司已经写了10亿行代码,那么就需要手动 的对返回值进行move,这并不现实。由此可见编译器优化的重要性。

注意:只有将亡值(右值)才能进行移动构造,一些函数的返回值(例如string)是因为会被编译器视为右值,所以才会进行swap即移动构造,如果是一个在main函数内的string对象,那么他在拷贝构造一个新的string时,不会进行swap,因为第一个string是一个左值,如果移动构造了,那岂不就是抢劫吗?

两个string地址不同,可见并没有进行移动构造

move字符串str3,那么str3是不是就能copy2被移动构造了呢?

结果并没有被移动构造,因为move不能改变本身属性

但是如果将move的返回值进行拷贝copy3那么就会成功调用拷贝构造

所以我们可以推断出move的返回值可能是一种str3的浅拷贝的右值

函数返回值属于右值,但是str属于左值,如果函数是值返回,那么就会面临编译器优化、移动构造情景,如果函数是引用返回,编译器就不会进行优化,也没有机会调用移动构造

函数的返回值属于右值,因为如果没有编译器优化,那么函数返回时会拷贝出来临时对象,该临时对象就属于右值,在构造str3的时候就会调用移动构造,而如果有编译器优化,此时就没有临时对象了,str3拷贝的就是func1内的str,但是str是属于左值的,所以为了优化,编译器将str强制识别为了右值(可能会进行move操作)

注意:这里的移动构造发生在func1函数内,即str销毁之前进行该工作,如果str销毁了,那么就无法进行拷贝了。

移动赋值 

来看看编译器将str识别为右值,能带来什么好处

如果我们不是用函数的返回值来构造一个对象,而是用一个之前已经定义出来的对象来接收函数的返回值,这时编译器就无法进行优化了(无法对连续的拷贝构造优化为单次拷贝构造)。比如: 

 

在该案例中,我们将str2的构造和拷贝构造分行编写,所以编译器无法对连续的拷贝构造优化为单次拷贝构造,因为此时是分开的拷贝构造、赋值运算符重载,此时再来看函数返回值的效率如何

移动构造、移动赋值,这是两次浅拷贝,也就是将str的成员变量交换到临时对象,再将临时对象的成员变量交换到str2中,这就是编译器将str视为右值的关键之处,同时也能看到C++11将所有STL容器增加移动构造、移动赋值的效率提升显著!

移动赋值和原有operator=函数的区别:

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

现在给string增加移动构造和移动赋值以后,就算是用一个已经定义过的string对象去接收func1函数的返回值,此时也不会存在深拷贝,而是先调用移动构造生成一个临时对象,然后再调用移动赋值将临时对象的资源转移给我们接收返回值的对象,这个过程虽然调用了两个函数,但这两个函数要做的只是资源的移动,而不需要进行深拷贝,大大提高了效率。

移动构造中,我们是在形参使用了右值引用,而不是在返回值处使用

  • 在返回值处不管是使用左值引用还是右值引用,都是错误的,因为函数内临时对象返回引用后,该临时对象就已经销毁了,在函数外引用该临时对象后就会访问野空间!
  • 只有当返回值不是引用类型时,才会触发编译器的优化方案和移动构造,因为会出现拷贝过程,而引用返回没有拷贝过程!
  • 可以引用返回的只有该对象声明周期不在函数作用域内,例如static修饰的变量、类内的operator=返回值(因为this对象生命周期不在operator()函数内)

4. STL容器

C++11标准出来之后,STL中的容器都增加了移动构造和移动赋值。

以我们刚刚说的string类为例,这是string类增加的移动构造:

这是string类增加的移动赋值:

5. 右值引用引用左值

右值引用虽然不能引用左值,但也不是完全不可以,当需要用右值引用引用一个左值时,可以通过move函数将左值转化为右值。

move函数的名字具有迷惑性,move函数实际并不能搬移任何东西,该函数唯一的功能就是将一个左值强制转化为右值引用,然后实现移动语义。

move函数的定义如下:

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

6. 右值引用的其他使用场景

右值引用版本的插入函数

C++11标准出来之后,STL中的容器除了增加移动构造和移动赋值之外,STL容器插入接口函数也增加了右值引用版本。

以list容器的push_back接口为例:

 

右值引用版本插入函数的意义

如果list容器当中存储的是string对象,那么在调用push_back向list容器中插入元素时,可能会有如下几种插入方式:

int main()
{
	list<test_string::string> lt;
	test_string::string s("1111");

	lt.push_back(s); //调用string的拷贝构造

	lt.push_back("2222");             //调用string的移动构造
	lt.push_back(cl::string("3333")); //调用string的移动构造
	lt.push_back(std::move(s));       //调用string的移动构造
	return 0;
}

list容器的push_back函数需要先构造一个结点,然后将该结点插入到底层的双链表当中。

  • 在C++11之前list容器的push_back接口只有一个左值引用版本,因此在push_back函数中构造结点时,这个左值只能匹配到string的拷贝构造函数进行深拷贝。
  • 而在C++11出来之后,string类提供了移动构造函数,并且list容器的push_back接口提供了右值引用版本,此时如果传入push_back函数的string对象是一个右值,那么在push_back函数中构造结点时,这个右值就可以匹配到string的移动构造函数进行资源的转移,这样就避免了深拷贝,提高了效率。
  • 上述代码中的插入第一个元素时就会匹配到push_back的左值引用版本,在push_back函数内部就会调用string的拷贝构造函数进行深拷贝,而插入后面三个元素时由于传入的是右值,因此会匹配到push_back的右值引用版本,此时在push_back函数内部就会调用string的移动构造函数进行资源的转移。

有移动构造 

这里的移动构造发生在list容器push_back一个string值后,在push_back函数内new新节点Node时,new先开空间再调用Node构造函数,在Node构造函数的初始化列表初始化成员string,然后初始化列表string的构造函数,又因为此时是右值属性,所以会调用string重载的移动构造。

注意:深拷贝后面还有一个构造是因为我们的拷贝构造采用了现代写法,所以有一次构造 

 没有移动构造

对于单参数构造函数隐式类型转换,例如string 

string str = "11111";

如果没有编译器优化,那么实际上是先构造一个临时对象,然后拷贝构造给str,但是有了编译器优化,那么这个常量字符串会直接作为参数构造str

完美转发

1. 万能引用

模板中的&&不代表右值引用,而是万能引用,其既能接收左值又能接收右值。比如:

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<typename T>
void PerfectForward(T&& t)
{
	Fun(t);
}

右值引用和万能引用的区别就是,右值引用需要是确定的类型,而万能引用是根据传入实参的类型进行推导,如果传入的实参是一个左值,那么这里的形参t就是左值引用,如果传入的实参是一个右值,那么这里的形参t就是右值引用。 

上面重载了四个Func函数,这四个Func函数的参数类型分别是左值引用、const左值引用、右值引用和const右值引用。在主函数中调用PerfectForward函数时分别传入左值、右值、const左值和const右值,在PerfectForward函数中再调用Func函数

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<typename T>
void PerfectForward(T&& t)
{
	Fun(t);
}

int main()
{
	int a = 10;
	PerfectForward(a);		// 左值
	PerfectForward(move(a));// 右值

	const int b = 20;
	PerfectForward(b);		// 左值
	PerfectForward(move(b));// 右值
	return 0;
}

运行结果:

由于PerfectForward函数的参数类型是万能引用,因此既可以接收左值也可以接收右值,而我们在PerfectForward函数中调用Func函数,就是希望调用PerfectForward函数时传入左值、右值、const左值、const右值,能够匹配到对应版本的Func函数。

但实际调用PerfectForward函数时传入左值和右值,最终都匹配到了左值引用版本的Func函数,调用PerfectForward函数时传入const左值和const右值,最终都匹配到了const左值引用版本的Func函数。

为什么我们传递的是右值,但是函数调用的是左值类型函数?

因为右值引用 t 本身是一个左值!正因为右值引用 t 是一个左值,才能支持移动构造,因为移动构造内需要swap右值属性,如果右值引用 t 本身是一个右值,那么不仅swap形参无法传递(因为是const &类型)而且还无法swap资源。

根本原因就是,右值被引用后会导致右值被存储到特定位置,这时这个右值可以被取到地址,并且可以被修改,所以在PerfectForward函数中调用Func函数时会将t识别成左值。

那么为什么右值引用了一个常量10,右值引用就可以取地址了?

这是我们误解了右值引用的作用。右值引用本身并不能让你获取字面量常量的地址。 字面量常量(如 10)通常不具有可以获取的内存地址,编译器会直接将其嵌入到代码中,或者在编译时优化掉。它们的值直接被编译器内嵌在指令中,而不是存储在内存中的特定位置。 我们无法获取它们的地址。

即使你使用右值引用绑定到字面量常量,例如:

int&& r = 10;

你仍然无法获得 10 本身的地址,&r 得到的是右值引用 r 的地址,而不是字面量 10 的地址。 r 本身就是一个临时变量,它拥有一个地址,但这个地址与 10 字面量本身的存储位置无关。 10 可能根本就没有存储在内存中,而是直接在指令中使用了其值。

 右值引用主要用于:

  1. 移动语义: 允许高效地转移资源(例如,字符串、容器等)的所有权,避免不必要的复制。

  2. 完美转发: 允许将参数完美地转发给其他函数,保持参数的左值或右值属性。

右值引用绑定的是一个即将被销毁的临时对象或表达式,而不是一个字面量常数本身。 字面量常数的处理方式不同于常规变量。 因此,你不能通过右值引用获取字面量常数的地址,即使编译器可能会创建临时变量来存储字面量常数的值,你也只是获取了该临时变量的地址,而不是字面量常数的地址。

总之,右值引用和字面量常量的地址问题是两个不同的概念,不能混为一谈。

2. 完美转发保持值的属性

要想在参数传递过程中保持其原有的属性,需要在传参时调用forward函数。比如:

template<class T>
void PerfectForward(T&& t)
{
	Func(std::forward<T>(t));
}

经过完美转发后,调用PerfectForward函数时传入的是右值就会匹配到右值引用版本的Func函数,传入的是const右值就会匹配到const右值引用版本的Func函数,这就是完美转发的价值。

3. 完美转发使用场景 

下面模拟实现了一个简化版的list类,类当中分别提供了左值引用版本和右值引用版本的push_back和insert函数。

namespace test_list
{
	template<class T>
	struct ListNode
	{
		T _data;
		ListNode* _next = nullptr;
		ListNode* _prev = nullptr;
	};
	template<class T>
	class list
	{
		typedef ListNode<T> node;
	public:
		//构造函数
		list()
		{
			_head = new node;
			_head->_next = _head;
			_head->_prev = _head;
		}
		//左值引用版本的push_back
		void push_back(const T& x)
		{
			insert(_head, x);
		}
		//右值引用版本的push_back
		void push_back(T&& x)
		{
			insert(_head, std::forward<T>(x)); //完美转发
		}
		//左值引用版本的insert
		void insert(node* pos, const T& x)
		{
			node* prev = pos->_prev;
			node* newnode = new node;
			newnode->_data = x;

			prev->_next = newnode;
			newnode->_prev = prev;
			newnode->_next = pos;
			pos->_prev = newnode;
		}
		//右值引用版本的insert
		void insert(node* pos, T&& x)
		{
			node* prev = pos->_prev;
			node* newnode = new node;
			newnode->_data = std::forward<T>(x); //完美转发

			prev->_next = newnode;
			newnode->_prev = prev;
			newnode->_next = pos;
			pos->_prev = newnode;
		}
	private:
		node* _head; //指向链表头结点的指针
	};
}

输出解释

  • 第一条:首因为我们的list是带头节点的list,所以在实例化对象时,list的构造函数内会new一个节点,此时new底层会调用operator new,先开空间然后调用Node的构造函数,又Node中的T被推导为string类型,所以Node 的_data为string自定义类型,自定义类型在初始化列表中会被自动调用string的默认构造。
  • 第二条:我们实例化了一个string。
  • 第三条:由于s是一个左值,所以调用const T&版本的push_back。
  • 第四条:由于push_back复用了insert,所以调用insert,此处使用了完美转发。
  • 第五条:因为新节点插入,需要new一个Node,与第一条类似,在NOde的构造函数中调用string的默认构造。
  • 第六条:newnode->_data = std::forward<T>(x),此处会调用string的赋值运算符重载(因为newnode内的string已经被调用了默认构造)。
  • 第七条:string的赋值运算符重载是现代写法,复用了string的拷贝构造函数。
  • 第八条:string的拷贝构造也是现代写法,复用了string的构造函数。

我们可以理解这个场景,如果没有完美转发,那么不管左值右值都只能调用const int&版本的push_back、insert函数。

此外,除了在右值引用版本的push_back函数中调用insert函数时,需要用完美转发保持右值原有的属性之外,在右值引用版本的insert函数中用右值给新结点赋值时也需要用到完美转发,否则在赋值时也会将其识别为左值,导致最终调用的还是原有的operator=函数。

也就是说,只要想保持右值的属性,在每次右值传参时都需要进行完美转发,实际STL库中也是通过完美转发来保持右值属性的。

完美转发需要条件

注意:

  • 代码中push_back和insert函数的参数T&&是右值引用,而不是万能引用,因为在list对象创建时这个类就被实例化了,后续调用push_back和insert函数时,参数T&&中的T已经是一个确定的类型了,而不是在调用push_back和insert函数时才进行类型推导的。
  • 如果需要改变右值引用的内容,例如在移动语义中我们需要swap资源,那么这里就需要右值引用本身的属性——左值。
  • 如果需要保持被右值引用的值的右值属性,那么就需要用到完美转发forward(),例如万能引用模板传递参数。
  • 完美转发需要推导模板类型,例如函数模板,而类模板是显示传递的,所以注意区分右值引用和万能引用。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值