C++ 左右值、左右引用、万能引用、引用折叠、完美转发详解

前言

本文介绍C++11引入的完美转发实现,其本质通过万能引用+引用折叠+std::static_cast进行实现。
本文将详细介绍以下内容:

  • 左值、范左值、右值、将亡值、纯右值等基本概念;
  • 左值引用、右值引用等基本概念
  • 万能引用、引用折叠
  • 完美转发
  • 完美转发的实现原理

左值与右值

左值与右值在C++11标准发布后有了很大的变化,在C++11之前似乎可以简单的将左值与右值理解为等式左右两边的值(右值是只能出现在等式右边的值,而左值则是可以出现在等式两边),这样的理解在C++11之前不会有什么大问题,但是在C++11标准之后,其引入移动语义之后,再这样理解就大有问题了。
由于cppreference官网的定义非常详细,但是想要全部记一下来并不容易。这里进行一些简单的说明。

左值

cppreference中对左值(lvalue)的定义非常详细,但是其定义又过于详细,很难记住。这里可以将左值简单的理解为可以通过&获得地址的值。
比较特殊的几个左值:

  • 字符串常量值,例如"Hello World"
  • 内嵌的左自增或者左自减操作,例如--a, ++a
  • 通过static_cast转换为左值引用,例如static_cast<int&>(a)
  • 第二个运算数为左值的逗号运算符返回值为左值;
  • 左值数组的下标访问,所谓左值数组指的是有标识指定的数组,例如:int a[] = {0};,其中a就是左值数组,那么a[0]就是一个左值。

不是左值的特殊例子:

  • 类变量的成员函数不是左值;
  • 枚举不是左值。

纯右值

纯右值(prvalue, pure rvalue)首先不是一个左值,即不能通过&获取地址。一种常见的纯右值为字面量(字符串字面量除外,其为左值),同时内嵌的算术运算法、逻辑运算法以及比较运算符返回值均为纯右值。
特殊的几个纯右值:

  • 类变量的成员函数(静态方法往往不会被认为属于类变量,静态方法属于左值);
  • 枚举;
  • 第二个运算数为右值的逗号运算符返回值为右值;
  • lamda表达式;
  • this指针(通常的指针是可以通过地址符获取指针变量的地址的,但是this不行,this更像是一个地址的别名,编译器在处理的时候会把this换成是一个地址字面量)。
  • 内嵌的右自增或右自减操作,例如a++, a--
  • 通过static_cast转换成非引用类型,例如static_cast<int>(a)

通过上面的例子可以将纯右值简单的理解为变量值的拷贝,且纯右值常常用于变量值的初始化。

将亡值

将亡值也叫xvalue(expiring value)其意思是指生命周期将要结束的值,该值与纯右值有个相同的特征,不能通过&获取地址。
几个常见的将亡值:

  • 右值的非静态成员变量(如果有的话)访问;
  • 第二个运算数为将亡值的逗号运算符返回值为左值;
  • 返回值为右值引用的函数返回值;
  • 通过static_cast转换成右值引用类型,例如static_cast<int&&>(a),那么std::move当然也是将亡值;
  • return x中的x是将亡值;
  • 右值数组的下标访问,右值数组指的是没有标识符的数组,例如:(int[3]){1, 2, 3},自然(int[3]){1, 2, 3}[0]是一个将亡值。由于运算符的优先级,需要增加括号。

注意:右值数组的下标访问为将亡值这是标准的定义,但是实际中需要确保自己编译器是否完全支持C++11的特性。例如MinGWgcc 8.1.0会将其认定为左值。

将亡值的资源往往可以重复利用。
注意:三元运算符的左右值确定规则是通过其返回值来决定,而其返回值类型确定较为复杂,准备之后介绍std::common_type时进行详细的解释,conditionnal operator cppreference

范左值

范左值(generalized lvalue)指的是左值和将亡值,这类值往往是对象或者函数的标识符。

右值

右值(rvalue)指的是纯右值和将亡值。

常见混淆

右值引用是右值吗?
左值引用是左值吗?

不论右值引用还是左值引用其都是左值,都可以通过取地址运算获取地址。

注意:这里的右值引用与左值引用是左值指的均是其对应的表达式,而不是指对应的变量,虽然在这里这两种说法有着相同的表现形式,即均为变量的名称,但是左值、右值的概念指的是表达式的类型,而不是变量的类型。

引用

左值引用

为什么需要左值引用?

在没有左值引用之前,当一个函数对形参进行修改同时希望实参也进行修改的话,那么就必须要用到指针了,例如学习C语言区分形参和实参过程中的交换程序:

void badSwapTwoInt(int a, int b)
{
	int temp = a;
	a = b;
	b = a;
}

上面的代码并不能够交换两个变量的值,原因在于形参是对实参的拷贝,若要进行交换则必须使用指针(在没有左值引用之前):

void goodSwapTwoInt(int *a, int *b)
{
	int temp = *a;
	*a = *b;
	*b = temp;
}	

这样的写法很不友好,每次需要读取值或者改变值的时候还需要使用*进行解引用,很是麻烦,同时指针对于初学者很不友好(虽然即使没有指针,C++对初学者也很不友好),有没有一种简单的方法,能够让两个变量进行绑定,其中一个改变,另一个也跟着变?
于是引用便产生了。上述代码的左值引用版本:

void goodSwapTwoIntByRef(int &a, int &b)
{
	int temp = a;
	a = b;
	b = temp;
}

上述的代码和badSwapTwoInt相比只是在形参前面加了&符号,也就是表示左值引用,只要做了这样的更改后,形参发生变化实参也就会发生变化(如果实参发生变化,形参也会变化,但是在这里并不能体现)。
于是左值引用便出现了,其语义更像是为变量取一个别名,引用的值始终会和绑定的变量值保持一致(任意一个发生变化,另一个均会同时变化)。

左值引用如何实现?

指针常量(constant pointer),一个指针,当其指定到一个地址后,其指向不能发生改变,但是可以修改指向的数据,也就是对于上面的swap编译器实际上为我们做了如下的操作:

void goodSwapTwoIntAct(int* const a, int* const b)
{
	int temp = *a;
	*a = *b;
	*b = temp;
}

在调用的时候编译器会帮我们取地址与解引用,这也是为什么上文中我用了“同时”这个词语,因为本来就是同一块地址,当然是同时进行变化,无所谓先后。
注意:上面的方式可以实现左值引用,但是按照cppreference的说法,引用是不需要额外的空间的(除非特殊情况下),而上面的代码是需要额外的空间存放指针指向的地址的(通过&a可以查看指针地址),所以编译器实现引用的方式,可能是直接进行地址替换,将所有引用直接替换成其指向的地址。

右值引用

为什么需要右值引用?

这就得提到数据的移动与复制了。在C++11之前是没有移动语义的,只有复制语义(复制构造器和赋值操作符)。对于成员变量含有指针的数据类型,我们往往需要自己实现复制构造器与赋值操作符,以保证数据能够成功被拷贝(通常所说的深拷贝),而不只是让指针指向同一块内存(通常说的浅拷贝)即可。
这么看似乎没有什么问题,但是当将这一切带入到函数传参过程中,一切就变得微妙起来了。当函数的形参是一个非指针或者左值引用类型时,那么函数每进行一次调用都会进行一次复制构造,那么内存中就会有同一份数据的多份样本,实际上,很多情况下我们只需要内存中有一份数据即可,这在形参被const修饰是表现得尤为明显:

// when calling function a, and reaching c, there are three copies of data (a, b, c) in memory.
void c(const BigData c) { // do something }
void b(const BigData b) { C(b); }
void a(const BigData a) { B(a); }

那用指针或者左值引用不就好了?
嗯,不错,用指针或者左值引用当然可以解决部分问题,例如上面的代码改成:

// there are only one data (a, b and c are the same data) in memory.
void c(const BigData &c) { // do something }
void b(const BigData &b) { C(b); }
void a(const BigData &a) { B(a); }

但是还是有问题,例如现在调用a函数:

a(BigData{"a.txt"}); // read big data from data.txt

上面的代码也能实现内存中只有一份数据,但是却不能在a, b, c中修改形参,想要修改则必须去掉形参中的const,但是去掉const之后就不能够绑定右值了,而右值引用的出现解决了这个问题。
右值引用可以用来绑定右值,以及用来实现移动语义,为此我们先介绍移动语义。

移动语义

所谓移动语义指的是先进行浅拷贝,然后将指针指向空。
根据移动语义的定义,我们可以知道我们如果要保证每一次参数的传递后内存中均只有一份数据,那么我们只需要每一次传参的时候通过移动语义构造参数即可。而移动语义的实现则依赖于右值引用以及移动构造器,移动赋值操作符。
右值引用,非模板参数使用&&指明:

st::string &&rRef = "HelloWorld";

移动构造器:

ClassType(ClassType &&other);

移动赋值操作符(通常返回值为左值引用):

ClassType& operator=(ClassType &&other);

C++11提供了默认的移动构造器与移动赋值操作符,其表现行为同默认复制构造器和默认赋值操作符。因此在大多数情况下,我们需要自己动手实现,以保证其符合移动语义。
下面来通过std::string给出几个移动语义的效果:

  1. 在初始化时使用右值进行初始化,此时会调用移动构造器:
    std::string value = std::string("HelloWorld"); // std::string("HelloWorld") is rvalue, this will trigger move constructor
    
    上面的代码在没有移动语义的时候,其先会构造出来一个临时对象,再通过复制构造器复制一份到value;有了移动语义以后,右值可以绑定到右值引用上,上述过程变成:构造临时对象,通过移动构造器将临时对象移动value,与之前相比少了一次复制操作,这在对象的指针指向大量数据时能够有效地提升性能。
  2. 非初始化阶段通过等号赋值,同时等号右侧为右值,则触发移动赋值运算符:
    std::string value;
    value = std::string("HelloWorld"); // std::string("HelloWorld") is rvalue, this will trigger move assignment.
    
    上述的代码的分析过程与1中类似。
  3. 将右值绑定到右值引用上,不会触发移动语义,但可以延长对象的生命周期:
    std::string value = "HelloWorld";
    std::string &&rRef = std::move(value); // no movement occurs, value is still "HelloWorld".
    
    在上面的过程中,右值引用的行为表现与左值引用一致,任意一方发生修改,另一方也会同时修改。
  4. 右值引用赋值给某个变量,或者在初始化时赋值给某个变量,此时不会发生移动语义,因为根据之前讲的右值引用是一个左值:
    std::string &&rRef = std::string("HelloWorld");
    std::string value = rRef; // no movement occurs, rRef is still "HelloWorld".
    
    上述代码如果想要触发移动语义,则需要将value = rRef改成value = std::move(rRef)此时等式右侧为一个右值(将亡值),那么此时会触发移动语义,同时rRef在移动后会变成空串。

此时,对于之前的问题我们已经成功解决,只需要将参数改为右值引用,同时实现自己的移动构造函数与移动赋值运算符即可:

void c(const BigData &&c) { // do something }
void b(const BigData &&b) { C(std::move(b)); }
void a(const BigData &&a) { B(std::move(a)); }
a(BigData("data.txt"));

注意:在函数中调用时,需要使用std::move将右值引用参数(此时是一个左值)转换成右值进行传递。
注意:通常在确定使用std::move的时候,我们应该认为后续的函数调用可能发生移动语义,在后续我们应该提醒自己,被std::move后的变量可能已经变成空值了。
例如cppreference中的一个例子:

std::string str = "Salut";
std::vector<std::string> v;

// uses the push_back(const T&) overload, which means 
// we'll incur the cost of copying str
v.push_back(str);
std::cout << "After copy, str is " << std::quoted(str) << '\n';

// uses the rvalue reference push_back(T&&) overload, 
// which means no strings will be copied; instead, the contents
// of str will be moved into the vector. This is less
// expensive, but also means str might now be empty.
v.push_back(std::move(str));
std::cout << "After move, str is " << std::quoted(str) << '\n';

std::cout << "The contents of the vector are { " << std::quoted(v[0])
          << ", " << std::quoted(v[1]) << " }\n";

上述的程序输出如下:

After copy, str is "Salut"
After move, str is ""
The contents of the vector are { "Salut", "Salut" }

可以看到通过调用参数为右值引用的push_back后,str已经变成空串了(在调用过程中触发了移动语义)。

如何快速确定一个值是左值还是右值?

当不能很轻易的判断一个值是右值还是左值时,我们可以通过尝试将其绑定到一个左值引用或者右值引用上,查看是否能够通过编译,如果能够成功的绑定到左值引用上,那么就是左值,而如果能够绑定到右值引用上,那么就是一个右值.。

引用折叠与万能引用

引用折叠:指多余两个的&进行缩减至小于等于两个&的情况,cppreference中给出了如下的例子:

typedef int&  lref;
typedef int&& rref;
int n;
 
lref&  r1 = n; // type of r1 is int&
lref&& r2 = n; // type of r2 is int&
rref&  r3 = n; // type of r3 is int&
rref&& r4 = 1; // type of r4 is int&&

也就是说只有当两个右值引用碰到一起时,其折叠结果才会是右值引用。
万能引用也叫做转发引用,其提出是为了解决完美转发的问题,之所以被叫做万能引用是因为其既能绑定左值也能够绑定右值。
通常情况下,万能引用通过如下方式定义:

template<class T>
void f(T &&t) {}; 
// NOTE!!!
// This is not a forwarding reference, because forwarding reference must be cv-unqualified
// template<class T>
// void f(const T &&t);

上面的代码看似是右值引用,其实是万能引用,下面给出几个例子说明:

int x = 0;
f(0); // call f(int &&t), T is int;
f(x); // call f(int &t), T is int&;
f(std::move(x)); // call f(int &&t), T is int&&;
int &lRef = x;
int &&rRef = 0;
f(lRef); // call f(int &t), T is int&;
f(rRef); // call f(int &t), T is int&;
f(std::move(lRef)); // call f(int &&t), T is int&&;
f(std::move(rRef)); // call f(int &&t), T is int&&;

上述的过程除了传入纯右值时,其余调用均发生了引用折叠。
简单总结来说:万能引用在传入的值为左值时,其被推导为左值引用,当传入的值为右值时,其被推导为右值引用。上述的过程中实际上发生了引用折叠,当传入类型为左值引用时,其被折叠成左值引用,当传入值为右值时,其被折叠成右值引用,唯一比较特殊的一点是:当传入非引用类型的左值时,此时也被折叠成了左值。
当然也可以指定参数类型(但是一般不这样操作,这里为了进一步演示引用折叠):

int x = 0;
f<int>(0) // no reference collapsing, call f(int &&t);
f<int&>(x) // & && -> &, call f(int &t);
f<int&&>(std::move(x)) // && && -> &&, call f(int &&t);

完美转发实现 std::forward

什么是完美转发?

首先我们需要知道什么叫转发,这里的转发指的时,函数调用过程中参数的传递过程。
完美转发则是指参数转发过程中保持其参数类型(指左右值),最外层函数如果传入的是一个右值,那么其希望在转发过程中该参数始终绑定到一个右值引用上(即继续作为右值),如果传入的是一个左值,那么其希望始终绑定到一个左值引用上(即继续作为左值)。

为什么需要完美转发?

当最外层函数传入一个右值时,外层的调用者是希望发生移动语义的(即在需要构造一个对象时,直接使用传入的右值),而不是希望其被复制到一个左值上发生不必要的数据拷贝。

完美转发实现

void handleInt(int &x){ }

void handleInt(int &&xx) { }

template<class T>
void perfectForwarding(T &&t)
{
	// do something ...
	
	// call another function:
	if (std::is_same<int, std::remove_reference_t<T>>::value) {
		handleInt(std::forward<T>(t));
	} else {
		// do something ...
	}
}
int x = 0;
int &lRef = x;
int &&rRef = 0;
perfectForwarding(1); // rvalue, so call handleInt(int &&x);
perfectForwarding(x); // lvalue, so call handleInt(int &x);
perfectForwarding(std::move(x)); //  rvalue, so call handleInt(int &&x);
perfectForwarding(lRef); // lvalue, so call handleInt(int &x);
perfectForwarding(rRef); // lvalue, so call handleInt(int &x);
perfectForwarding(std::move(lRef)); // rvalue, so call handleInt(int &&x);
perfectForwarding(std::move(rRef)); // rvalue, so call handleInt(int &&x);

完美转发实现原理

完美转发的实现基于万能引用和引用折叠,我们可以查看std::forward的源码:

template< class T >
T&& forward( typename std::remove_reference<T>::type& t ) noexcept;
template< class T >
T&& forward( typename std::remove_reference<T>::type&& t ) noexcept;

上面的代码有两个版本,这两个版本分别用于实参为左值和实参为右值(如果需要传入某个函数的返回值,且其返回值不是左值引用的时候)的时候。根据引用折叠的规则我们可以知道,当模板类型是左值引用时,其返回一个左值引用,为右值引用时,其返回一个右值引用(由前面的介绍可以知道,函数返回值是右值引用时,其会被认为是一个右值,故此时可以绑定到参数为右值引用的函数上)。
std::remove_reference有什么作用,为什么需要这样做?

根据名字可以知道起作用是移除类型的引用,例如传入std::string&&那么type最后会变成std::string,上面实现了两个版本,其中一个版本参数为左值引用,另一个为右值引用,其目的就是为了能够让std::forward能够接收右值作为参数。

std::remove_reference如何移除引用?

通过类模板即可,下面是源代码:

template<typename _Tp>
struct remove_reference
{ typedef _Tp   type; };

template<typename _Tp>
struct remove_reference<_Tp&>
{ typedef _Tp   type; };

template<typename _Tp>
struct remove_reference<_Tp&&>
{ typedef _Tp   type; };

参考

value category cppreference
std::move cppreference
reference cppreference
std::forward cppreference

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值