3 min read
右值引用和完美转发

C++11引入右值引用T&&移动语义std::move,并结合std::forward实现完美转发。

本文将围绕为什么需要右值引用什么是右值引用何时使用std::movestd::forward有何用展开。

为什么需要右值引用

C++03及之前存在右值,但并不存在右值引用。

缓慢的深拷贝严重拖垮C++程序的性能

考虑以下场景

int main() {
  vector<int> b;
  {
    vector<int> a(1000000000);
    b = a;
  }
}

在我的M3-MacBook-Air的orbstack x86_64 Linux虚拟机上,执行时间如下

________________________________________________________
Executed in    2.24 secs    fish           external
   usr time    0.75 secs  475.00 micros    0.75 secs
   sys time    1.49 secs  222.00 micros    1.49 secs

而仅仅修改一行代码

b = std::move(a); // 使用a的右值引用,触发vector赋值运算符的重载,发生移动而不是拷贝
///
// vector&
// operator=(vector&& __x) noexcept(_Alloc_traits::_S_nothrow_move())
// {
// constexpr bool __move_storage =
// _Alloc_traits::_S_propagate_on_move_assign()
// || _Alloc_traits::_S_always_equal();
// _M_move_assign(std::move(__x), __bool_constant<__move_storage>());
// return *this;
// }

结果发生巨大变化,性能得到极大的提升

________________________________________________________
Executed in  815.27 millis    fish           external
   usr time  546.29 millis    0.23 millis  546.06 millis
   sys time  269.19 millis    1.16 millis  268.03 millis

此类性能提升得益于相比于拷贝型更快的转移型赋值操作符重载。

与之类似的,转移构造函数提供了比拷贝构造函数更好的性能,我们也可以自定义类似的转移函数优化性能

这些转移型函数重载接收右值引用类型的参数,相对应的,只有传入右值引用类型的参数才会触发此类函数重载。

但其实我们代码中有许多情况下会被自动优化出右值引用类型,正是如此,大量标准库的性能在C++11后得到了极大的提升,且这些提升不需要用户对自己的代码进行过多的修改。

什么是右值引用

右值引用是对右值的引用

那什么是右值

https://en.cppreference.com/w/cpp/language/value_category.html#rvalue

An rvalue expression is either prvalue or xvalue.

  • a prvalue (“pure” rvalue) is an expression whose evaluation
  • an xvalue (an “eXpiring” value) is a glvalue that denotes an object whose resources can be reused;

cppreference的定义或许有些晦涩,我们用字面意思解释下

默认情况下,字面量表达式返回值临时对象是最常见的三种右值

func(10); // 字面量:10是右值
a + b; // 表达式返回值:a + b是右值
func(10); // 表达式返回值:func(10)是右值
A(1, 2); // 临时对象A(1, 2)是右值

此外,使用std::move也可以声明右值引用

A a(1, 2);
func(std::move(a)); // std::move(a)是右值引用

需要注意的是,基础类型的右值引用没有意义,不要再傻傻地move基础类型,这里不是Rust🐶

于是,对右值的引用便是右值引用,表示为T&&

何时使用std::move

其实大部分情况的右值都是自动推导的,即三种最常见的右值,但有时我们也需要手动将左值转换为右值,这时候std::move便有了用武之地。

std::move的实现非常简单,仅有两行代码

constexpr typename std::remove_reference<_Tp>::type&&
move(_Tp&& __t) noexcept
{ return static_cast<typename std::remove_reference<_Tp>::type&&>(__t); }

这里的std::remove_reference非常关键,其定义如下

template<typename _Tp>
struct remove_reference
{ typedef _Tp   type; };

无论值是左值还是右值,std::remove_reference会去除值的引用部分,仅保留类型部分

因此,概括而言,std::move对值进行类型转换,转为其类型的右值引用

所以,其实std::move本身对值没有进行操作,只是会告诉函数这是一个右值,从而触发相关的转移函数,当没有与之对应的转移函数时,会自动降级为左值引用,触发拷贝函数。

可以认为std::move只是给值贴上标签,便于转移函数辨认,从而触发更高效的语句

std::forward有何用

经过上面的分析,可以注意到我们的目标是触发转移函数而不是拷贝函数,但考虑下面的代码

template <typename T>
void wrapper(T&& arg) {
  callee(arg);
}
void callee(T&& arg) {

}
void callee(T& arg) {

}

不要被定义迷惑,这个wrapper函数又可以接受左值引用,也可以接受右值引用

这涉及到一个关键概念:引用折叠

函数定义的形参类型传入的实参实参
T&T&T&
T&&T&T&
T&T&&T&
T&&T&&T&&

可以注意到只有定义形参和传入实参均为右值引用时,实参才为右值引用

callee(A(1)); // 触发void callee(T& arg)
A a(1);
callee(a); // 触发void callee(T& arg)

这边是由于arg是具名变量,无法触发右值引用

机智如你或许想到这样修改wrapper函数

template <typename T>
void wrapper(T&& arg) {
  callee(std::move(arg));
}

这样引用折叠就会失效,所有情况都使用右值引用,导致某些情况的语义错误

std::forward便应运而生

template <typename T>
void wrapper(T&& arg) {
  callee(std::forward(arg));
}

std::forward可以保留参数类型的不变性,正确触发对应的函数重载

这样,我们便实现了完美转发

你理解了吗,有问题欢迎评论区交流