左值与右值

C++中左值与右值的概念是从C中继承而来,一种简单的定义是左值能够出现再表达式的左边或者右边,而右值只能出现在表达式的右边

int a = 5; 	// a是左值,5是右值 
int b = a;	// b是左值,a也是左值
int c = a + b;	// c是左值,a + b是右值

另一种区分左值和右值的方法是:有名字、能取地址的值是左值,没有名字、不能取地址的值是右值。比如上述语句中a,b, c是变量可以取地址,所以是左值,而5和a + b无法进行取地址操作,因此是右值。C++中左值与右值的一个主要的区别是:左值可以被修改,而右值不可修改

左值引用与右值引用

了解了左值与右值的概念后,接下来介绍下C++中的左值引用与右值引用。左值引用很简单,就是一个变量的别名,绑定到一个左值上:

int a = 1;
int& b = a; 	//a = 1,b = 1
b = 2;		// a = 2,b = 2

这里b就等于a,在汇编层面其实和普通的指针一样,对引用的修改(b)也会修改到被引用的对象(a),需要注意的是,因为引用实际是一个别名,因此必须初始化,即告诉编译器是那个具体对象的别名。因此下列左值引用都是错误的:

int& a;		// 错误!左值引用必须初始化
int& b = 10;	// 错误!左值引用不能以临时变量初始化(临时变量没有地址)

右值引用是C++11中新增的特性,顾名思义,右值引用就是用来绑定到右值的引用,一个右值被绑定到右值引用之后,原本需要被销毁的此右值生命周期会延长至绑定它的右值引用的生命周期。在汇编层面,右值引用和const引用所做的事情是一样的,即产生临时量来存储常量。但是右值引用可以进行读写操作,而const引用只能进行读操作。绑定右值引用使用&&,具体使用如下:

int a = 5;
int& b = a;		// 正确!b是一个左值引用
int&& c = 6;		// 正确!c是一个右值引用,绑定到右值6
int&& d = a * 2;	// 正确!d是一个右值引用,绑定到右值a * 2
int&& e = i;		// 错误!不能将左值绑定到右值引用
int& f = 7;		// 错误!不能将右值绑定到左值引用
const int& g = a * 3;	// 正确!可以将右值绑定到const 左值引用

可以看到我们虽然不能将右值绑定到左值引用,但是可以将右值绑定到const左值引用
注意: 变量表达式都是左值!。变量可以看作是只有一个运算对象而没有运算符的表达式,跟其他表达式一样,变量表达式也有左值/右值属性。变量表达式都是左值,因此我们不能将一个右值引用绑定到一个右值引用类型的变量上。

int&& a = 5;	// 正确!a是一个右值引用
int&& b = a;	// 错误!a是一个左值,不能绑定到右值引用

这里虽然a是右值引用类型,但是确实一个左值,因此无法绑定到右值引用b上。因为在C++中,右值一般是临时对象,但是绑定到右值引用之后,其生命周期变长了,因此a是一个左值。我们不能将一个右值引用直接绑定到一个变量上,即使是这个变量是右值引用类型也不行。具体的这个问题在后续的介绍forward的时候会详细说明。

左值/右值引用的模板实参推断

在另一篇文章中介绍了C++的模板类型推断的几种类型,可以总结为以下三种:

  1. ParamType 是一个指针或者引用(&),但是不是通用引用(&&)
  2. ParamType是一个通用引用(&&)
  3. ParamType既不是指针,也不是引用(&)或者通用引用(&&)

从左值引用函数参数推断类型

当一个函数参数是模板的左值引用(T&)时,根据绑定规则,只能传递一个左值实参,这个左值实参可以时const类型,也可以不是。如果实参时const的,那么T就会被推导为const类型

template<typename T>
void func(T& param);

int a = 0;
const int b = a;
func(a);	// T被推导为int,param类型为int&
func(b);	// T被推导为const int,param类型为const int&
func(5);	// 错误!实参必须是一个左值!

如果一个函数的类型时const T&,那么根据绑定规则,可以传递任何类型的实参:const或者非const,左值或者右值,由于函数类型本身已经是const,因此T的推导结果不会是一个const,因为const已经是函数参数类型的一部分了。

template<typename T>
void func(const T& param);

int a = 0;
const int b = a;
func(a);	// T被推导为int,param类型为const int&
func(b);	// T被推导为int,param类型为const int&
func(5);	// 正确!const T&可以绑定一个右值,T为int

可以看到,当函数参数类型为const T&时,可以接受一个右值实参,而函数参数类型为 T& 时是不可以的。

从右值引用函数参数推断类型

当一个函数的参数是一个右值引用(T&&)时,根据绑定规则可以传递一个右值实参。类似左值引用推导,右值引用推导得到的T的类型为右值的类型:

template<typename T>
void func(T&& param);

func(5);	// 实参5为右值,T被推导为int类型

与不能给右值引用赋值左值不同,右值引用函数的模板实参却可以接受一个左值的输入。当我们将一个左值传递给函数的右值引用参数时,且此右值引用指向模板参数类型(T&&)时,编译器推导模板类型参数为实参的左值引用类型:

template<typename T>
void func(T&& param);

int a = 1;
func(a);	// T被推导为int&,而不是int

如上述推导所示,当传入一个左值a时,T被推导为int&,而不是int,对应的param的类型为int& &&,根据引用折叠的规则,int& &&被折叠为int&。

引用折叠规则
T& & ,T& && 和T&& &都会被折叠为T&
T&& &&被折叠为T&&

引用折叠的规则告诉我们:如果一个函数的参数时指向模板参数类型的右值引用(如T&&),则可以传递给它任意类型的实参,如果传递的左值实参,那么T将会推导成为一个左值引用,函数参数被实例化为一个普通的左值引用(T&)。这种引用叫做“通用引用”

右值引用与通用引用

C++中T&&有两种不同的意思,第一种是右值引用,用于绑定到右值上,它们主要存在的原因是为了声明某个对象可以被移动。T&&的第二层意思是,它既可以是一个右值引用,也可以是一个左值引用。这种引用在代码里看起来像是右值引用(T&&),又可以表现的像是左值引用(T&)。它既可以绑定到右值,也可以绑定到左值,还可以绑定到const和no_const对象上,几乎可以绑定到任何东西,这种引用叫做“通用引用”。在两种情况下会出现通用引用,最常见的就是函数模板参数:

template<typename T>
void func(T&& param); // param是一个通用引用

第二种情况是auto声明符:

auto&& a = b;	//a是一个通用引用

以上两种情况的共同之处在于都是类型推导。在func内部,param类型需要被推导,在auto声明中,a的类型也需要被推导,而如果带有&&而不需要推导,则就是普通的右值引用:

void func(A&& param);	// 没有类型推导,param是一个右值引用
A&& a = b;		// 没有类型推导,a是一个右值引用

由于引用必须初始化,通用引用也一样。一个通用引用的初始值决定了其具体代表的是一个左值引用还是右值引用。如果初始值是一个左值,那么通用引用对应的就是左值引用,如果初始值是一个右值,那么通用引用对应的就是一个右值引用。

template<typename T>
void func(T&& param); // param是一个通用引用

int a = 1;
func(a);		// a是左值,T被推导为int&,参数param的类型是int&,是一个左值引用
func(5);		// 5是右值,T被推导为int,参数param的类型是int&&,是一个右值引用

需要注意的是,判断一个引用是不是通用引用,类型推导是必要的,但是并不是类型推导就是通用引用,还需要看是不是准确的T&&,如:

template<typename T>
void func(std::vector<T>&& param); // param是一个右值引用

template<typename T>
void func(const T&& param); // param是一个右值引用

上述模板函数func被调用的时候,类型T也会被推导,但是参数param的类型并不是T&&,而是一个std::vector&&,因此param是一个右值引用而不是通用引用。即使多了一个const,那么param也不能成为一个通用引用。

理解std::move()

有了上述的知识基础之后,C++中的move函数功能就很好理解了,std::move的主要作用是将一个左值/右值无条件的转换为右值,但是函数本身并不移动任何东西,只是进行类型的转换,那么这种转换是如何做到的呢?我们来看下std::move具体实现的代码:

template<class T>
typename remove_reference<T>::type&& move(T&& param)
{
    using returnType = typename remove_reference<T>::type&&;
    return static_cast<returnType>(param);
}

通过源码可以看到,std::move接受一个通用引用的参数,函数返回一个&&表明std::move函数返回的是一个右值引用,这里remove_reference表示移除类型T的引用部分,具体的实现可以参考文档,即返回结果是右值。在C++14中std::move的实现更加简单:

template<typename T>
decltype(auto) move(T&& param)
{
    using returnType = remove_reference_t<T>&&;
    return static_cast<returnType>(param);
}

让我们通过以下的代码示例具体分析下std::move是如何工作的:

string s1("hello"),s2;
s2 = std::move(string("world")); // 从右值移动数据
s2 = std::move(s1);		 // 将左值转换为右值 

在第一个赋值中,传递给move的实参是一个右值,当向一个右值引用传递一个右值时,推导的类型即被引用的类型,因此在std::move(string("world"))中:

  • T被推导为string
  • returnType为string
  • move的返回类型为string&&
  • move的函数参数param的类型为string&&
    则函数std::move被推导为:
string&& move(string&& param)
{
	return static_cast<string&&>(param);
}

由于param已经时右值引用类型,因此实际上move函数什么也没做。
在第二个赋值中,传给std::move的参数是一个左值,则在std::move(s1)中:

  • T被推导为string&
  • returnType为string
  • move的返回类型为string&&
  • move的函数参数param的类型为string&
    则函数std::move被推导为:
string&& move(string& param)
{
	return static_cast<string&&>(param);
}

可以看到参数param被static_cast转换为sting&&,在C++中,从一个左值static_cast到一个右值引用时允许的
从以上的示例可以看到,不管传入的是左值还是右值,最终move都会返回一个右值。

理解std::forward()

std::forward与std::move实现的功能是类似的,只不过std::move总是无条件的将它的参数转换为右值,而std::forward只有在满足一定的条件下才会执行转换。std::forward最常见的使用场景是一个模板函数,接受一个通用引用参数,并将其传递给另外的函数:

void Process(const A& lvalue);	// 处理左值
void Process(A&& rvalue);	// 处理右值

template<typename T>
void PrintAndProcess(T&& param)
{
    Print("Some Log");
    process(std::forward<T>(param))
}

现在考虑两次对PrintAndProcess的调用,一次参数为左值,一次参数为右值

A a;
PrintAndProcess(a);		// 左值参数
PrintAndProcess(std::move(a));	// 右值参数

在PrintAndProcess函数内部,参数param被传递给process函数,process函数分别对左值和右值进行了重载,传入PrintAndProcess左值参数时希望process左值版本被调用,传入PrintAndProcess右值参数时,process右值版本被调用。但是前面我们提过,一个右值引用的变量,其本身时一个左值,因此无论传给PrintAndProcess函数的实参时左值还是右值,最终调用process函数都是左值版本。为了解决这个问题,我们就需要一种机制:当传入PrintAndProcess函数的实参是右值时,调用的时process的右值版本。这就是std::forward的使用场景:只把由右值初始化的参数,转换为右值

那么std::forward如何知道param参数是被一个左值还是一个右值给初始化的呢?我们来看下std::forward实现的源码:

template<class T>
constexpr T&& forward(std::remove_reference_t<T>& arg) noexcept{
    // forward an lvalue as either an lvalue or an rvalue
    return (static_cast<T&&>(arg));
}

template<class T>
constexpr T&& forward(std::remove_reference_t<T>&& arg) noexcept{
    static_assert(!std::is_lvalue_reference<_Tp>::value, "template argument"
        " substituting _Tp is an lvalue reference type");
    // forward an rvalue as an rvalue
    return (static_cast<T&&>(arg));
}

对于左值的转发,首先通过获取类型type,定义args为左值引用的左值变量,然后通过static_cast<T&&>进行强制转换,这里T&&会发生引用折叠,当T被推导为左值引用时,则为T&& &,折叠为T&,当推导为右值引用时,则本身为T&&,forward返回值与static_cast都为T&&。
对于右值的转发不同于左值,只有当类型时右值时才进行static_cast转换,arg为右值引用的左值变量,通过cast转换为T&&。
对应到上述PrintAndProcess函数中我们进行分析:

  • 当PrintAndProcess(a),传入的为左值A时,T被推导为A&,std::forward返回值和static_cast被推导为A& &&,折叠为A&,返回一个左值。
  • 当PrintAndProcess(std::move(a)),传入为右值时,T被推导为A,在std::forward返回值和static_cast被推导为T&&,返回一个右值。

std::move 和 std::forward对比

  • std::move执行到右值的无条件转换。就其本身而言,它没有move任何东西。
  • std::forward只有在它的参数绑定到一个右值上的时候,它才转换它的参数到一个右值。
  • std::move和std::forward只不过就是执行类型转换的两个函数;std::move没有move任何东西,std::forward没有转发任何东西。在运行期,它们没有做任何事情。它们没有产生需要执行的代码,一个byte都没有。
  • std::forward()不仅可以保持左值或者右值不变,同时还可以保持const、Lreference、Rreference、validate等属性不变;