std::move 与转发引用

有的时候,我们知道某个变量已经“快死了”,比如:

template<typename T>
void swap(T& a, T& b) {
    T temp{a};
    a = b;
    b = temp;
}

这个通用的“交换”函数交换 a b 两个对象的值,原理是普通的“临时变量法”。但这里,a 原始的值一但初始化了变量 temp 后就再也没有被用过了,或者说紧接着就被 b 的值覆盖了。temp 也是如此,赋值给 b 之后就不会再用了。b 也是同理。这三个变量在整个函数中只使用了一次,之后马上就“死”掉——就是退出生存期/存储期的意思。

那么,正如在前一节所述的,如果一个对象使用一次之后马上就会被释放,那它最好在这次使用中将资源“交付”出去,然后干干净净地死掉。比如,我们更希望 a = b 这个表达式调用的是 aT& 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 getSomething() {
    T t;
    // [...]
    return std::move(t); // 不要这样做!最好直接 return t;
}

这是因为,整个函数返回的是 T 这个值类型而不是引用类型(为什么不能是?),而移动/复制语义仅仅在绑定到引用时才会起效果,因此此处 return 语句内是左值还是右值并没有效果上的区别。但 std::move(t) 破坏了潜在的编译器优化:标准允许 return 一个左值局部变量时进行复制消除(即直接在调用表达式处构造本应在局部构造的变量 t),但一旦转换为亡值就意味着这个潜在优化的可能被消除了。因此没有理由在 return 处使用 std::move

转发引用

转发引用(Forwarding reference)又是一个奇怪的语法。转发引用的定义就很奇怪,它是这样说的:如果 T 位于推导语境下(比如模板形参),则 T&& 称为转发引用。比如下面的代码。

template<typename T>
void f(T&& x) {
    // 这里的 x 就是转发引用
}

转发引用的关键点就在于“推导语境”。它的含义是:如果推导语境中,传入的实参是左值表达式,则 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 一个东西之后就不能再使用它了——它的资源可能已经被移走了。

最近更新:
代码未运行