Item 25 对右值引⽤使⽤std::move,对通⽤引⽤使⽤ std::forward
右值引⽤仅绑定可以移动的对象。如果你有⼀个右值引⽤参数,你就知道这个对象可能会被移动:
1 | class Widget { |
这是个例⼦,你将希望通过可以利⽤该对象右值性的⽅式传递给其他使⽤对象的函数。这样做的⽅法是将绑定次类对象的参数转换为右值。如 Item23 中所述,这不仅是 std::move
所做,而且是为它创建:
1 | class Widget { |
另⼀⽅⾯,通⽤引⽤可能绑定到有资格移动的对象上。通⽤引⽤使⽤右值初始化时,才其强制转换为右值。Item23 阐释了这正是 std::forward
所做的:
1 | class Widget { |
总而⾔之,当传递给函数时右值引⽤应该⽆条件转换为右值(通过 std::move
),通⽤引⽤应该有条件转换为右值(通过 std::forward
)。
Item23 解释说,可以在右值引⽤上使⽤ std::forward
表现出适当的⾏为,但是代码较⻓,容易出错,所以应该避免在右值引⽤上使⽤ std::forward
。更糟的是在通⽤引⽤上使⽤ std::move
,这可能会意外改变左值。
1 | class Widget { |
上⾯的例⼦,局部变量 n
被传递给 w.setName
,可以调⽤⽅对 n
只有只读操作。但是因为 setName
内部使⽤ std::move
⽆条件将传递的参数转换为右值,n
的值被移动给 w
,n
最终变为未定义的值。这种⾏为使得调⽤者蒙圈了。你可能争辩说 setName
不应该将其参数声明为通⽤引⽤。此类引⽤不能使⽤ const
(Item 24),但是 setName
肯定不应该修改其参数。你可能会指出,如果 const
左值和右值分别进⾏重载可以避免整个问题,⽐如这样:
1 | class Widget { |
这样的话,当然可以⼯作,但是有缺点。⾸先编写和维护的代码更多;其次,效率下降。⽐如,考虑如下场景:
1 | w.setName("Adela Novak"); |
使⽤通⽤引⽤的版本,字⾯字符串 "Adela Novak"
可以被传递给 setName
,在 w
内部使⽤了 std::string
的赋值运算符。w
的 name
的数据成员直接通过字⾯字符串直接赋值,没有中间对象被创建。但是,重载版本,会有⼀个中间对象被创建。⼀次 setName
的调⽤会包括 std::string
的构造器调⽤(中间对象),std::string
的赋值运算调⽤,std::string
的析构调⽤(中间对象)。这⽐直接通过 const char*
赋值给 std::string
开销昂贵许多。实际的开销可能因为库的实现而有所不同,但是事实上,将通⽤引⽤模板替换成多个函数重载在某些情况下会导致运⾏时的开销。如果例⼦中的 Widget
数据成员是任意类型(不⼀定是 std::string
),性能差距可能会变得更⼤,因为不是所有类型的移动操作都像 std::string
开销较小。
但是,关于重载函数最重要的问题不是源代码的数量,也不是代码的运⾏时性能。而是设计的可扩展性差。 Widget::setName
接受⼀个参数,可以是左值或者右值,因此需要两种重载实现,n 个参数的话,就要实现 2^n 种重载。这还不是最坏的。有的函数—函数模板—-接受⽆限制参数,每个参数都可以是左值或者右值。此类函数的例⼦⽐如 std::make_unique
或者 std::make_shared
。查看他们的的重载声明:
1 | template<class T, class... Args> |
对于这种函数,对于左值和右值分别重载就不能考虑了:通⽤引⽤是仅有的实现⽅案。对这种函数,我向你保证,肯定使⽤ std::forward
传递通⽤引⽤给其他函数。
好吧,通常,最终。但是不⼀定最开始就是如此。在某些情况,你可能需要在⼀个函数中多次使⽤绑定到右值引⽤或者通⽤引⽤的对象,并且确保在完成其他操作前,这个对象不会被移动。这时,你只想在最后⼀次使⽤时,使⽤ std::move
或者 std::forward
。⽐如:
1 | template<typename T> |
这⾥,我们想要确保 text
的值不会被 sign.setText
改变,因为我们想要在 signHistory.add
中继续使⽤。因此 std::forward
只在最后使⽤。
对于 std::move
,同样的思路,但是需要注意,在有些稀少的情况下,你需要调⽤ std::move_if_noexcept
代替 std::mov
。要了解何时以及为什么,参考 Item 14。
如果你使⽤的按值返回的函数,并且返回值绑定到右值引⽤或者通⽤引⽤上,需要对返回的引⽤使⽤ std::move
或者 std::forward
。要了解原因,考虑 + 操作两个矩阵的函数,左侧的矩阵参数为右值(可以被⽤来保存求值之后的和)
1 | Matrix operator+(Matrix&& lhs, const Matrix& rhs){ |
通过在返回语句中将 lhs
转换为右值,lhs
可以移动到返回值的内存位置。如果 std::move
省略了
1 | Matrix operator+(Matrix&& lhs, const Matrix& rhs){ |
事实上,lhs
作为左值,会被编译器拷⻉到返回值的内存空间。假定 Matrix
⽀持移动操作,并且⽐拷⻉操作效率更⾼,使⽤ std::move
的代码效率更⾼。
如果 Matrix
不⽀持移动操作,将其转换为左值不会变差,因为右值可以直接被 Matrix
的拷⻉构造器使⽤。如果 Matrix
随后⽀持了移动操作,+
操作符的定义将在下⼀次编译时受益。就是这种情况,通过将 std::move
应⽤到返回语句中,不会损失什么,还可能获得收益。
使⽤通⽤引⽤和 std::forward
的情况类似。考虑函数模板 reduceAndCopy
收到⼀个未规约对象 Fraction
,将其规约,并返回⼀个副本。如果原始对象是右值,可以将其移动到返回值中,避免拷⻉开销,但是如果原始对象是左值,必须创建副本,因此如下代码:
1 | template<typename T> |
如果 std::forward
被忽略,frac
就是⽆条件复制到返回值内存空间。
有些开发者获取到上⾯的知识后,并尝试将其扩展到不适⽤的情况。
1 | Widget makeWidget() { |
想要优化 copy
的动作为如下代码:
1 | Widget makeWidget() { |
这种⽤法是有问题的,但是问题在哪?
在进⾏优化时,标准化委员会远领先于开发者,第⼀个版本的 makeWidget
可以在分配给函数返回值的内存中构造局部变量 w
来避免复制局部变量 w
的需要。这就是所谓的返回值优化(RVO),这在 C++ 标准中已经实现了。
所以 "copy"
版本的 makeWidget
在编译时都避免了拷⻉局部变量w,进⾏了返回值优化。(返回值优化的
条件:1. 局部变量与返回值的类型相同;2. 局部变量就是返回值)。
移动版本的 makeWidget
⾏为与其名称⼀样,将 w
的内容移动到 makeWidget
的返回值位置。但是为什么编译器不使⽤ RVO
消除这种移动,而是在分配给函数返回值的内存中再次构造w呢?条件 2 中规定,仅当返回值为局部对象时,才进⾏ RVO
,但是 move
版本不满⾜这条件,再次看⼀下返回语句:
1 | return std::move(w); |
返回的已经不是局部对象w,而是局部对象 w
的引⽤。返回局部对象的引⽤不满⾜ RVO
的第⼆个条件,所以编译器必须移动 w` 到函数返回值的位置。开发者试图帮助编译器优化反而限制了编译器的优化选项。
(译者注:本段即绕⼜⻓,⼤意为即使开发者⾮常熟悉编译器,坚持要在局部变量上使⽤ std::move
返回)
这仍然是⼀个坏主意。C++ 标准关于 RVO
的部分表明,如果满⾜ RVO
的条件,但是编译器选择不执⾏复制忽略,则必须将返回的对象视为右值。实际上,标准要求 RVO
,忽略复制或者将 sdt::move
隐式应⽤于返回的本地对象。因此,在 makeWidget
的"copy"
版本中,编译器要不执⾏复制忽略的优化,要不⾃动将 std::move
隐式执⾏。
按值传递参数的情形与此类似。他们没有资格进⾏ RVO
,但是如果作为返回值的话编译器会将其视作右值。结果就是,如果代码如下:
1 | Widget makeWidget(Widget w) { |
实际上,编译器的代码如下:
1 | Widget makeWidget(Widget w){ |
这意味着,如果对从按值返回局部对象的函数使⽤ std::move
,你并不能帮助编译器,而是阻碍其执⾏优化选项。在某些情况下,将 std::move
应⽤于局部变量可能是⼀件合理的事,但是不要阻碍编译器 RVO
。
总结
- 在右值引⽤上使⽤
std::move
,在通⽤引⽤上使⽤std::forward
- 对按值返回的函数返回值,⽆论返回右值引⽤还是通⽤引⽤,执⾏相同的操作
- 当局部变量就是返回值是,不要使⽤
std::move
或者std::forward