针对右值引用使用Std::move, 针对万能引用使用Std::forward

本文最后更新于:April 20, 2022 am

本篇为介绍右值引用、移动语义和完美转发系列博客的第二篇,介绍对两种引用应该采取不同操作

右值引用,移动语义、完美转发系列目录:右值、移动语义和完美转发

在合适的时机使用std::move和std::forward

右值引用只能被绑定到移动操作的候选对象上。如果我们的函数有一个右值引用类型的参数,我们知道这个对象快要被移动了:

1
2
3
4
class Widget{
Widget(Widget&& rhs);
...
}

基于以上这一点共识,当我们将上述的对象传入其它函数时,会希望那些函数依然能够利用这个对象关于右值的性质。解决方案也很简单——将被绑定到上述对象的参数转换为右值即可。Item23已经解释过,这不仅就是std::move的功能,这也是为什么std::move被创造出来

1
2
3
4
5
6
7
8
9
class Widget{
public:
Widget(Widget&& rhs):name(std::move(rhs.name)),p(std::move(rhs.p))
{...}
...
private:
std::string name;
std::shared_ptr<SomeDataStructure> p;
};

另一方面,一个万能引用,和右值引用比起来,仅仅是可能被绑定到能被移动的对象上。因此,当且仅当万能引用的初始化是由右值完成时,应该将万能引用转换为右值。Item23也解释过了,这就是std::forward所做的全部工作。

1
2
3
4
5
6
7
class Widget{
public:
template<typename T> void setName(T&& newName){
name=std::forward<T>(newName);
}
...
};

总之,右值引用应该被无条件转换为右值(使用std::move),万能引用只在一定条件下转换为右值(使用std::forward

Item23 还解释过了,可以使用std::forward来代替std::move所做的工作,但这样的代码会显得冗长易出错,不正规。因此,应该避免让std::forward越俎代庖。

更坏的情况是在应该使用std::forward的情景,错误地使用了std::move,这可能会导致意外地修改左值(比如局部变量):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Widget{
public:
template<typename T> void setName(T&& newName){
name=std::move(newName);
}
...
private:
std::string name;
std::shared_ptr<SomeDataStructure> p;
};


std::string getWidgetName(); //from some factory function
Widget w;
auto n=getWidgetName(); // n is a local variable
w.setName(n); // moving n into w::name!

... // now the value of n is unknown!

此处,局部变量n被传到w.setName中去,调用者认为setName是一个只读的函数,这情有可原;但是setName内部使用std::move来无条件地将其参数转换为了右值,因此n的值就被移动到了w::name中去,函数调用完成之后n的值变成了未定义,这会让我们的同事发疯。

重载 or std::forward

有人可能会说,setName方法不应该用万能引用来声明其参数的,因为这种引用不能被const修饰,尽管setName方法的确不应该修改它的参数。一种可行的方法是,给setName函数同时重载针对常量左值引用和右值引用的两个版本,这个问题就能被解决:

1
2
3
4
5
6
7
class Widget{
public:
void setName(const std::string& newName)
{ name =newName; }
void setName(std::string&& newName)
{ name=std::move(newName); }
}

这样的确能解决问题,但这仅仅局限在这个场景下。

第一,这需要编写和维护更多的源代码,当项目变得很大时,其影响不可忽视。

第二,这可能导致效率下降,考虑这样一个调用:

1
w.setName("Adele Novak");

如果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
2
3
4
5
template<class T, class... Args>shared_ptr<T> make_shared(Args&&... args);
//C++11 standard

template<class T, class... Args>unique_ptr<T> make_unique(Args&&... args);
//C++14 standard

对于这种函数,对每个参数挨个重载本身就不可能,只能通过万能引用来做,并且在这种函数内部,肯定通过使用std::forward来将一些参数来传递给其它函数。

在少数情况下,在一个函数内部,我们可能需要重复使用某个右值引用或者万能引用绑定到的对象,并且在最后一次使用时才将它移动掉,但在这之前我们希望它不会被意外地修改掉。这时,就可以在最后一次使用时才调用std::move或者std::forward,比如:

1
2
3
4
5
6
7
8
9
10
template<typename T> void setSignText(T&& text)// text是万能引用
{
sign.setText(text);
//此处仅仅使用text,但没有将之变为右值


auto now=std::chrono::system_clock::now();
signHistory.add(now, std::forward<T>(text));
//最后一次使用时,做完美转发
}

此处,在第八行之前,我们只希望使用text,而避免其被意外改掉。当我们最后一次使用text时,才调用了std::forward。对于std::move也是相似的,不过在很少的情况下,可能会选择调用std::move_if_noexcept而不是std::move,具体内容参见Item14.

返回值的函数

参数是右值引用或者万能引用

如果我们正在一个返回“值”的函数内部,并且函数正在返回一个绑定到右值引用或者万能引用的对象时,在返回时我们会希望加一层std::move或者std::forward。以矩阵相加的情景来讨论,在矩阵Matrix类上重载了+运算符,假设这个场景下左侧的矩阵参数是一个右值(因此它的空间可以用来存储矩阵的和:

1
2
3
4
5
6
Matrix operator+(Matrix&& lhs, const Matrix& rhs){
//运算的结果以值的形式返回

lhs+=rhs;
return std::move(lhs);
}

通过在返回时多用一次std::move,我们将lhs的内容移动到了返回值的对象中去。如果没有这一层std::move,lhs会强制编译器拷贝其内容到返回对象内部。如果Matrix类恰好不支持移动构造,多用一次std::move也无伤大雅,因为Matrix的拷贝构造函数也可以接受右值(Item23)。如果晚些时候Matrix类新增了移动构造函数,那么下次编译时编译器就会自动地将之改为调用移动构造函数了。

对于万能引用和std::forward,情景也是相似的。假设有一个函数reduceAndCopy,它接受一个分数对象,将之约分之后返回其结果的一个拷贝。如果传入的参数是右值,那么也该返回右值来避免拷贝,如果传入的是左值,那我们也应该返回左值(执行拷贝)

1
2
3
4
5
6
template<typename T>Fraction reduceAndCopy(T&& frac){
// 返回“值”的万能引用函数模板
frac.reduce();
return std::forward<T>(frac);
// 或是向返回值中移动,或是向其中拷贝
}

Return Value Optimization

有些开发者在理解了上述信息之后,会尝试错误地拓展它。有人会认为,如果返回值的表达式中,在一个右值引用的函数参数上施加std::move操作,能够将一次拷贝变成一次移动,那么我也能够对正在返回的本地变量施加同样的std::move操作来优化程序:

1
2
3
4
5
6
7
8
Widget makeWidget(){
Widget w;// local variable
...// configure w


return w; //right
// return std::move(w); //wrong "optimization"
}

这样做的问题出在哪里呢?其实C++的标准委员会早就提前想过了这个问题,在很早以前,这群人就注意到这种函数的返回值的特点。由于运行时栈,编译器为函数makeWidget预留的返回值的空间其实不在makeWidget函数本身的栈空间内部,可以通过直接在其返回值的空间上进行对象构造和操作来避免在return语句处执行拷贝。这叫做返回值优化(Return Value Optimization, RVO),并且从第一份C++标准开始就明确规定了这一点。

这条规定的措辞十分严谨,因为大家都只希望上述的拷贝优化以不影响软件的可观测行为的方式进行运作。其原文的转述是,编译器可以在满足以下条件的情况下避免对一个本地对象的拷贝构造或者移动构造:

  • 函数的返回值类型和这个本地对象的类型一致
  • 函数正好要返回这个本地对象

了解了以上这些内容之后,再去看一下“拷贝版本”的makeWidget函数:

1
2
3
4
5
Widget makeWidget(){
Widget w;
...
return w;
}

此处makeWidget函数正好满足这两点,因此可以信誓旦旦地保证,任何优雅的C++编译器都会采用RVO来避免拷贝,这个函数看起来要在返回语句时进行拷贝,其实没有拷贝任何内容,这在C++中叫做复制省略(Copy Elision)。

再回过头来看“移动”版本的makeWidget函数时,我们发现它还是该干啥干啥,老老实实地在自己的函数栈上构造一个Widget对象,对它一顿猛烈操作,然后在返回语句处将本地对象移动构造给栈上函数返回值的那片空间(假设Widget类有移动构造函数)。那么为什么,此处没有RVO呢?答案也很简单,编译器不能这样,这个场景不满足上述条件的第二点——函数的返回值不是一个本地对象,而是return std::move(w); 此处返回的内容并不是本地对象w,而是它的一个引用(std::move(w)的返回值)。这样,本来为了避免拷贝做出的努力,反而限制了编译器的优化。

但是RVO只是一种优化,标准并不强制编译器进行这种优化。或许我们洞若观火,一眼看出满足上述两点条件,但是编译器很难做到优化的情景。


当函数内部的不同执行路径返回了不同的本地变量,因为编译器需要生成代码来为合适的本地变量在函数的返回值的内存区域上进行构造,但编译器怎么知道哪个本地变量是合适的呢?可以看下面这段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
MyClass fun(int condition){// suppose condition >= 1 
MyClass obj1;
if(condition & 3){
...//configure obj1;
}
if(condition & 7){
MyClass obj2;
...//configure obj2, perhaps obj1 is also involved.
return obj3;// obj3 does not necessarily equal with obj2
}
else{
...//configure obj1
return obj1;
}
}

运行时传入的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
2
3
4
5
Widget makeWidget(Widget w){ 
//pass by value
...
return w;
}

编译器实际会将之视为

1
2
3
4
5
Widget makeWidget(Widget w){ 
//pass by value
...
return std::move(w);
}

这意味着我们如果在一个返回值的函数的返回语句上加一句std::move,不仅不能让函数的性能变得更好,反而断绝了复制省略的可能。虽然在一些情况下,对一个本地变量进行std::move操作确实是有道理的——比如我们会将它传给下一次层函数调用,并且明确知道不会再使用它了——但是return语句上的std::move并不在此列。

总结

最后一次使用某个引用时,如果它是右值引用,应该施加std::move;如果是个万能能引用,应该施加std::forward

对于返回值的函数,但参数是右值引用或者万能引用时,也做相同的事情

如果对象满足RVO的条件,就不要再添加std::move或者std::forward


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!