引用与值类型
本文主要以值类别为线索,介绍 C++11 引入的右值引用相关内容,主要关注语言规定中的内容,而非各种实现上的抽象(例如所有权等)。
类别
与右值引用密切相关的是 C++ 从 C 语言中继承的值类别系统。 在 C 语言中,所有表达式可分为两类:
- 左值(lvalue):能够指向非
void类型对象的表达式,标识了一个对象。这种表达式可以取地址(除非是位域或寄存器变量)、能够被赋值,从而出现在赋值表达式左侧。一般被声明的变量单独出现时,这种表达式就是左值。 - 非左值或右值(rvalue):其他所有表达式,不能标识对象。典型的例子有各种字面量(但字符串字面量是左值)和算术、关系、逻辑和位运算符的结果(例如
a+b)。这种表达式一般只能出现在赋值表达式右侧。
C++11 继承并发展了这一分类,通过引入新的值类别来指代标识“将要消失”的对象的表达式。 现在,表达式可分为三类:
- 左值(lvalue):标识对象,且不能被移动的表达式。C 语言中典型的左值仍是这种左值。
- 纯右值(prvalue):不标识对象,但可以被移动的表达式。C 语言中典型的右值(非字符串的字面量等)变为纯右值。
- 将亡值(xvalue):标识对象,且可以被移动的表达式。这种表达式指向的对象“将要消失”,因此得名。
- 左值和将亡值合称泛左值(glvalue);纯右值和将亡值合称右值;不标识对象且不能被移动的表达式不能出现。
注意到将亡值既是泛左值又是右值,因此具有很多特别的性质并且很难自然出现。
典型的例子是对右值的非静态数据成员的访问(例如(a+b).m),这种访问在 C 语言中是右值,而在 C++11 中是将亡值。
特别地,被专门标记为返回右值应用的函数调用表达式(例如std::move(x))是将亡值,因此可以被移动。
C++17 对值类别进行了一次修订以支持强制复制消除,这里选择使用 C++11 的定义是因为该定义和右值引用关系更加易于理解。
引用类型与值类别
在现代 C++ 中声明引用变量时,可有两种声明:
T & l声明左值引用变量,左值引用一般绑定到左值表达式;T && r声明右值引用变量,用于延长临时对象的生命周期;右值引用无法绑定到左值表达式。
这里特别注意该规则仅适用于变量声明。 当声明为函数返回值或参数时,这些声明则具有不同的意义:
- 若声明返回值为左值引用,那么对该函数返回左值引用,并且其调用表达式是左值而非纯右值。
- 相对的,若声明返回值为右值引用,那么对该函数返回右值引用,并且其调用表达式是将亡值而非纯右值。
- 返回局部变量的左值或右值引用均会导致悬垂引用!
- 当参数声明为左值引用时,除了表示这个参数是左值引用之外,还表示该参数按引用传递(有时错误地称为按地址传递)而非按值传递。
- 当参数声明为右值引用时,若其类型是还是模板类型,则会根据模板推导的结果变化为左值或右值引用。
这些特殊的规则使得左值和右值引用本身变得非常令人迷惑。
为什么说值类别和引用密切相关呢? 除了名字和函数调用表达式的值类型之外,还因为因为进行重载决议时,右值引用重载绑定到右值(包括纯右值和将亡值)表达式,而左值引用重载绑定到左值表达式。 以上三点意味着左值引用和左值表达式对应,而右值引用和右值表达式对应。 这使得程序员可通过重载对象的右值引用的构造函数和赋值运算符来实现移动构造和移动赋值,因为右值均可被移动。
移动语义
以上介绍的内容以足以说明移动语义(move semantics)的实现原理。
std::move其实只是个转型操作
template <typename T>
typename remove_reference<T>::type&& move(T&& arg)
{
return static_cast<typename remove_reference<T>::type&&>(arg);
}
参数中的右值引用暂且按下不表,可见该函数核心只是一个static_cast。
被该函数包裹的参数在参与重载时会优先选中右值引用的版本,从而提醒程序员该资源可被移动,因此程序员可能可以(但非必须)利用这一点来进行优化。
能这样做是因为右值引用代表的右值表达式(即纯右值和将亡值)都是可被移动的,如同上一节介绍的一样。
对 STL 库而言,所有声明了右值引用版本的成员函数都尽可能进行了这种优化。 同时 STL 库本身也对被移动的资源做出了特别的规定:
所有被移动的标准库对象都处于“有效但未指定的状态”,这意味着对象的类不变量保持。 因此不带前置条件的函数,例如赋值运算符,可以在对象被移动后安全地使用。
可以讲,移动语义是 C++ STL 中由规定产生的习语(idiom),即程序员们为右值引用赋予了“应该发生移动”的涵义,但这并非 C++ 核心语言(Core language)的内容。 通过对移动语义的进一步抽象,可形成所谓所有权(Ownership)的概念,但这一点并非本文的重点。
例外情况
如果仅仅是左值引用和左值表达式对应、右值引用和右值表达式对应,那么右值引用还是比较简单的,但是凡是规则总有例外:
首先,右值表达式既可以绑定到右值引用,也可以绑定到const修饰的左值引用,但是优先绑定到前者。
这条规则对变量和函数重载都生效,这意味着若没有声明右值应用的重载,则会调用const T&类型的重载,这一行为与旧版本的 C++ 相兼容。
对类而言,这意味着若仅将复制运算符声明为被删除且未声明移动运算符,那么该类的对象既不能复制、也不能移动。
另一方面,左值表达式则可通过强制转型(例如std::move)绑定至右值引用中。
这里需要注意,虽然左值表达式可隐式转换为纯右值表达式(lvalue-to-rvalue conversion),在引用绑定时这种隐式转换不会发生。
struct S { int i; };
S x{0}, y{1};
y = x;
// 不发生引用隐式转换:此处调用隐式生成的运算符
// operator = (const S &)
// 而右值表达式不可能被绑定到左值引用上。
int z = x.i + y.i;
// 发生隐式转换:对 x.i 这一左值表达式进行算数
// 操作,而算术操作一般需要纯右值。
// 这种隐式转换是对“从主存中读取至寄存器”的抽象。
其次,通过 decltype (expr),每一种表达式的值类别都对应了一种引用类型:
- 左值对应左值引用
T &; - 将亡值对应右值引用
T &&。 - 纯右值对应非引用
T,而非上文所述的右值引用;
这意味着对函数调用表达式,decltype得到的类型总和声明的类型相同。
但是由于该说明符本身的特点,会产生一些非常令人迷惑的结果:
decltype(auto)
// 由于 i 是变量,推导类型为 int,一切正常。
good(int i) { return i; }
decltype(auto)
// 由于 (i) 是左值表达式,推导类型为 int &,返回悬垂引用!
bad_lvalue(int i) { return (i); }
struct S { int i = 0; };
decltype(auto)
// 由于 (S{}) 是纯右值表达式,推导类型为 S,一切正常。
good_2() { return (S{}); }
decltype(auto)
// 由于 S{}.i 是变量,推导类型为 int,一切正常。
good_3() { return S{}.i; }
decltype(auto)
// 由于 (S{}.i) 是将亡值表达式,推导类型为 int &&,返回悬垂引用!
bad_xvalue() { return (S{}.i); }
最后,右值引用变量和左值引用变量本身,若单独出现在表达式中,那么都是左值,如同其他所有变量一样。 因此以下程序无法通过编译:
void inner(int && x) {}
void outer(int && x) {
// OK
inner(std::move(x));
// 错误:x 是左值,无法绑定至右值引用。
inner(x);
}
转发引用
最后介绍一下右值引用与模板联合出现时发生的化学反应:转发引用(Forwarding reference),也称万能引用(Universal reference)。
正如此前提到的,当参数声明为右值引用时,若其类型是还是尚未被推导的模板类型,且引用本身不被const或volatile修饰,那么该参数会根据模板推导的结果变化为左值或右值引用:
- 若调用函数时实参表达式为左值,那么参数类型为左值引用;
- 若调用函数时实参表达式为右值,那么参数类型为右值引用。
这种模板推导保持了原表达式的值类型信息,因此也被称为完美转发。
注意完成完美转发需要该模板类型尚未被推导,且不能被CV限定,因此以下代码均不是转发引用:
template <class T>
class U {
// 并非转发引用
void memfn_1(T &&);
// 并非转发引用
template <class V> void memfn_2(const V &&);
};
引用折叠
上一节中我们提到引用会被替换为左值或右值引用,但实际上被替换的是被推导的类型,而参数本身的类型“恰好”和被推导的类型相同,这是因为 C++ 中关于推导过程中重复出现的引用有特殊的规定:
- 若重复出现的引用均为右值引用,那么被推导的引用是右值引用;
- 否则,被推导的引用是左值引用。
而 C++ 对该特例的规定实际上是:
如果 P (形参)是对cv非限定模板参数的右值引用(所谓的转发引用),并且对应的函数调用实参是左值,则推导时使用对 A (实参)的左值引用类型代替 A 。
以下列函数为例:
template <class T>
void fwd(T && v);
- 若调用
fwd(x),其中x定义为int x;,那么T被上述特殊规则替代为int &,从而被推导的类型变为int & &&而折叠为int &; - 若调用
fwd(std::move(x)),那么T被推导为int,不发生折叠,参数类型为int &&; - 若调用
fwd(1),那么T仍被推导为int,不发生折叠,参数类型为int &&。
注意由于 C++ 不允许对引用的引用,因此这种情况基本只能在引用推导中出现。
完美转发
最后介绍一下 C++11 STL 提供的完美转发工具,即std::forward。
之前介绍过,由于右值引用本身是左值,因此不能直接将其作为参数传递到另一个函数中。
如果已经知道这个变量一定是右值引用,那么借助std::move便可将其重新转换为右值引用。
但是,如果这个函数是模板函数,并且使用了转发引用,因此不知道该变量到底是什么引用,那么要怎么办呢?
答案很简单,利用模板特化构造一个函数,使其在对左值引用返回左值表达式、而对右值引用返回右值表达式即可:
// libc++ implementation, some details omitted.
template <class T>
T&& forward(typename std::remove_reference<T>::type& t) {
return static_cast<T&&>(t);
}
template <class T>
T&& forward(typename std::remove_reference<T>::type&& t) {
static_assert(!std::is_lvalue_reference<T>::value,
"Can not forward an rvalue as an lvalue.");
return static_cast<T&&>(t);
}
由于T出现在非推导语境(Non-deduced context)中,因此在使用时必须显式指定T。
这里没有使用转发引用,但是仍然通过static_cast<T&&>使用了引用折叠。
这一点可能比较让人困惑,因为std::forward虽然名叫转发,但并未使用转发引用;相对的,它确实基本总是和转发引用一同出现。
利用转发引用和引用折叠,有人可能会写出以下“朴素”的代码:
template<class T>
T&& forward(T&& param) {
return static_cast<T&&>(param);
}
// 这个调用是左值还是右值?
int && x = 1;
static_assert(
!std::is_same<decltype(forward(x)), int &>::value,
"rvalue reference is forwarded as lvalue."
);
但是,牢记右值引用本身是左值,因此用右值引用调用此函数返回的仍是左值。