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

我们已经知道,右值引用只会绑定到可供移动的对象上。如果形参类型为右值引用,那么也就说明它绑定的对象是可移动的:

class Widget {
    Widget(Widget&& rhs);       // rhs definitely refers to an// object eligible for moving
};

我们会自然而然地希望将对象传给其他函数(比如传给上面Widget的构造函数),并使这些函数可以利用该对象的右值属性。办法就是,将绑定到这些对象的形参转换为右值(因为形参都是左值嘛)。Item 23中也提到过,std::move就能干这个事,并且它就是为了这个事被发明出来的,那么可以写出如下代码:

class Widget {
public:
    Widget(Widget&& rhs)              // rhs is rvalue reference
    : name(std::move(rhs.name)),
        p(std::move(rhs.p))
        {}private:
    std::string name;
    std::shared_ptr<SomeDataStructure> p;
};

而万能引用,则是可能会绑定到可供移动的对象上。万能引用只有在使用右值初始化时,才会被转为右值引用。而这就需要用到std::forward了:

class Widget {
public:
    template<typename T>
    void setName(T&& newName)                   // newName is
    { name = std::forward<T>(newName); }        // universal reference};

总而言之,当转发右值引用给其他函数时,就使用std::move强转为右值,因为右值引用一定绑定到右值上;而当转发万能引用时,就使用std::forward,因为万能引用不一定绑定到右值上。
其实,Item 23提到过可以利用std::forward对右值引用施加转换,但是太麻烦,且容易出错。
而如果对万能引用施加std::move,那就更扯淡了。因为如果是一个左值,那这个左值可能会遭遇意外改动:

class Widget {
public:
    template<typename T>
    void setName(T&& newName)               // universal reference
    { name = std::move(newName); }          // compiles, but is// bad, bad, bad!
private:
    std::string name;
    std::shared_ptr<SomeDataStructure> p;
};

std::string getWidgetName(); // factory function
Widget w;
auto n = getWidgetName();    // n is local variable
w.setName(n);                // moves n into w!// n's value now unknown

看上面的代码,局部变量n被传给w.setName,而调用者会合情合理地假定这时一个对n的只读操作。但是setName函数内部使用了std::move将其形参转换到右值,n的值被移动到了w.name中,当setName调用完返回后,n就变成了一个不确定的值。这样的行为对于调用者是很可怕的。
有人说,setName的形参就不应该声明为万能引用。这样的引用不能用const修饰(Item 24),当然setName也不应该修改形参的值。那是不是声明两个setName对常量左值和右值的重载就可以解决这个问题呢:

class Widget {
public:
    void setName(const std::string& newName)        // set from
    { name = newName; }                             // const lvalue
    
    void setName(std::string&& newName)             // set from
    { name = std::move(newName); }                  // rvalue};

上面的代码当然可以正常工作,但是有这些缺点:1,需要编写和维护更多的源代码;2,效率可能降低。例如下面的用法:

w.setName("Adela Novak");

再使用万能引用做形参的setName版本里,字符串的字面值"Adela Novak"会被传给setName,然后再转手传给w内部std::string的赋值运算符。w内的name可以直接从字符串字面值得到赋值,而不会产生std::string的临时变量。然而,重载版本setName将会创建std::string类型的临时对象以供其形参绑定,随后盖临时对象才会一如w的数据成员。因此,一个对setName(重载版本)的调用会产生这样的执行序列:一次std::string的构造函数(创建临时对像),一次std::string的移动赋值运算符(移动newNmame到w.name),还有一次std::string的析构函数(销毁临时变量)。这个执行序列几乎肯定比仅仅调用一次std::string赋值运算符(通过使用const char*指针)代价高昂。这种额外的开销和实现有关,是否需要担心这种开销取决于不同的引用程序和库。但事实是,通过对左值和右值引用的重载来替换万能引用形参的,可能会导致效率问题。如果继续设想,例子中Widget中成员不是std::string,而是别的类型,效率可能更差,毕竟不是所有类型在执行移动时都像std::string一样成本低廉(Item 29)。

其实啊,上述的方法不仅是代码膨胀和运行效率的问题,而是这种设计的可扩展性太差。Widget::setName只有一个形参,因此两个重载就够了,但是对于多个形参的函数,这代码就没法写了,更糟糕的是有些函数(比如函数模板)会有无穷多个参数。比如std::make_shared和std::make_unique :

template<class T, class... Args>                // from C++11
shared_ptr<T> make_shared(Args&&... args);      // Standard

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

对于这样的函数,针对左值和右值的重载根本就不可行:万能引用才是唯一的解决之道。在这些函数内部,我可以保证,std::forward肯定被用于万能引用后,再转发给其他函数的。所以,我们也应该这么做!

虽然说,我们最终会这么做,但有些情况需要注意。比如,你会想要在单一函数内不止一次地绑定到右值引用或者万能引用,还要保证对该对象的其他所有操作之前,其值不被移走。那么,就需要在最后一次使用该引用时,对其实施std::move (for rvalue references) or std::forward (for universal references)。例如:

template<typename T>                    // text is
void setSignText(T&& text)              // univ. reference
{
    sign.setText(text);                 // use text, but
                                        // don't modify it
                                        
    auto now =                          // get current time
        std::chrono::system_clock::now();
        
    signHistory.add(now,
        std::forward<T>(text));         // conditionally cast
} 

我们想要text的值不会被sign.setText修改,因为随后在调用signHistory.add时还会用到这个值,所以std::forward仅仅用在该万能引用最后一次使用的场合。
对于std::move,也同样适用(最后一次使用右值引用时,实施std::move)。在极少的情况下可能会用std::move_if_noexcept替换std::move。To learn when and why, consult Item 14.

在按值返回的函数中,如果返回一个绑定到右值引用或万能引用的对象(注意,不是局部对象哦),当返回引用时,应该应用std::move或std::forward。 原因是为何呢?考虑一个operator+函数,用以实现矩阵加法,加号左边是个右值(因此它的存储空间可以复用,以保存矩阵的和):

Matrix                                       // by-value return
operator+(Matrix&& lhs, const Matrix& rhs)
{
    lhs += rhs;
    return std::move(lhs);                   // move lhs into
}                                            // return value

通过在返回语句中把lhs强转为右值(被std::move),lhs会被移入返回值的存储位置。如果std::move的调用去掉:

Matrix                                       // as above
operator+(Matrix&& lhs, const Matrix& rhs)
{
    lhs += rhs;
    return lhs;                              // copy lhs into
}                                            // return value

lhs是一个左值,编译器会将其拷贝到返回值存储位置。
如果Matrix支持移动构造,应用std::move版本的operator+函数效率会更高。即使Matrix不支持移动,将其转换为右值也并无大碍,因为右值也就是通过Matrix的拷贝构造函数来完成拷贝而已(see Item 23)。如果后续Matrix又支持移动了,代码都不用改。所以,在return by value中,使用std::move把要返回的值转换成右值(注意,不是局部变量哦),不会付出任何代价(可能还有不小的收益)。
对于万能引用和std::forward来说,情况类似。比如:

template<typename T>
Fraction                                // by-value return
reduceAndCopy(T&& frac)                 // universal reference param
{
    frac.reduce();
    return std::forward<T>(frac);       // move rvalue into return
}                                       // value, copy lvalue

如果去掉std::forward,frac会无条件的拷贝到reduceAndCopy的返回值中。

既然如此,那我们是不是可以认为:“相同的优化是否可以用在返回局部变量的场景中呢”。比如,常见的返回局部变量的函数,类似如下代码:

Widget makeWidget()         // "Copying" version of makeWidget
{
    Widget w;               // local variable// configure w
    return w;               // "copy" w into return value
}

可以通过将“拷贝”转换为移动来“优化”:

Widget makeWidget()         // Moving version of makeWidget
{
    Widget w;return std::move(w);    // move w into return value
}                           // (don't do this!)

我们特意使用了引号来提示这种想法是错误的,但错在哪里呢?

根本原因在于,便准委员会料敌预先,C++标准一经问世就说:可以通过直接在为函数返回值分配的内存上创建局部变量来避免拷贝。这就是所谓的RVO(return value optimization)。

编译器若要在一个按值返回的函数里省略对局部对象的拷贝(或者移动),必须满足两个条件:(1) 局部对象类型与函数返回值类型相同;(2) 返回的必须是局部对象本身。上面的makeWidget拷贝版本代码是符合条件的,所以并未发生拷贝w的情况。

移动版本的makeWidget,它移动了w的内容到makeWidget的返回值空间里。那么为什么编译器没有用RVO来消除移动呢,而是在函数返回值分配的内存里重建了一个w呢?答案很简单:做不到。条件(2)规定了RVO只能在返回值是一个局部对象时执行,然后:

return std::move(w);

这里返回的不是局部对象w,而是w的引用——std::move(w)的结果。返回一个局部对象的引用并不满足实施RVO的条件,因此编译器必须把w移入函数的返回值存储位置。本来我们想通过std::move来帮助编译器进行优化,但却适得其反,限制了本来可用的编译器优化选项。

话说回来,RVO毕竟是种优化。编译器没有义务必须省略拷贝和移动操作。如此一来,你可能会疑惑,万一编译器因为某些原因就采用了拷贝的方式,岂不是效率又下降了。或者说,你觉着在比较复杂的函数中(比如,存在不同的控制路径返回的局部变量),编译器也没办法做出RVO的决策,那么应用std::move是不是就更保险一点呢?
答案是,即便出现所说的情况,针对局部变量应用std::move也是个馊主意。因为标准说了,如果RVO的条件满足了,但编译器却没有执行拷贝省略,返回对象必须作为右值处理。换种说法就是,标准要求:当RVO的条件满足时,要么执行拷贝省略,要么std::move被隐式的作用于返回的局部对象上。假如有如下代码:

Widget makeWidget() // as before
{
    Widget w;return w;
}

编译器要么省略w的拷贝操作,也就是采用RVO,要么让函数做特殊处理,以与下面的代码等价:

Widget makeWidget()
{
    Widget w;return std::move(w);        // treat w as rvalue, because
}                               // no copy elision was performed

上面这种按照右值处理的情况,同样适用于形参是按值传递的函数,比如:

Widget makeWidget(Widget w)         // by-value parameter of same
{                                   // type as function's returnreturn w;
}

上面的代码,不适合实施拷贝省略,但编译器必须在其返回时作为右值处理,形式类似于下面这样:

Widget makeWidget(Widget w)
{return std::move(w);        // treat w as rvalue
}

综上所述,针对函数中安置返回的局部对象实施std::move操作(不能给编译器帮上忙,因为即便编译器不能省略拷贝操作,也会把返回的局部变量按右值处理),却可能帮倒忙(可能排除掉RVO的实施机会)。

确实存在适合于针对局部变量应用std::move的情况(即,将其传递给某个函数,并且确定不会再使用该变量)。但作为return语句一部分时,就别用std::move了

Things to Remember

  • 针对右值引用的最后一次使用实施std::move,针对万能引用的最后一次使用实施std::forward;
  • 对于按值返回的函数函数,要被返回的右值引用和万能引用做同样的事情(把std::move用在右值引用上,把std::forward用在万能引用上);
  • 若局部对象可能适用于RVO,则不要对其使用std::move or std::forward;
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值