本文主要参考C++ Rvalue References Explained和C++ Primer第五版。

右值引用主要用于解决两个问题:

  1. 实现移动语义
  2. 完美转发

1. 左值与右值

简单而言,左值(lvalue)是一个表示(refers to)存储位置的表达式,它允许我们通过&运算符获取该存储位置的地址。右值(rvalue)则是不是左值的表达式。因此,左值有持久的状态,而右值要么是字面值常量,要么是在表达式求值过程中创建的临时对象,临时对象的状态是短暂的。
左值引用(lvalue reference)用于绑定到一个左值上,通过&来声明左值引用。常量左值引用也可以绑定到一个右值上。右值引用(rvalue reference)必须绑定到右值上,除了字面值常量外,该右值是一个将要销毁的对象。因此,我们可以从绑定到右值引用的对象“窃取”状态。不能将一个右值引用直接绑定到一个左值上,通过&&来声明右值引用。

2. 实现移动语义

要实现移动语义,首先对象所属的类必须定义自己的移动构造函数和移动赋值运算符。对于Class X而言,移动构造函数和移动赋值运算符的接口为:

1
2
3
4
5
6
7
X(X &&rhs) noexcept {
...
}

X& operator=(X && rhs) noexcept {
...
}

将移动构造函数和移动赋值运算符标记为noexcept,显示地告诉标准库我们的移动操作不会抛出异常,可以安全使用。这样在重新分配内存等适用移动语义的地方,标准库可以使用我们定义的移动操作而不是拷贝操作。
为了在普通函数和成员函数中实现移动语义,必须同时提供函数移动和拷贝两种版本。该函数使用和移动/拷贝构造函数和赋值运算符相同的参数模式——拷贝版本接受一个指向const的左值引用,移动版本接受一个指向非const的右值引用。以普通函数foo为例,两个版本的定义分别为:

1
2
3
4
5
6
7
void foo(const X &x) {
X anotherX = x; // call X(X& rths)
}

void foo(X &&x) {
X anotherX = std::move(x); // call X(X &&x)
}

一般来说,我们不需要为foo函数定义接受一个const X&&或是一个(普通的)X&参数的版本。当我们希望从实参“窃取”数据时,通常传递一个右值引用。为了达到这一目的,实参不能是const的。类似的,从一个对象进行拷贝的操作不应该改变该对象。因此,通常不需要定义一个接受一个(普通的)X&参数的版本。

我们注意到,foo(X &&x)中在赋值时使用了std::move(x)而不是直接使用形参x。这是实现移动语义的第二个关键点。核心问题是被声明为右值引用的变量是右值吗?
实际上,被声明为右值引用的变量可以是左值或右值。区分的标准是:如果有名字(name),则为左值。否则,则是右值。
上面的例子中,第二个foo函数的x被声明为右值引用,并且有名字,因此x是一个左值,要实现移动语义,必须使用std::move(x)将其转换为一个右值。下面的例子声明了一个右值引用并且没有名字,因此是一个右值:

1
2
X&& bar();
X x = bar(); // calls X(X &&rhs) because the thing on the right side has no name

该规则背后的出发点是:允许移动语义默认应用于具有名字的对象,如X anotherX = x,会造成危险的混乱并且容易出错。因为我们刚刚移动的x,在后续的代码中仍然可以访问。而C++要求,在移动操作之后,移后源对象必须保持有效的、可析构的状态,并且用户不能对其值进行任何假设。但是这里的x可能在后面的代码中被读取,并且用户会认为x应该保持原来的值,而这个要求也是合理和直观的。因此,规则的前一半就应该为“如果右值引用有名字,则它是一个左值”。
而如果右值引用没有名字,则代表它是一个临时对象,可以知道其马上要被销户并且该对象没有其他用户,这两个特性意味着:可以自由接管该对象的资源。因此,规则的另一半就应该为“如果右值引用没有名字,则它是一个右值”。而且,这后一半规则可以使我们可以根据自己的意愿以一种受控的方式在左值上强制移动语义(force move semantics on lvualues)。这就是std::move的工作机制。std::move可以接受一个左值,返回绑定到该左值上的右值引用,因为该右值引用没有名字,所以它是一个右值。也就是说,std::move将其参数转换为右值,如果该参数不是右值的话。并且它是通过隐藏名字来实现的。

std::move的内部实现可能如下:

1
2
3
4
5
6
namespace std {
template <typename T>
typename remove_reference<T>::type&& move(T&& t) {
return static_cast<typename remove_reference<T>::type&&>(t);
}
} // namespace std

std::move的内部实现,基于这样一个规则:虽然不能将一个右值引用直接绑定到一个左值上,但可以显示地(通过static_cast)将一个左值转换为对应的右值引用类型。我们必须意识到,调用std::move(x)就意味着承诺:除了对x赋值和销毁它外,我们将不再使用它。在调用std::move之后,我们不能对移后源对象的值做任何假设。

3. 完美转发

对于模板类型参数的函数参数,转发就是参数传递,将函数实参传递给函数内部的另外一个函数。完美转发指的是,其转发效果就像外部函数不存在,内部函数直接被调用一样。具体而言,完美转发是指一个函数需要将一个或多个实参连同类型不变地转发给其他函数,我们需要保持被转发实参的所有性质,包括实参类型是否是const的以及实参是左值还是右值。
要实现完美转发,函数参数不能是值传递,否则无法转发引用实参,因此函数参数必须是模板类型参数的引用类型。

3.1 左值引用函数参数

当一个函数参数是模板类型参数的左值引用时,只能传递给它一个左值。实参可以是const类型,也可以不是。如果实参是const的,则模板类型参数T将被推断为const类型。
如果一个函数参数的类型是const T&,则可以传递给它任何类型的实参——一个对象(const或非const)、一个临时对象或是一个字面值常量。当函数参数本身是const时,T的类型推断的结果不会是一个const类型。const已经是函数参数类型的一部分;因此,它不会也是模板类型参数的一部分。

可见,当函数参数是模板参数类型的左值引用时,只能转发左值实参(T&),或可以接受任何类型的实参(const T&)但将阻塞右值实参的移动语义,不能实现完美转发。

3.2 右值引用函数参数

当一个函数参数是模板参数的右值引用时,正常的绑定规则告诉我们可以传递给它一个右值,此时类型推断过程类似于左值引用函数参数的推断过程,推断出的T的类型为该右值实参的类型:

1
2
template <typename T> void f3(T &&);
f3(42); // 实参是一个int类型的右值;模板参数T是int

除此之外,C++语言在正常绑定规则(向右值引用函数参数传递一个右值)之外定义了两个例外规则:

  1. 第一个例外规则影响右值引用参数的推断如何进行。当我们将一个左值(如i)传递给函数的右值引用参数,且此右值引用指向模板参数类型(如T&&)时,虽然通常我们不能将一个右值引用绑定到一个左值上,但此时该绑定是合法的,编译器推断模板类型参数为实参的左值引用类型。因此,当我们调用f3(i)时,编译器推断T的类型为int&,而非int
  2. T被推断为int&意味着f3的函数参数是一个类型int&的右值引用。通常,我们不能直接定义一个引用的引用(因为引用是对象的别名,必须指向一个对象),但是,通过类型别名或通过模板类型参数间接定义是合法的。如果我们间接创建了一个引用的引用,则这些引用形成了“引用折叠”。除了右值引用的右值引用会折叠成右值引用外,其他引用都会折叠为一个普通的左值引用类型。即,对于一个给定类型X
    • X& &X& &&X&& &都折叠成类型X&
    • X&& &&折叠成X&&

这两个规则导致了两个重要结果:

  • 如果一个函数参数是一个指向模板类型参数的右值引用(如T &&),则它可以被绑定到一个左值;且
  • 如果实参是一个左值,则推断出的模板类型参数将是一个左值引用,且函数参数将被实例化为一个左值引用参数(T&

综上,函数参数是模板类型参数的右值引用类型时,可以传递给它任意类型的参数,而且可以保持参数的左值或右值属性,可以用于实现完美转发。

3.3 std::forward<Arg>(arg)

函数参数是模板类型参数的右值引用,可以接受任意类型的参数且保持参数的左值或右值属性。但正如2. 实现移动语义提到的,被声明为右值引用的变量可以是左值或右值。而在参数转发中,右值引用变量是函数参数,是有名字的,所以是左值,即使传递给该右值引用参数的是一个右值实参。
为了实现右值引用参数为右值实参时的移动语义,同时保证右值引用参数为左值时无移动语义,显然不能直接使用std::move处理右值引用参数,为此引入了std::forward<Arg>(arg)std::forward是一个类模板,必须通过显示模板实参来调用,std::forward返回该显示实参类型的右值引用。即,std::forward<T>的返回类型是T&&。通常情况下,我们使用std::forward传递那些定义为模板类型参数的右值引用的函数参数。通过其返回类型上的引用折叠,std::foward可以保持给定实参的左值/右值属性。对于右值引用参数的左值实参可以返回一个左值类型,对于右值引用的右值实参则返回一个右值。其内部实现可能如下:

1
2
3
4
5
6
7
8
9
namespace std {

template <typename Arg>
Arg&& forward(typename remove_reference<Arg>::type& arg) noexcept
{
return static_cast<Arg&&>(arg);
}

} // namespace std

实际上,std::forward中的remove_reference是不需要的,我们将typename remove_reference<Arg>::type&替换为Arg&也可以保证完美转发,但此时必须显示指明Argstd::forward的模板类型参数。而std::forward中的remove_reference用于迫使我们必须这样做。