EffectiveModernCppChinese/5.RRefMovSemPerfForw/item25.md
猫耳堀川雷鼓 8870e30a58
Update item25.md
2021-02-28 21:31:15 +08:00

16 KiB
Raw Blame History

条款二十五:对右值引用使用std::move,对通用引用使用std::forward

Item 25: Use std::move on rvalue references, std::forward on universal references

右值引用仅绑定可以移动的对象。如果你有一个右值引用形参就知道这个对象可能会被移动:

class Widget {
    Widget(Widget&& rhs);       //rhs定义上引用一个有资格移动的对象
    
};

这是个例子你将希望传递这样的对象给其他函数允许那些函数利用对象的右值性rvalueness。这样做的方法是将绑定到此类对象的形参转换为右值。如Item23中所述,这不仅是std::move所做,而且它的创建就是为了这个目的:

class Widget {
public:
    Widget(Widget&& rhs)        //rhs是右值引用
    : name(std::move(rhs.name)),
      p(std::move(rhs.p))
      {  }
    
private:
    std::string name;
    std::shared_ptr<SomeDataStructure> p;
};

另一方面(查看Item24),通用引用可能绑定到有资格移动的对象上。通用引用使用右值初始化时,才将其强制转换为右值。Item23阐释了这正是std::forward所做的:

class Widget {
public:
    template<typename T>
    void setName(T&& newName)           //newName是通用引用
    { name = std::forward<T>(newName); }

    
};

总而言之,当把右值引用转发给其他函数时,右值引用应该被无条件转换为右值(通过std::move),因为它们总是绑定到右值;当转发通用引用时,通用引用应该有条件地转换为右值(通过std::forward),因为它们只是有时绑定到右值。

Item23解释说,可以在右值引用上使用std::forward表现出适当的行为,但是代码较长,容易出错,所以应该避免在右值引用上使用std::forward。更糟的是在通用引用上使用std::move,这可能会意外改变左值(比如局部变量):

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();        //工厂函数

Widget w;

auto n = getWidgetName();           //n是局部变量

w.setName(n);                       //把n移动进w

                                   //现在n的值未知

上面的例子,局部变量n被传递给w.setName,调用方可能认为这是对n的只读操作——这一点倒是可以被原谅。但是因为setName内部使用std::move无条件将传递的引用形参转换为右值,n的值被移动进w.name,调用setName返回时n最终变为未定义的值。这种行为使得调用者蒙圈了——还有可能变得狂躁。

你可能争辩说setName不应该将其形参声明为通用引用。此类引用不能使用const(见Item24),但是setName肯定不应该修改其形参。你可能会指出,如果为const左值和为右值分别重载setName可以避免整个问题,比如这样:

class Widget {
public:
    void setName(const std::string& newName)    //用const左值设置
    { name = newName; }
    
    void setName(std::string&& newName)         //用右值设置
    { name = std::move(newName); }
    
    
};

这样的话,当然可以工作,但是有缺点。首先编写和维护的代码更多(两个函数而不是单个模板);其次,效率下降。比如,考虑如下场景:

w.setName("Adela Novak");

使用通用引用的版本的setName,字面字符串“Adela Novak”可以被传递给setName,再传给w内部std::string的赋值运算符。wname的数据成员通过字面字符串直接赋值,没有临时std::string对象被创建。但是,setName重载版本,会有一个临时std::string对象被创建,setName形参绑定到这个对象,然后这个临时std::string移动到w的数据成员中。一次setName的调用会包括std::string构造函数调用(创建中间对象),std::string赋值运算符调用(移动newNamew.namestd::string析构函数调用(析构中间对象)。这比调用接受const char*指针的std::string赋值运算符开销昂贵许多。增加的开销根据实现不同而不同,这些开销是否值得担心也跟应用和库的不同而有所不同,但是事实上,将通用引用模板替换成对左值引用和右值引用的一对函数重载在某些情况下会导致运行时的开销。如果把例子泛化,Widget数据成员是任意类型(而不是知道是个std::string),性能差距可能会变得更大,因为不是所有类型的移动操作都像std::string开销较小(参看Item29)。

但是,关于对左值和右值的重载函数最重要的问题不是源代码的数量,也不是代码的运行时性能。而是设计的可扩展性差。Widget::setName有一个形参因此需要两种重载实现但是对于有更多形参的函数每个都可能是左值或右值重载函数的数量几何式增长n个参数的话就要实现2n种重载。这还不是最坏的。有的函数——实际上是函数模板——接受无限制个数的参数,每个参数都可以是左值或者右值。此类函数的典型代表是std::make_shared还有对于C++14的std::make_unique(见Item21)。查看他们的的重载声明:

template<class T, class... Args>                //来自C++11标准
shared_ptr<T> make_shared(Args&&... args);

template<class T, class... Args>                //来自C++14标准
unique_ptr<T> make_unique(Args&&... args);

对于这种函数,对于左值和右值分别重载就不能考虑了:通用引用是仅有的实现方案。对这种函数,我向你保证,肯定使用std::forward传递通用引用形参给其他函数。这也是你应该做的。

好吧,通常,最终。但是不一定最开始就是如此。在某些情况,你可能需要在一个函数中多次使用绑定到右值引用或者通用引用的对象,并且确保在完成其他操作前,这个对象不会被移动。这时,你只想在最后一次使用时,使用std::move(对右值引用)或者std::forward(对通用引用)。比如:

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的值不会被sign.setText改变,因为我们想要在signHistory.add中继续使用。因此std::forward只在最后使用。

对于std::move,同样的思路(即最后一次用右值引用的时候再调用std::move),但是需要注意,在有些稀少的情况下,你需要调用std::move_if_noexcept代替std::move。要了解何时以及为什么,参考Item14

如果你在按值返回的函数中,返回值绑定到右值引用或者通用引用上,需要对返回的引用使用std::move或者std::forward。要了解原因,考虑两个矩阵相加的operator+函数,左侧的矩阵为右值(可以被用来保存求值之后的和):

Matrix                              //按值返回
operator+(Matrix&& lhs, const Matrix& rhs)
{
    lhs += rhs;
    return std::move(lhs);	        //移动lhs到返回值中
}

通过在return语句中将lhs转换为右值(通过std::movelhs可以移动到返回值的内存位置。如果省略了std::move调用,

Matrix                              //同之前一样
operator+(Matrix&& lhs, const Matrix& rhs)
{
    lhs += rhs;
    return lhs;                     //拷贝lhs到返回值中
}

lhs是个左值的事实,会强制编译器拷贝它到返回值的内存空间。假定Matrix支持移动操作,并且比拷贝操作效率更高,在return语句中使用std::move的代码效率更高。

如果Matrix不支持移动操作,将其转换为右值不会变差,因为右值可以直接被Matrix的拷贝构造函数拷贝(见Item23)。如果Matrix随后支持了移动操作,operator+将在下一次编译时受益。就是这种情况,通过将std::move应用到按值返回的函数中要返回的右值引用上,不会损失什么(还可能获得收益)。

使用通用引用和std::forward的情况类似。考虑函数模板reduceAndCopy收到一个未折叠unreduced对象Fraction,将其折叠,并返回一个折叠后的副本。如果原始对象是右值,可以将其移动到返回值中(避免拷贝开销),但是如果原始对象是左值,必须创建副本,因此如下代码:

template<typename T>
Fraction                            //按值返回
reduceAndCopy(T&& frac)             //通用引用的形参
{
    frac.reduce();
    return std::forward<T>(frac);		//移动右值,或拷贝左值到返回值中
}

如果std::forward被忽略,frac就被无条件复制到reduceAndCopy的返回值内存空间。

有些开发者获取到上面的知识后,并尝试将其扩展到不适用的情况。“如果对要被拷贝到返回值的右值引用形参使用std::move,会把拷贝构造变为移动构造,”他们想,“我也可以对我要返回的局部对象应用同样的优化。”换句话说,他们认为有个按值返回局部对象的函数,像这样,

Widget makeWidget()                 //makeWidget的“拷贝”版本
{
    Widget w;                       //局部对象
                                   //配置w
    return w;                       //“拷贝”w到返回值中
}

他们想要“优化”代码,把“拷贝”变为移动:

Widget makeWidget()                 //makeWidget的移动版本
{
    Widget w;
    
    return std::move(w);            //移动w到返回值中不要这样做
}

我的注释告诉你这种想法是有问题的,但是问题在哪?

这是错的,因为对于这种优化,标准化委员会远领先于开发者。早就为人认识到的是,makeWidget的“拷贝”版本可以避免复制局部变量w的需要,通过在分配给函数返回值的内存中构造w来实现。这就是所谓的返回值优化return value optimizationRVO这在C++标准中已经实现了。

对这种好事遣词表达是个讲究活,因为你想只在那些不影响软件外在行为的地方允许这样的拷贝消除copy elision。对标准中教条的也可以说是有毒的絮叨做些解释这个特定的好事就是说编译器可能会在按值返回的函数中消除对局部对象的拷贝或者移动如果满足1局部对象与函数返回值的类型相同2局部对象就是要返回的东西。适合的局部对象包括大多数局部变量比如makeWidget里的w),还有作为return语句的一部分而创建的临时对象。函数形参不满足要求。一些人将RVO的应用区分为命名的和未命名的即临时的局部对象限制了RVO术语应用到未命名对象上并把对命名对象的应用称为命名返回值优化named return value optimizationNRVO把这些记在脑子里再看看makeWidget的“拷贝”版本:

Widget makeWidget()                 //makeWidget的“拷贝”版本
{
    Widget w;
    
    return w;                       //“拷贝”w到返回值中
}

这里两个条件都满足你一定要相信我对于这些代码每个合适的C++编译器都会应用RVO来避免拷贝w。那意味着makeWidget的“拷贝”版本实际上不拷贝任何东西。

移动版本的makeWidget行为与其名称一样(假设Widget有移动构造函数),将w的内容移动到makeWidget的返回值位置。但是为什么编译器不使用RVO消除这种移动而是在分配给函数返回值的内存中再次构造w答案很简单它们不能。条件2中规定仅当返回值为局部对象时才进行RVO但是makeWidget的移动版本不满足这条件,再次看一下返回语句:

return std::move(w);

返回的已经不是局部对象w,而是**w的引用**——std::move(w)的结果。返回局部对象的引用不满足RVO的第二个条件所以编译器必须移动w到函数返回值的位置。开发者试图对要返回的局部变量用std::move帮助编译器优化,反而限制了编译器的优化选项。

但是RVO就是个优化。编译器不被要求消除拷贝和移动操作即使他们被允许这样做。或许你会疑惑并担心编译器用拷贝操作惩罚你因为它们确实可以这样。或者你可能有足够的了解意识到有些情况很难让编译器实现RVO比如当函数不同控制路径返回不同局部变量时。编译器必须产生一些代码在分配的函数返回值的内存中构造适当的局部变量但是编译器如何确定哪个变量是合适的呢如果这样你可能会愿意以移动的代价来保证不会产生拷贝。那就是极可能仍然认为应用std::move到一个要返回的局部对象上是合理的,只因为可以不再担心拷贝的代价。

那种情况下,应用std::move到一个局部对象上仍然是一个坏主意。C++标准关于RVO的部分表明如果满足RVO的条件但是编译器选择不执行拷贝消除则返回的对象必须被视为右值。实际上标准要求当RVO被允许时或者实行拷贝消除或者将std::move隐式应用于返回的局部对象。因此,在makeWidget的“拷贝”版本中,

Widget makeWidget()                 //同之前一样
{
    Widget w;
    
    return w;
}

编译器要不消除w的拷贝,要不把函数看成像下面写的一样:

Widget makeWidget()
{
    Widget w;
    
    return std::move(w);            //把w看成右值因为不执行拷贝消除
}

这种情况与按值返回函数形参的情况很像。形参们没资格参与函数返回值的拷贝消除,但是如果作为返回值的话编译器会将其视作右值。结果就是,如果代码如下:

Widget makeWidget(Widget w)         //传值形参,与函数返回的类型相同
{
    
    return w;
}

编译器必须看成像下面这样写的代码:

Widget makeWidget(Widget w)
{
    
    return std::move(w);
}

这意味着,如果对从按值返回的函数返回来的局部对象使用std::move你并不能帮助编译器如果不能实行拷贝消除的话他们必须把局部对象看做右值而是阻碍其执行优化选项通过阻止RVO。在某些情况下std::move应用于局部变量可能是一件合理的事你把一个变量传给函数并且知道不会再用这个变量但是满足RVO的return语句或者返回一个传值形参并不在此列。

请记住:

  • 最后一次使用时,在右值引用上使用std::move,在通用引用上使用std::forward
  • 对按值返回的函数要返回的右值引用和通用引用,执行相同的操作。
  • 如果局部对象适合返回值优化,就不要使用std::move或者std::forward