Modern C++系列之一--右值引用详解

本文最早发表于公司内部博客,禁止转载

文章目录

    • 一. C++11右值
      • 1. 左值、右值
      • 2. 右值引用
    • 二. 移动语义和完美转发
      • 1. 移动语义(Move Semantics)
        • a. 移动构造函数与移动赋值运算符
        • b. std::move()
        • c. 右值引用一定会是一个右值吗?
      • 2. 万能引用(universal references)
      • 3. 引用折叠(reference collapsing)
      • 5. 完美转发(perfect forwarding)
    • 三.后记

一. C++11右值

1. 左值、右值

在c++11中,左值(L-Value),L应表示Location,表示可寻址,左值指可以取地址的一个值。右值(R-Value), 右值指的是不能取地址的值(关于左值右值,新老解释并不一致,但是我们没必要深究,此处定义更方便我们理解)。区分左值和右值的简单方法:看能不能对表达式取地址,如果能, 则为左值, 否则为右值。所有具有名称的变量或者对象都是左值, 而匿名变量则是右值

c++11中又将右值分为纯右值和将亡值。纯右值就是C++98中右值的概念,如返回非引用值的函数的返回的临时变量;一些写死的值, 如"hello world"、true、1、‘c’,这些值都不能被取地址。

将亡值的产生,是由C++11中右值引用的产生而引起的。 它是和右值引用相关的表达式。这样的表达式通常是将要移动的对象,它通常为:返回右值引用的函数的条用表达式,如std::move,或者转换为右值引用的转换函数的调用表达式。

实际上我们不用太过去在意这些晦涩的语法糖。我们只需记住,左值是允许我们取地址的值或者表达式, 而右值就是不是左值的值。下面一个简单的列子:

	int i = 10;
	i = 11;//正确,i是个左值
	int* p = &i;//正确,i是个左值, 可以取地址
	int & func_L();
	func_L() = 42;//正确,func_L()是一个左值

	//右值
	int func_R();
	int j = 0, *p2 = 0;
	j = func_R();//正确,func_R() 是个右值
	int* p2 = &func_R();//错误,右值无法取地址
	j = 22;//正确,22是一个右值
	&j = p2;//错误,&j是个右值,表达是必须是可修改的左值

2. 右值引用

C++中引用很常见, 就是给变量取一个别名,因为在c++11中增加了右值引用(rvalue reference), 所以我们将之前的引用称作左值引用(lvalue reference)。右值引用使用的符号是&&。顾名思义左值引用、右值引用, 分别对应的是左值和右值。下面是一例子:

	int a = 1;
	int& refA = a;//refA是a别名,它是一个左值引用,修改refA就是修改a
	int& b = 1;//编译出错,1是一个右值,不能赋值给左值引用
	int&& c = 2;//正确,给了一个匿名变量2赋值给右值引用
	int&& d = a;//错误,a是左值
	int&& e = a * 10;//正确, a * 10 是个右值
	const int& f = a * 10;//正确,注意可以将一个const左值引用绑定到一个右值

	class CA 
	{
	public:
		int x1 = 0;
	};

	CA GetAObj()
	{
		return CA();
	}

	CA&&  objA = GetAObj();//正确,GetAObj()返回的是右值(临时变量)
	CA&&  objA2 = objA;//错误,注意,objA是一个右值引用类型变量, 但是它是一个左值

如上,GetAObj()返回的是一个临时变量(右值),在表达式结束后, 它的生命周期也应该结束,通过右值引用,这个右值又延续了生命,他的生命周期跟右值引用变量objA一样。看上述代码,左值引用只能绑定左值, 右值引用只能绑定右值, 不过常量左值引用属列外,它能既能绑定左值,又能绑定右值,不过它只能读, 不能写。

结合上面的代码示例,我们会发现,左值都是有一段生命周期的,而右值要么是写死的字面值,要么是表达式返回的临时对象,即左值持久,右值短暂。所以基于右值引用只能绑定到临时对象, 我们可以得出结论:1)右值引用所引用的对象将要被销毁;2)该对象没有其他拥有或者使用者。这个特征意味着, 使用右值引用可以自由接管所引用右值的资源

二. 移动语义和完美转发

当我刚开始接触C++11中的右值引用, 我也疑惑它到底有什么用处。右值引用可以减少不必要的内存分配和拷贝,它解决了两个问题,实现移动语义和完美转发。c++11之前的老代码,如果用支持c++11的编译器重新编译,性能也会得到一定的提高,原因就在于标准库中使用新增的移动语义。

1. 移动语义(Move Semantics)

a. 移动构造函数与移动赋值运算符

看下面代码,X是一个类,它类似vector, 它包含资源的指针m_pResource。m_pResource是数组指针,它里面包含一些对象,(代码都经过测试,但是只是为了示例,并不完善,能看懂表达意思即可)。

template
class X
{
public:
	X(int capacity = DEFAULT_CAPACITY) explicit
	{
		m_pResource = nullptr;
		m_nSize = capacity;
	}

	X(const X& rhs)
	{
		//TODO:释放m_pResource,给m_pResource重新分配空间,将rhs中资源复制拷贝给m_pResource
		//此处代码类似下面 = 号重载函数, 省略...
	}

	X& operator = (const X& rhs)
	{
		if (this == &rhs) return *this;

		//释放 m_pResource
		if (m_pResource != nullptr) delete[] m_pResource;

		//复制 rhs.m_pResource, 此处默认T为简单类型,可以直接内存复制,复杂对象的话需要一个个赋值
		m_nSize = rhs.m_nSize;
		m_pResource = new T[m_nSize];
		memcpy(m_pResource, rhs.m_pResource, sizeof(T) * m_nSize);
	}

	~X()
	{
		delete[] m_pResource;
	}
protected:
	T* m_pResource;
	int m_nSize;
};

假设X模板类像下面这样使用

	X GetObj();//函数可以获取一个对象
	X x1;
	//中间X1可能还有其他用处...
	x1 = GetObj();

这段代码并没有问题,GetObj()返回一个临时对象,即上文说右值。将这个临时对象赋值给x1,首先我们会释放x1拥有的资源,然后将临时对象资源克隆一份给x1对象(见上述代码operator = 重载),最后赋值操作完成后,临时对象会释放。

很显然,如果我们直接在x1和临时对象之间直接交换资源指针,然后让临时对象的析构函数销毁x1的原始资源,这是更高效的做法。换句话说,在特殊情况下, 如果赋值的右侧是一个右值, 我们希望赋值运算符的行为如下:

// do something...
// swap m_pReource and rhs.m_pResource
// do something...

这就叫做Move Semantics,所谓移动语义。在c++11中, 这种行为可以通过函数的重载来实现,伪代码如下:

X& operator=( rhs)
{
  // [...]
  // swap this->m_pResource and rhs.m_pResource
  // [...]  
}

由于我们重载了赋值运算符,上述中“unknown type”必然需要是一个引用类型,我们当然希望通过引用将右侧的值传给我们。此外,我们期望这个“unknown type”具有这样的行为:当在两个重载之间进行选择时,其中一个重载是普通引用, 另一个重载是“unknown type”,那么右值必须选择这个“unknown type”,而左值则使用普通引用。

如果认真看了上面rvalue reference描述,此时很容易就想到了“unknown type ”就是右值引用了。
要实现移动语义, 我们在类中必须增加两个函数:移动构造函数和移动赋值运算符函数。给上述类X加上这两个函数如下:

	//移动赋值函数 = 号重载,注意没有const修饰
	X& operator = (X&& rhs) noexcept
	{
		if (this == &rhs) return *this;

		//此处可以直接交换rhs.m_pResource和m_pResource
		//我们选择直接交换指针资源,对比上述普通左值引用的赋值运算符函数,此处单纯交换指针,效率提高了
		std::swap(m_pResource, rhs.m_pResource);
		std::swap(m_nSize, rhs.m_nSize);

#if 0  //也可以直接把m_pResource释放,直接将rhs.m_pResource赋值给它,然后rhs.m_pResource置为null
		if (m_pResource != nullptr) delete[] m_pResource;
		m_pResource = rhs.m_pResource;
#endif

		return *this;
 	}

	//移动构造函数,类似移动赋值函数, 注意没有const修饰
	X(X&& rhs) noexcept	
		:m_pResource(rhs.m_pResource)
	{
		m_nSize = rhs.m_nSize;
		rhs.m_pResource = nullptr;//置为空,避免在临时右值对象中再次释放
	}

此时再来看看下面这些调用:

	X x1;//默认构造函数
	//中间X1可能还有其他用处...
	//do something...
	x1 = GetObj();   //调用移动赋值运算符函数,指针交换,减少一次资源拷贝
	X x2(x1);//调用拷贝构造函数
	X x3 = x2;//调用普通的赋值运算符函数

	/*
	  注意:理论上下面这一行会调用移动构造函数,但是编译器进行了
	  RVO(Return Value Optimization)。移动构造本来就是为了减少复制,
	  GetObj构造的对象地址直接给x4复用,省略了复制和移动,vs2017下测试
	  也是这样,这句代码只在GetObj中调用一次默认构造函数,并没有拷贝发生。
	*/
	X x4(GetObj());

结合上文,可以看到,移动构造函数和拷贝构造函数区别,拷贝构造函数参数是const X& rhs,移动构造函数参数是X&& rhs,这是个右值引用。当构造函数参数是一个右值时,优先进入移动构造函数而不是拷贝构造函数。移动构造函数中,它并没有重新分配新的空间,也无资源拷贝发生,它是直接“窃取”了右值引用参数中的资源指针,然后将参数中的指针置为nullptr,置为nullptr防止在右值中释放这个资源,因为这个资源已经被当前对象接管。移动赋值运算符也基本类似,它同样的也是“窃取”资源。

为什么能“窃取”右值中的资源呢?对于一个临时对象,它的生命周期很短暂,一般在执行完当前这条表达式之后,他就释放了,所以充分利用资源,即高效又合理。

b. std::move()

我们仍然有一个问题。基于右值短暂, 左值持久的结论,实际上大部分情况下我们遇到的都是左值。在某些情况下,左值都是一个局部变量,或者左值只使用一次,它的生命周期也很短暂。那么能不能对这样的左值只移动而不拷贝呢?C+11中提供一个std::move()方法,它表示对象能被移出,允许将资源转移到另一个对象,特别是它能生成一个右值表达式,实际这个函数等效于static_cast到一个右值引用类型。std::move能让你在左值上也使用移动语义。看下面的例子:

   x1 = GetObj();   //调用移动赋值运算符函数,减少了一次资源拷贝
   X x2(x1);//调用拷贝构造函数
   X x5(std::move(x1));//此时调用了移动构造函数,x1中资源被移出,理论上x1可被重新赋值使用,但是我们最好不这样做以避免混淆

再举一个列子,标准库中的std::swap,我们已经为类M重载赋值运算符和拷贝构造函数:

	template
	void swap(T& a, T& b) 
	{ 
	  T tmp(a);
	  a = b; 
	  b = tmp; 
	} 
	M  a, b; 
	swap(a, b);

swap函数中没有右值, 所以函数中三行代码都没有使用到移动语义, 但是显而易见, 我们已经知道移动语义会更高效:不管一个变量是作为被拷贝的对象还是赋值运算的目标值,这个变量要么根本不会再使用,要么只作为赋值的目标。

在c++11中我们使用移动语义,重写std::swap,如下:

	template 
	void swap(T& a, T& b) 
	{ 
	  T tmp(std::move(a));
	  a = std::move(b); 
	  b = std::move(tmp);
	} 
	Ma, b;
	swap(a, b);

现在swap函数中三行代码都使用了移动语义。需要注意的是,对于那些没有实现移动语义的类型, 调用这个swap,它仍然跟之前的旧swap函数一样调用。再看下从标准库拷贝出的swap实现:

template inline
	void swap(_Ty& _Left, _Ty& _Right)
		_NOEXCEPT_OP(is_nothrow_move_constructible<_Ty>::value
			&& is_nothrow_move_assignable<_Ty>::value)
	{	// exchange values stored at _Left and _Right
	_Ty _Tmp = _STD move(_Left);
	_Left = _STD move(_Right);
	_Right = _STD move(_Tmp);
	}

跟我上述实现的swap是一样的。我们继续思考一个问题:swap函数中使用了std::move,我们知道std::move是返回的是一个右值引用,对于没有实现移动语义的类型,swap中构造和赋值实际调用的是左值拷贝构造函数和左值赋值运算符,其参数都是一个左值引用。 这是将一个右值引用传递给一个参数为左值引用函数的问题,这涉及到引用叠加。下文会讲到这个问题

c. 右值引用一定会是一个右值吗?

假设类M已经支持了移动语义,现在考虑一下下面的调用:

	 void foo(M&& m)
	{
	  M m_ = m;//等同于M m_(m);
	  // TODO...
	}

现在有一个问题:到底M的哪一个复制构造函数会被调用呢?m是一个右值引用参数,即指向一个右值的一个引用。因此,我们很可能预期m本身也应该像一个右值一样可以绑定,也就是说会调用:

M(M&& rhs);

这个移动构造函数。换句话说,可能会期望任何声明为右值引用的东西就是右值。但是右值引用设计者却设计了一个更加求巧妙的解决方案:被声明为右值引用的东西既可以是左值也可以是右值,区别的标准是:如果有名称,则为左值,否则,它是一个右值
所以上面例子中,声明为右值引用的变量有一个名字(m),它是一个左值:

void ttt(M&& m)
{
  M m_ = m;//此处调用M(const M& rhs);
  // TODO...
}

M&& sss();
M m = sss();//调用M(M&& rhs),因为右边表达式返回值无名字

假如允许将移动语义应用于一个有名字的表达式或变量,我们刚从该表达式或变量中移出东西(即被窃取的资源),在后续代码中确仍然可以访问,这是很容易混淆并容易出错的。移动语义的全部要点就在于“无关紧要”的地方应用它,从某种意义上说,我们移动的东西,会在移动之后立即消失。因此规则为,如果有名称,则为左值,没名字就是右值。根据这个规则, 我们很能大概猜测出std::move()函数的实现方式。std::move()通过引用传递参数,并不做其它事情,std::move结果是一个右值引用,并且没有名字,它是一个右值。std::move()能将他的参数转换成一个右值,即使该参数不是,原理就是通过隐藏参数的名字

有名字为左值,没名字为右值。下面这个列子会让你意识到这条规则的重要性。假设你编写了一个Base父类,并且通过重载了拷贝构造函数和赋值运算符实现了移动语义:

	Base(Base const & rhs); // 普通拷贝构造函数
	Base(Base&& rhs); // 移动构造函数

现在你写了一个雷Derived,它继承Base类, 为了确保移动语义应用于Derived对象的Base部分, 你也重载了Derived的拷贝构造函数和赋值运算符。让我们看下左值拷贝构造函数:

	Derived(Derived const & rhs) 
	  : Base(rhs)
	{
	  //左值版本很简单,并没什么问题
	}

我们再看下移动构造函数:

	Derived(Derived&& rhs) 
	  : Base(rhs) // 错误rhs是一个左值
	{
	  // do something
	}

如果这样编码的话,会调用到Base的左值拷贝构造函数,因为rhs有个名字,它是个左值。而我们实际想做的是调用Base的移动构造函数,只有参数为右值才能调用到移动构造函数,我们应该这样写:

	Derived(Derived&& rhs) 
	  : Base(std::move(rhs)) // OK 调用Base(Base&& rhs)
	{
	  // do something
	}

2. 万能引用(universal references)

当右值引用和模板结合的时候,T&&并不一定表示右值引用,它可能是一个左值引用也可能是一个右值引用。如下面这段代码:

	template
	return_type func(T&& parem)
	{
	    //do something
	}

假如上面的函数模板中参数只表示右值引用的话,那么我们是不能传递左值的,但是实际上却是可以。T&&在此处是一个未定的引用类型,当它作为参数时,它是左值引用还是右值引用取决于它的初始化,如果它被一个左值初始化就是左值引用,如果被一个右值初始化,它就是右值引用。

需要注意的是,只有在进行类型推导时(如函数模板的自动推导、auto关键字),T&&才是universal reference。没有发生类型推导就没有universal reference。universal references只能以T&&出现。decltype也会发生类型推导,其可能能引发引用折叠(下文会讲)的发生,但是并不能说decltype(expression)&&就是一个universal reference。看下面例子:

	template 
	void func(T&& param) {//T的类型需要推导,&&是一个universal references
		//do something
	}

	template 
	class CTest1
	{	
		CTest1(Test&& rhs);//CTest1类型确定,不需要推导,所以&&表示rvalue reference
	};

	template
	class MyVector
	{
		void push_back(T&& param);//调用时已存在MyVector对象,T类型已经确定,没发生类型推导,&&是rvalue reference
	};

	//注意universal references只能以T&&出现

	template
	void func2(const T&& param); //含const,这只是一个rvalue reference

	template
	void func3(std::vector&& param);  //std::vector&&非T&&形式, 且函数调用前std::vector类型已确定,无需推导,&&是一个rvalue reference;

	std::string  s1, s2;
	auto&&  t1 = s1;// t1为auto,需编译时推导类型,&&表示universal reference,它使用左值初始化,所以t1变成可一个左值引用
	auto&&  t2 = string("hello");// 同上,&&表示universal reference,=右侧为右值,auto&&转换为string&&

	decltype(s1) && z1 = s2;//decltype(s1) 也需推导类型,但是此处并不是一个universal reference,此处类型是string&&, 赋值一个左值,编译出错
	decltype(s1) && z1 = std::move(s2);//这样写是对的

universal references常出现于函数模板和auto声明的变量中。我们再来看一个例子,std::vector中的emplace_back函数,它功能跟push_back类似,但是某些情况下效率会更高,先看下标准库中实现:

template >
	class vector
		: public _Vector_alloc<_Vec_base_types<_Ty, _Alloc> >
	{	// varying size array of values

    //省略其它函数实现
	template
		void emplace_back(_Valty&&... _Val)
		{	// insert by moving into element at end
		if (this->_Mylast() == this->_Myend())
			_Reserve(1);
		_Orphan_range(this->_Mylast(), this->_Mylast());
		this->_Getal().construct(_Unfancy(this->_Mylast()),
			_STD forward<_Valty>(_Val)...);
		++this->_Mylast();
		}
	} 

windows这种风格看着乱七八糟, 我们稍微翻译下emplace_back实现:

	template  >
	class vector {
	public:
	    ...
	     //class... 这是模板变长参数 别在意细节~
	    template 
	    void emplace_back(Args&&... args); // 需要 ⇒ 类型推导;
	    ...                                // && ≡ universal references
	};

不要让emplace_back中可变数量参数迷惑了我们,函数的模板参数Args独立于类模板参数T,因此假设我们知道类为std::vector,我们也不会知道emplace_back参数所采用的类型。所以即使知道是类std::vector,编译器仍然需要推断传给emplace_back的参数类型。emplace_back的参数是一个universal reference。所以使用emplace_back ,在参数为右值的情况下,内部会使用移动语义, 减少无谓的拷贝。

3. 引用折叠(reference collapsing)

实际上reference collapsing正是导致上文所述universal references产生的根本原因。C++11后,有左值引用T&和右值引用T&&两种类型,所谓引用折叠就是两种引用排列组合使用,其有四种情况,遵循下述规则:
(1)左值-左值 T& &,变成T&
(2)左值-右值 T& &&,变成T&
(3)右值-左值 T&& &,变成T&
(4)右值-右值 T&& &&,变成T&&

然而C++中并不允许使用对引用的引用,看下面代码:

	int a = 0;
	int &ra = a;
	int & &rra = ra;  // 编译报错:不允许使用对引用的引用

既然不允许使用对引用的引用,那么引用折叠有什么意义呢?其实看了上面universal references,我们已经知道对于一个函数模板, 参数为T&&的时候,如果参数被左值初始化就是左值引用, 如果被右值初始化就是有值引用,很明显这遵循了上述引用叠加规则的3、4两条。universal references实际上就是利用了模板推导和引用折叠的规则,生成不同实例化模板来接受传进的参数
所以编译器不允许我们写int& &&这样的代码,但是它自己却能推导出这样的int& &&这样的代码出来,理由就是编译器可以利用引用折叠规则int& &&可转化为int&,最终版本并没有reference to reference(对引用的引用)。
有了万能引用, 当我们需要即需要接收左值类型又需要接收右值类型的时候,我们不用再写两个重载函数了。

5. 完美转发(perfect forwarding)

在讲解完美转发之前,我们先来了解下一个函数模板的参数推导规则,对于函数模板,在参数为T&&右值引用的时候:

	template
	void foo(T&&);

T的推断规则如下:
(1)当foo调用传入的是一个左值A,那么T将被推导为A&,因此根据引用叠加规则,参数最终类型为A&
(2)当foo调用传入的是一个右值A,那么T将被推导为A,因此参数最值类型为A&&

这个规则很重要。

所谓完美转发,就是将一个函数的参数继续转交给另一个函数进行处理,这个参数可能是左值或者右值,如果能不改变参数的特征,那么它就是完美的。

这时我们可能会想到上面所说的universal references。不过上面讨论支持universal references的函数模板时, 我们并没考虑到在函数中继续转发参数的情况,我们来用代码测试下:

	//接首左值
	template 
	void print_(T&& param)
	{
		std::cout << "print_(T&&)" << std::endl;
	}
	
	//重载,接收右值
	template 
	void print_(T& param)
	{
		std::cout << "print_(T&)" << std::endl;
	}

	//func_forward中T&&是universal reference
	template 
	void func_forward(T&& param)
	{
		//此时此时param虽然是右值引用,但是它有name,是左值
		//左值会进入print_的左值引用重载函数
		//print_(T& &&) = print_(T&)
		//右值引用变成了左值引用
		print_(param);
	}

	void test_forwarding()
	{
		int a = 0;
		func_forward(a);//传入左值
		func_forward(10);//传入右值
	}

运行test_forwarding,打印的是:

output:
print_(T&)
print_(T&)

可以看出不会进入print_的右值重载函数中,这个问题我们前面也探讨过。在func_forward函数内部,不管我们参数是什么类型,它都是有名字的, 有名字是一个左值。
此处在参数转发过程中就是不完美转发了。C++中提供了std::forwarding()模板函数解决这个问题。将func_forward函数简单改写下:

	template 
	void func_forward(T&& param)
	{
		//对比之前的print_(param)
		//此处完美转发了,注意是std::forward不是std::forward
		print_(std::forward(param));
	}

现在看输出

	output:
	print_(T&)
	print_(T&&)

完美转发了 so easy!

我们来看下std::forwarding()函数实现,标准库中它有两个重载, 一个参数是左值引用的一个是右值引用的。很明显在上述函数中参数转发时,参数是一个左值, 所以我们只看左值引用版本的实现:

template inline
	constexpr _Ty&& forward(
		typename remove_reference<_Ty>::type& _Arg) _NOEXCEPT
	{	// forward an lvalue as either an lvalue or an rvalue
	return (static_cast<_Ty&&>(_Arg));
	}

再精简下这个代码:

template 
	 T&& forward(T& param) 
	{	
		return (static_cast(param));
	}

分析下这段代码:T不管是什么类型,经过折叠引用, 其最终还是一个T&的左值引用类型,forward是以左值引用的方式接收param,然后通过static_cast强制转换,最终再以T&&返回。

我们再来分析下func_forward(T&& param)是如何完美转发的:
1)传入参数是int的左值类型

	//本节开头所说函数模板参数类型推导规则,T被推断为int&
	void func_forward(T && param){ print_(std::forward(param));}
	
	
	//再来分析std::forward(param) ,将T=int&代入
	int& && std::forward(int& & param)
	{
		return static_cast(param);
	}
	
	//引用折叠下,最终返回一个左值引用int&
	int& std::forward(int& param)
	{
		return static_cast(param);
	}

最终forward返回一个左值引用,保留实参的左值属性,转发正确!
2)传入参数是int右值类型

//本节开头所说函数模板参数类型推导规则,T被推断为int
	void func_forward(T && param){ print_(std::forward(param));}
	
//再来分析std::forward(param) ,将T=int代入,很明显返回的是个int&&,右值引用
	int && std::forward(int& param)
	{
		return static_cast(param);
	}

最终forward返回一个右值引用,保留实参的左值属性,转发正确!

到此,完美转发也介绍完毕了!

三.后记

在写文章的过程中,我查询了不少资料,一开始我也以为自己熟知右值引用相关知识栈,然而在写此文的过程中, 随着不断查询资料,我才知道我所了解的也只是冰山一角。直至现在我仍不敢说我已窥其全貌。但是,我已经尽力去描述了右值引用及相关特性,如有疏漏或者不足,烦请指正!

附相关参考资料:
https://isocpp.org/blog/2012/11/universal-references-in-c11-scott-meyers
http://thbecker.net/articles/rvalue_references/section_01.html
https://en.cppreference.com/w/cpp/11
《Effective Modern c++》

你可能感兴趣的:(Modern,c++,c++,c++11)