C++11标准引入的是右值引用的概念来方便我们操作右值,但右值的概念是在之前的版本中就有的。在引入右值引用概念后,左右值也被分为左值(lvalue)、将亡值(xvalue)、纯右值(prvalue)。其中将亡值和左值合称为泛左值,将亡值和纯右值合称为右值。
- 左值可以形象理解为可以取到地址的值
比如字符串字面量能取到地址,是左值。 - 纯右值例如整型字面量或者求值结果相当于是字面值或者不具名的临时对象
- 将亡值包括类似
T && foo()
函数返回的右值引用或由std::move
强转来的右值引用。
将亡值属于泛左值,又属于右值。属于泛左值是由于将亡值作为右值引用是具名的,这和纯右值如字面量不一样,所以被视为左值。作为右值是由于将亡值具有可移动性。而将亡值之所以又具名又能移动,是因为它要死了。
这些概念的区分涉及到 Value categories,在这里会有简单讨论。
注意类似 T foo()
的函数返回值是纯右值。在使用右值和移动语义时容易产生下面的问题:
- 右值、右值引用之间有什么区别
- 重载决议中右值引用、左值引用、通用引用有什么区别
- 右值、(N)RVO之间的关系是什么
- 移动语义在哪些地方可以提高性能
诸如此类,在本文中详细讲解。
Value categories
这一部分较为深入,移动到C++中类型和类型转换
右值与右值引用
左值是不能绑定到右值引用的,如下面的代码是错误的
1 | // code 1 |
正确的做法是使用 std::move()
函数将它转换成一个右值,而这个函数实际上就是调用了 static_cast
进行一次强转,得到的是一个 xvalue。xvalue 表示在逻辑上当做右值来用的左值。
查看下面的代码,发现编译错误,为什么?
1 | int main() { |
42是右值,但 i
是个右值引用,右值引用并不等于右值,它和引用类型、指针类型一样,属于一个新的类型,通常被认为是左值。一个右值引用能够接受一个右值,而一个左值引用不能绑定到一个右值,这是区别。在绑定之后,i
是个具名对象,是个左值。
函数返回右值与 (N)RVO
(N)RVO 优化
什么是 RVO
根据 copy elision,C++ 从 17 开始 RVO 并不是一个编译器的优化,而是一个标准的要求了。后面有更详细的讨论。
RVO 的目的是 fun()
的返回值在 main
中不会产生一个临时对象。RVO 不能解决 callee 内部的临时对象的问题,考虑下面的代码。fun
中创建了一个 x1
,然后返回了 x1
,这里 x1
就不是 RVO 的场景。但在 bar
中,直接用 fun()
返回的 prvalue 初始化自己,这就是 RVO。
1 | X fun(){ |
什么是 NRVO
1 | X fun(){ |
别忘了编译器并不一定会进行 NRVO,例如编译器遵循下面一些规则:
- return 的类型和函数签名中的返回值类型相同
- return 的是局部变量
- 条件语句返回时会抑制 NRVO
使用移动语义从函数返回
std::move 返回 T&&
1 | X && fun(){ |
这似乎“替编译器做了”RVO 同样的工作,这是一个比较好的实践么?
事实上这是大错特错的。首先 x1
是一个自动变量,它在栈上,生命周期到 fun
函数结束就终止了。std::move
将 x1
强转成 X &&
返回。然而到了调用者main
那里,x1
对象早已被析构了,这个右值引用 X &&
和相应的左值引用 X &
、悬挂指针 X *
一样,并不能延续生命。因此不要返回局部变量的右值引用。在StackOverflow 上有关于 Best Practice 的讨论
std::move 返回 T
能正常运行的代码是这样的
1 | X fun(){ |
对下面两个有疑惑的,可以看 Value categories。
- 从函数定义来看,返回的是 prvalue
- 从 return 来看,返回的是 xvalue
因此这个 std::move
除了潜在地暗示编译器不要进行 NRVO 外并没有任何作用。我们返回的是通过 X
的 move-ctor 创建的一个新临时变量 X{std::move(x1)}
。
最佳的实践是直接依赖 NRVO。测试如下。
1 |
|
需要返回 std::move 的情况
1 | struct Y { |
Guaranteed copy elision
在 C++17 之后这一部分发生了变化,对于 RVO 以及 copy elision 我们需要重新认识。
可以构造下面的代码,它在 C++17 上不能编译,需要改成 std::move(pp2)
,而在 20 上则可以。
1 |
|
这里的要点是,RVO 是一个 copy elision,但不是一定会发生的。对应有 Guaranteed copy elision 或者
Mandatory elision of copy/move operations 的概念。主要是下面几点:
- C++ 17 开始,prvalue 的 materialize 动作是 lazy 的,也就是说它可能会 materialize a temporary,但它未必会这么做。
- 因此,这就改变了 prvalue 的语义。由此导致的结果是,在一些情况下,即使语言中显式地要求要 copy 或者 move,也实际上没有 copy 或者 move 真的发生。
这里提到的“一些情况”包括:
Initializing the returned object in a return statement, when the operand is a prvalue of the same class type (ignoring cv-qualification) as the function return type
1
2
3
4
5
6
7
8
9T f()
{
return U(); // constructs a temporary of type U,
// then initializes the returned T from the temporary
}
T g()
{
return T(); // constructs the returned T directly; no move
}The destructor of the type returned must be accessible at the point of the return statement and non-deleted, even though no T object is destroyed.
In the initialization of an object, when the initializer expression is a prvalue of the same class type (ignoring cv-qualification) as the variable type
1
T x = T(T(f())); // x is initialized by the result of f() directly; no move
Cppreference 上还有个 Note,实际上也印证了相关的其他材料中的说法。这里的关键是 C++ 17 中引入了 materialized 的概念,导致实际上有些东西根本就没有 materialized,就更不会被 elision 了。
This rule does not specify an optimization, and the Standard does not formally describe it as “copy elision” (because nothing is being elided). Instead, the C++17 core language specification of prvalues and temporaries is fundamentally different from that of earlier C++ revisions: there is no longer a temporary to copy/move from. Another way to describe C++17 mechanics is “unmaterialized value passing” or “deferred temporary materialization”: prvalues are returned and used without ever materializing a temporary.
回到代码中,这里为什么不能编译的原因是:
- 这里的 pp2 是一个 lvalue 而不是 rvalue。如果是个 lvalue 的话,其实就不符合 Guaranteed copy elision 的定义了。
- 没有发生 copy elision。因为 P 就没有 copy 语义,那就更不可能有 elision 了。
- 没有发生 move elision。因为这里 structured binding 实际得到的是一些引用,而不是一个值。
为什么 std::move 可以?这里是发生了 move 构造。
Non-mandatory copy/move elision
下面的情况下,编译器可以,但是不是必须去优化掉 copy 或者 move 构造。注意,即使 cons 和 decons 有副作用,编译器这样的优化都是合法的。这样的优化指的是直接在对应位置构建,而不需要 copy 或者 move。
这里需要注意,即使对应的 copy 或者 move 实际不会被调用到,但它们必须存在并且可以访问,不然程序是 ill-form 的。我理解这就对应了我上文的情况。
- In a return statement, when the operand is the name of a non-volatile object with automatic storage duration, which isn’t a function parameter or a catch clause parameter, and which is of the same class type (ignoring cv-qualification) as the function return type. This variant of copy elision is known as NRVO, “named return value optimization.”
- In a return statement, when the operand is the name of a non-volatile object with automatic storage duration, which isn’t a function parameter or a catch clause parameter, and which is of the same class type (ignoring cv-qualification) as the function return type. This variant of copy elision is known as NRVO, “named return value optimization.”
- (until C++17) 在创建一个对象时,如果 source object 是同类型(不论 CV 修饰符)的 nameless temporary,并且是 return 操作符的操作数,此时对应了 URVO, “unnamed return value optimization.”。但是在 C++17 之后,URVO 是必须发生的,并且不会被算在 copy elision 里面了。
- In a throw-expression, when the operand is the name of a non-volatile object with automatic storage duration, which isn’t a function parameter or a catch clause parameter, and whose scope does not extend past the innermost try-block (if there is a try-block).
- In a catch clause, when the argument is of the same type (ignoring cv-qualification) as the exception object thrown, the copy of the exception object is omitted and the body of the catch clause accesses the exception object directly, as if caught by reference (there cannot be a move from the exception object because it is always an lvalue). This is disabled if such copy elision would change the observable behavior of the program for any reason other than skipping the copy constructor and the destructor of the catch clause argument (for example, if the catch clause argument is modified, and the exception object is rethrown with throw).
- In coroutines …
When copy elision occurs, the implementation treats the source and target of the omitted copy/move(since C++11) operation as simply two different ways of referring to the same object, and the destruction of that object occurs at the later of the times when the two objects would have been destroyed without the optimization (except that, if the parameter of the selected constructor is an rvalue reference to object type, the destruction occurs when the target would have been destroyed)(since C++11).
Multiple copy elisions may be chained to eliminate multiple copies.
使用移动构造
移动构造函数
如果要根据一个对象创建另一个对象,传统的方式是调用复制构造函数
1 | X a{b}; // or |
重新考虑之前的代码
1 | struct X { |
由于(N)RVO 的存在,这段代码中实际只调用了一次默认构造函数。
虽然右值引用的“光辉”常被 (N)RVO 掩盖,但其能够对资源移动行为进行更精细化的定义。进一步看,右值包括将亡值和纯右值,它是一个临时的、字面的,或者即将被销毁的对象,可以随时被 gc 掉。
现在需要构造对象:
- 如果别人传进来一个左值,那只有老老实实地调用复制构造或者复制赋值。
- 如果别人传进来一个右值引用
T&& other
,那就是一个暗示,“我现在已经没人要了,你可以来掏空我啦”。所以移动构造或者移动赋值就会直接掏空other
,因为它知道other
没人要了。
但注意,掏完之后要给原来的值赋上 nullptr,不然原对象被析构的时候,会把你移动完的析构掉。
如下代码所示,右值引用 A && a
接受一个右值并绑定,产生一个左值。移动构造函数将 a
持有的指针 a.p
设为 nullptr
。如果不设为nullptr
,在 a
销毁时会执行 delete p
,那么 this
好不容易从 a
那里掏来的 p
就会被销毁,this->p
会变成悬挂指针。这样传进来的右值 a
里面的东西通过了 this
的皮囊得到了续命。而 this
也避免了调用一个拷贝构造函数的开销。
1 | struct A |
传值还是传右值引用?
同样考虑上面的定义,使用下面的代码
1 | TM f(TM tm) { |
结果:
- f 会产生一个 copy 构造和 一个 move 构造
- g 会产生一个 copy 构造
原因是尽管 tm2 被 move 进来了,但是返回的时候需要一个复制。 - h 会产生一个 move 构造
原因是 x 被 NRVO 了
函数返回值是右值
类似地可以写出这样的代码
1 | X fun(){ |
fun
返回值的生命在 x
中得到了延长。
在等号右边接受一个 X && fun()
的返回值是错误的。
1 | X && fun(){ |
x1
的生命没有通过 x
得到延长,作为自动变量,x1
是肯定要在栈上被销毁的。实际上延长的 fun()
的返回值的寿命。
此外,StackOverflow阐释了 cppreference 的论点,因为有一种叫 dangling reference 的东西,如果将 xvalue 绑定到 rvalue reference 上就可能会产生这个。这也说明了为什么函数返回 T&&
是危险的。
对此 SOF 上还有进一步的讨论。
综上所述,当 fun
返回一个 prvalue 时,用 T &&
来续命是合法的。而如果函数的返回值不是引用,那就是 prvalue。如下所示,std::move 是多余的。
1 | X get_x(){ |
移动操作是重新构造对象
注意,虽然定义了移动构造函数可以从 old_x
构造 new_x
,但是 old_x
和 new_x
在地址上仍然是不同的,例如被移动对象指针被其他对象持有的话,同样会发生错误。这是因为 move 语义并不是单纯的“移动”,而是通过默认或自定义的移动构造等函数掏空对象的过程。
pass by value,pass by const reference,还是pass by rvalue reference
Effective Modern C++ 中指出在需要 copy 的情况下,与其传 const T &
不如直接传 T
。查看SoF上的一个例子。提问者认为1需要 copy 一次再 move 一次,而2只需要 copy 一次,为什么1的效率会更好。回答指出2中的 copy 可能不一定发生,这是由于对临时对象可能做copy elide的缘故。
1 | // 1. Better than 2 |
在爆栈网中讨论了是通过值还是右值引用来传递lambda。首先这里的“右值引用”实际上是通用引用,传递通用引用可以实现完美转发(将在后面介绍),而传递值并不会造成复制开销,因为编译器会进行优化。特别地,当创建一个lambda时,它是一个临时对象,因此不能将一个左值引用绑定到它,如 auto & f = [](){}
是不正确的。
拷贝构造函数与移动构造函数的关系
如果一个程序中显式定义了拷贝构造函数,编译器便不会合成移动构造函数。这会造成下面的效果,如果程序中没有定义移动构造函数,那 std::move
会调用拷贝构造函数。
将移动构造函数声明为 noexcept
noexcept
在 C++17 标准中成为了函数类型的一部分。
通常来说应该将移动构造函数声明为 noexcept。特别是当你的类型会和STL中的容器一起用时,如果移动构造函数不是 noexcept 的,那么容器会调用复制构造函数。
一个类似的考虑是是否需要将拷贝构造函数设为 noexcept
。
emplace 系列函数
在C++11后,诸如 std::vector<T>
的容器的 push_back
方法也能接受右值了并移动构造了。
不过更有效的方式是使用另一个添加的 emplace_back
的方法。这个函数直接免去了创建临时对象的成本,而直接在原地进行构造。一如 placement new 的行为一样,它调用std::allocator_traits::construct
这个函数来创建对象。
但 emplace 系列的问题是必须在调用这个函数的时候构造,而不能用一个函数的返回值了。
1 | // OK |
1 | template<typename F> |
另外 emplace 系列需要提供一个用户定义的构造函数。
有关右值引用的类型推导
注意在使用 decltype(t)
时,如果 t
是一个右值引用,那么推导出来的类型需要 std::remove_reference_t<decltype(t)>
一下才可以使用。
自引用结构的问题
自引用结构在进行移动构造的时候,会产生悬垂引用的问题,参考文章。
通用引用和完美转发
完美转发是来自于 C++ 的引用折叠特性,也就是右值引用叠加到右值引用,还是右值,其他所有引用类型的叠加会变成左值。
通用引用是针对模板编程的一个概念,当 T
是要被推导的时,T&&
是一个通用引用。
介绍
根据 Effective Modern C++ Item 24 的介绍,标准中有这么个规定。函数签名中的 T&&
,如果 T
是需要推导得来的,这样的 T
表示通用引用(universal/forward reference)。通用引用是 C++ 通过引用折叠(reference collapsing)表现出的一个特性,一个通用引用可以绑定到任何由 CV 修饰的引用上。
容易混淆的通用/右值引用包括
1 | // 以下属于rvalue reference |
如果对于参数包不加上通用引用 Args&&
,那这个参数包就不能接受一个左值。
1 | // 注意参数包的终止条件要在主模板前面 |
辨析
在CFortranTranslator 的 ImpliedDo实现中,发现将构造函数的 F func
改成 F && func
就会编译错误:
1 | error: cannot bind ‘io_implieddo2_Test::TestBody()::<lambda(const fsize_t*)>::<lambda(for90std::fsize_t)>::<lambda(const fsize_t*)>’ lvalue to ‘io_implieddo2_Test::TestBody()::<lambda(const fsize_t*)>::<lambda(for90std::fsize_t)>::<lambda(const fsize_t*)>&&’ |
其实这对应了 Effective Modern C++ 条款24中的一个 case,也就是模板类的成员函数实际上没有经过推导,所以实际上是右值引用
1 | class vector<Widget, allocator<Widget>> |