针对右值引用使用Std::move, 针对万能引用使用Std::forward
本文最后更新于:April 20, 2022 am
本篇为介绍右值引用、移动语义和完美转发系列博客的第二篇,介绍对两种引用应该采取不同操作
右值引用,移动语义、完美转发系列目录:右值、移动语义和完美转发
在合适的时机使用std::move和std::forward
右值引用只能被绑定到移动操作的候选对象上。如果我们的函数有一个右值引用类型的参数,我们知道这个对象快要被移动了:
1 |
|
基于以上这一点共识,当我们将上述的对象传入其它函数时,会希望那些函数依然能够利用这个对象关于右值的性质。解决方案也很简单——将被绑定到上述对象的参数转换为右值即可。Item23已经解释过,这不仅就是std::move
的功能,这也是为什么std::move
被创造出来
1 |
|
另一方面,一个万能引用,和右值引用比起来,仅仅是可能被绑定到能被移动的对象上。因此,当且仅当万能引用的初始化是由右值完成时,应该将万能引用转换为右值。Item23也解释过了,这就是std::forward
所做的全部工作。
1 |
|
总之,右值引用应该被无条件转换为右值(使用std::move
),万能引用只在一定条件下转换为右值(使用std::forward
)
Item23 还解释过了,可以使用std::forward
来代替std::move
所做的工作,但这样的代码会显得冗长易出错,不正规。因此,应该避免让std::forward
越俎代庖。
更坏的情况是在应该使用std::forward
的情景,错误地使用了std::move
,这可能会导致意外地修改左值(比如局部变量):
1 |
|
此处,局部变量n
被传到w.setName
中去,调用者认为setName
是一个只读的函数,这情有可原;但是setName
内部使用std::move
来无条件地将其参数转换为了右值,因此n
的值就被移动到了w::name
中去,函数调用完成之后n
的值变成了未定义,这会让我们的同事发疯。
重载 or std::forward
有人可能会说,setName
方法不应该用万能引用来声明其参数的,因为这种引用不能被const
修饰,尽管setName
方法的确不应该修改它的参数。一种可行的方法是,给setName
函数同时重载针对常量左值引用和右值引用的两个版本,这个问题就能被解决:
1 |
|
这样的确能解决问题,但这仅仅局限在这个场景下。
第一,这需要编写和维护更多的源代码,当项目变得很大时,其影响不可忽视。
第二,这可能导致效率下降,考虑这样一个调用:
1 |
|
如果setName
的实现采用的是万能引用的版本,字符串字面量就会被传递给setName
函数,然后会被进一步传递给w
的成员std::string
的赋值运算符,进一步地说,w::name
会直接地被从字符串字面量赋值,避免中间std::string
对象的生成。
而如果我们使用的是两种重载版本,会生成一个std::string
类型的对象,然后setName
函数的参数会绑定到其上面,最后再对这个对象进行一次移动操作以对w::name
赋值。对setName
的一次调用会额外调用一次std::string
的构造,移动赋值和析构,这比起上面的只调用std::string
的赋值操作(const char *
版本)会更麻烦。
这种额外的开销根据不同的实现机制,其究竟值不值得担忧也根据项目的不同而变化,但事实就是,用一对重载在常量左值引用和右值引用上的函数来替代std::forward
所带来的影响,很可能导致额外的运行时开销。如果将讨论的场景铺开,两种方式之间的性能鸿沟会变得更大,因为不是所有对象移动起来都和std::string
一样简单。
实际上,用重载函数替换std::forward
的最严重的问题,既不是源代码的体积膨胀,也不是运行时开销,而是这种设计本身的泛用性太差了。这里setName
函数只有一个参数,所以会有常量左值引用和右值引用两个版本,但是如果函数需要更多的参数,那么每个参数都会需要两个版本。如果函数有$n$个参数,那么一共就需要$2^n$个重载版本,去写这么多个版本并不值得。对于有的函数,比如函数模板,它们的参数个数本来就是不受限制的,并且每个参数都可能是左值或者右值,比如std::make_shared
, std::make_unique
(参见Item21),以下是在库中的声明:
1 |
|
对于这种函数,对每个参数挨个重载本身就不可能,只能通过万能引用来做,并且在这种函数内部,肯定通过使用std::forward
来将一些参数来传递给其它函数。
在少数情况下,在一个函数内部,我们可能需要重复使用某个右值引用或者万能引用绑定到的对象,并且在最后一次使用时才将它移动掉,但在这之前我们希望它不会被意外地修改掉。这时,就可以在最后一次使用时才调用std::move
或者std::forward
,比如:
1 |
|
此处,在第八行之前,我们只希望使用text
,而避免其被意外改掉。当我们最后一次使用text
时,才调用了std::forward
。对于std::move
也是相似的,不过在很少的情况下,可能会选择调用std::move_if_noexcept
而不是std::move
,具体内容参见Item14.
返回值的函数
参数是右值引用或者万能引用
如果我们正在一个返回“值”的函数内部,并且函数正在返回一个绑定到右值引用或者万能引用的对象时,在返回时我们会希望加一层std::move
或者std::forward
。以矩阵相加的情景来讨论,在矩阵Matrix
类上重载了+
运算符,假设这个场景下左侧的矩阵参数是一个右值(因此它的空间可以用来存储矩阵的和:
1 |
|
通过在返回时多用一次std::move
,我们将lhs的内容移动到了返回值的对象中去。如果没有这一层std::move
,lhs会强制编译器拷贝其内容到返回对象内部。如果Matrix
类恰好不支持移动构造,多用一次std::move
也无伤大雅,因为Matrix
的拷贝构造函数也可以接受右值(Item23)。如果晚些时候Matrix
类新增了移动构造函数,那么下次编译时编译器就会自动地将之改为调用移动构造函数了。
对于万能引用和std::forward
,情景也是相似的。假设有一个函数reduceAndCopy
,它接受一个分数对象,将之约分之后返回其结果的一个拷贝。如果传入的参数是右值,那么也该返回右值来避免拷贝,如果传入的是左值,那我们也应该返回左值(执行拷贝)
1 |
|
Return Value Optimization
有些开发者在理解了上述信息之后,会尝试错误地拓展它。有人会认为,如果返回值的表达式中,在一个右值引用的函数参数上施加std::move
操作,能够将一次拷贝变成一次移动,那么我也能够对正在返回的本地变量施加同样的std::move
操作来优化程序:
1 |
|
这样做的问题出在哪里呢?其实C++的标准委员会早就提前想过了这个问题,在很早以前,这群人就注意到这种函数的返回值的特点。由于运行时栈,编译器为函数makeWidget
预留的返回值的空间其实不在makeWidget
函数本身的栈空间内部,可以通过直接在其返回值的空间上进行对象构造和操作来避免在return语句处执行拷贝。这叫做返回值优化(Return Value Optimization, RVO),并且从第一份C++标准开始就明确规定了这一点。
这条规定的措辞十分严谨,因为大家都只希望上述的拷贝优化以不影响软件的可观测行为的方式进行运作。其原文的转述是,编译器可以在满足以下条件的情况下避免对一个本地对象的拷贝构造或者移动构造:
- 函数的返回值类型和这个本地对象的类型一致
- 函数正好要返回这个本地对象
了解了以上这些内容之后,再去看一下“拷贝版本”的makeWidget
函数:
1 |
|
此处makeWidget
函数正好满足这两点,因此可以信誓旦旦地保证,任何优雅的C++编译器都会采用RVO来避免拷贝,这个函数看起来要在返回语句时进行拷贝,其实没有拷贝任何内容,这在C++中叫做复制省略(Copy Elision)。
再回过头来看“移动”版本的makeWidget
函数时,我们发现它还是该干啥干啥,老老实实地在自己的函数栈上构造一个Widget
对象,对它一顿猛烈操作,然后在返回语句处将本地对象移动构造给栈上函数返回值的那片空间(假设Widget
类有移动构造函数)。那么为什么,此处没有RVO呢?答案也很简单,编译器不能这样,这个场景不满足上述条件的第二点——函数的返回值不是一个本地对象,而是return std::move(w);
此处返回的内容并不是本地对象w
,而是它的一个引用(std::move(w)
的返回值)。这样,本来为了避免拷贝做出的努力,反而限制了编译器的优化。
但是RVO只是一种优化,标准并不强制编译器进行这种优化。或许我们洞若观火,一眼看出满足上述两点条件,但是编译器很难做到优化的情景。
当函数内部的不同执行路径返回了不同的本地变量,因为编译器需要生成代码来为合适的本地变量在函数的返回值的内存区域上进行构造,但编译器怎么知道哪个本地变量是合适的呢?可以看下面这段代码:
1 |
|
运行时传入的condition
的值不同,函数fun
要么直接走else,要么还走一个第一个if,要么第一和第二个if都走,那么在此函数的汇编代码中,应该位于何处对栈上的返回值那片内存进行构造?如果在两个分支里都进行构造,一旦condition
同时进入两个if,第一次if里构造的对象的所有内容将会被清空,但我们不能保证第二个if返回的东西就是obj2
,比如返回的东西其实是一个返回MyClass
类型的函数的调用,或者一个obj3
。
我们此时或许仍然觉得,即使可能阻止编译器进行复制省略,手动写一个std::move
,总能百分之百地避免复制。其实即使没有RVO,手动加入std::move
也是个坏主意。标准还规定了,当满足上述条件但是没有执行RVO时,必须将函数的返回值视作右值对待。即,当满足了RVO的规定时,要么出现复制省略,要么返回值会被隐式加上std::move
函数(也就是不用再手动去写一层std::move
了,编译器会帮我们加的)
同样的情况也出现在pass by value的函数参数上,相比于函数的返回值,函数参数不能有复制省略的优化方式,但是如果参数被返回了,编译器也被要求将它们视作右值,如果我们的代码写成这样:
1 |
|
编译器实际会将之视为
1 |
|
这意味着我们如果在一个返回值的函数的返回语句上加一句std::move
,不仅不能让函数的性能变得更好,反而断绝了复制省略的可能。虽然在一些情况下,对一个本地变量进行std::move
操作确实是有道理的——比如我们会将它传给下一次层函数调用,并且明确知道不会再使用它了——但是return语句上的std::move
并不在此列。
总结
最后一次使用某个引用时,如果它是右值引用,应该施加std::move;如果是个万能能引用,应该施加std::forward
对于返回值的函数,但参数是右值引用或者万能引用时,也做相同的事情
如果对象满足RVO的条件,就不要再添加std::move或者std::forward
本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!