std::move
与转发引用
有的时候,我们知道某个变量已经“快死了”,比如:
这个通用的“交换”函数交换 a
b
两个对象的值,原理是普通的“临时变量法”。但这里,a
原始的值一但初始化了变量 temp
后就再也没有被用过了,或者说紧接着就被 b
的值覆盖了。temp
也是如此,赋值给 b
之后就不会再用了。b
也是同理。这三个变量在整个函数中只使用了一次,之后马上就“死”掉——就是退出生存期/存储期的意思。
那么,正如在前一节所述的,如果一个对象使用一次之后马上就会被释放,那它最好在这次使用中将资源“交付”出去,然后干干净净地死掉。比如,我们更希望 a = b
这个表达式调用的是 a
的 T& operator=(T&&);
,尽管这样调用之后 b
就不再持有原来的资源,但这无所谓,反正 b
之后也不会被使用。因此,我们需要一种手段让普通的复制 a = b
也调用移动赋值重载,或者类似地让 T temp{a};
调用移动构造,以尽可能地避免额外的复制。
于是就引入了一个看上去没有道理但很直观的语法——static_cast<T&&>
。它的意思是将左值转换为右值:标准规定形如 static_cast<T&&>(对象)
的表达式是亡值,该亡值所指代的实体仍然是 对象
所指代的实体。这句话不会生成任何汇编代码,纯粹就是告诉编译器要认为 对象
是个右值。
改进后的 swap
长成这样:
template<typename T>
void swap(T& a, T& b) {
T temp = static_cast<T&&>(a);
a = static_cast<T&&>(b);
b = static_cast<T&&>(temp);
}
由于 static_cast<T&&>(a)
是亡值,也是右值,因此倘若移动构造 T::T(T&&);
存在,则会被优先调用(而不是调用开销更大的复制构造 T::T(const T&);
。类似地,a = static_cast<T&&>(b)
会优先调用 T& T::operator=(T&&);
,以避免复制。
需要强调的是,当一个对象被 static_cast<T&&>
后,意味着它不应该继续被使用。因为 static_cast<T&&>
意味着它可能被当做右值来使用了,而这隐含的意思是它所持有的资源可能已经被“偷”走了。基本上,在设计移动构造函数和移动赋值重载时,只保证被利用后的临时对象可重新赋值、可正常析构。此外的任何使用都应当尽量避免。
static_cast<T&&>
这个写法太长了,标准库提供了 std::move
函数来简化。下面是 std::move
的简化定义:
template<typename T>
T&& move(T& t) {
return static_cast<T&&>(t);
}
就是字面意思上把 static_cast<T&&>
包装起来而已。标准额外规定,返回右值引用的函数调用表达式也是亡值表达式,因此 std::move(...)
也是右值,可以在移动函数上使用。
如上节所述,返回 T&& 不延长生存期;但
t
是绑定到来自函数外的对象的引用,并不是函数内的临时对象,因此不会发生悬垂引用问题。该函数返回的亡值,其关联的实体就是t
所指代的实体。这里的返回值类型实际上是
std::remove_reference_t<T>&&
,但为了便于理解而省略。我特意指出这一点,是为了强调它不是下文所述的转发引用,只是一个普通的右值引用。
因此标准库中的 std::swap
也是如此实现的:
#include <utility> // std::move 定义于此
template<typename T>
void swap(T& a, T& b) {
T temp = std::move(a);
a = std::move(b);
b = std::move(temp);
}
总结下来,如果我们确保某个对象之后不再使用,为了更可能让其所持有的资源得以复用,可以在它最后一次使用的位置加上 std::move
,即字面意义上表明可将资源移动到有需求的地方。讲到这里,我终于可以解释左值和右值的核心理念:左值和右值就是为了区别一个值到底是还可以被多次使用,还是用完就扔的。如果这个值用完一次就扔了,那纯右值天然符合这个要求(虽然用的时候需要一次临时量实质化)。如果这个值是变量之类的,那用完一次就扔明显不符合语义,所以它是左值。而 std::move
出来的亡值,则本来是左值,但我确实希望它用完一次就扔,那就“强制地”让他符合右值的语义,从而尽可能地节约资源。在大部分书里,称“用完就扔”这种特性为“可移动”,这是一个意思。
此外,下面这个情形是要有意避免的:
这是因为,整个函数返回的是 T
这个值类型而不是引用类型(为什么不能是?),而移动/复制语义仅仅在绑定到引用时才会起效果,因此此处 return 语句内是左值还是右值并没有效果上的区别。但 std::move(t)
破坏了潜在的编译器优化:标准允许 return 一个左值局部变量时进行复制消除(即直接在调用表达式处构造本应在局部构造的变量 t
),但一旦转换为亡值就意味着这个潜在优化的可能被消除了。因此没有理由在 return 处使用 std::move
。
转发引用
转发引用(Forwarding reference)又是一个奇怪的语法。转发引用的定义就很奇怪,它是这样说的:如果 T
位于推导语境下(比如模板形参),则 T&&
称为转发引用。比如下面的代码。
转发引用的关键点就在于“推导语境”。它的含义是:如果推导语境中,传入的实参是左值表达式,则 T&&
是左值引用类型;如果传入的实参是右值表达式,则 T&&
是右值引用类型。比如:
template<typename T>
void f(T&& x) { }
int main() {
int i{0};
f(i); // 实例化出 void f(int&);
f(0); // 实例化出 void f(int&&);
}
转发引用
T&&
是第二种“左右通吃”的引用类型。第一种是第五章就介绍的const T&
,由于它不可以修改引用内容,因此丧失了移动语义。除此之外还有一种是*this
,但它实在太诡异以至于我不认为我能在这里讲明白。
恶心的又来了,所有的引用都是左值,即便是右值引用它也是左值。为什么这样规定?因为不论左值还是右值引用,它总是可以被多次使用的。比如之前的 String
类的移动赋值,我至少就使用了两次 String&& assignVal
:一次取它的资源地址 str
,一次取它的长度 len
。而右值是用完就扔的,只有左值可以这样多次使用,所以应当将引用定义为左值。事实上,这种带变量名的对象一般都是左值:因为变量名它既然出现,其意义就是要多次使用。
template<typename T>
void f(T&& x) {
auto copy{x}; // 这里总是调用复制构造,因为 x 是左值
}
int main() {
String a, b;
f(a + b); // 即便这里传入的是右值
}
那么再回头来看转发引用 T&& x
,会发现一些语义上的矛盾。最初设计转发引用的时候,是希望同样一份模板代码,既可以运用于左值表达式,也可以运用于右值表达式;而且希望实例化为左值引用的版本调用复制构造/赋值,而实例化为右值引用的版本调用移动构造/赋值。但现在一旦出现 T&& x
的具名引用 x
,就全变成左值了。这不好。
于是标准库又增加了 std::forward
。标准库保证,对于 T&&
类型的转发引用 x
,表达式 std::forward<T>(x)
的值类别与绑定到 x
之前的值类别保持一致。
#include <utility> // std::forward 定义于此
template<typename T>
void f(T&& x) {
// 移动构造或者复制构造
auto copy = std::forward<T>(x);
}
int main() {
String a, b;
f(a + b); // 如果这里传入的是右值,则 copy 可能“偷”走临时对象的资源
f(a); // 如果这里传入的是左值,则 copy 老老实实复制
}
std::forward
的实现比较复杂,这里就不讲了(其实我也讲不明白)。std::forward
拥有一个美妙的俗名:“完美转发”(perfect forwarding),意思就是将传入转发引用之前的实参值类别“完美地”转发给内层的对应函数。你还可以将 std::forward
理解为“智能”的 std::move
。因此你也要注意在 std::forward
一个东西之后就不能再使用它了——它的资源可能已经被移走了。