Effective Modern C++ 条款25 对右值引用使用std::move,对通用引用使用std::forward

对右值引用使用std::move,对通用引用使用std::forward

右值引用只能绑定有移动机会的对象。如果你有个右值引用参数,那么你要知道它绑定的对象可能要被移动:

class Widget {
    Widget(Widget&& rhs);  // rhs要绑定一个有移动机会的对象
    ...
};

情况既然是这样,你将想要把这样的对象,传递给那些以对象右值性质为优势的其它函数。因此,我们需要把绑定到右值对象的参数转化为右值。就如条款23所说,std::move不仅是这样做的,它就是以这个为目的创建出来的:

class Widget {
public:
    Widget(Widget&& rhs)    // rhs是个右值引用
    : name(std::move(rhs.name)),
      p(std::move(p))
      { ... }
    ...

private:
    std::string name;
    std::shared_ptr<SomeDataStructure> p;
};

另一方便(条款24),通用引用有可能绑定一个有移动机会的对象,当初始值为右值时,通用引用应该被转换为右值。条款23说明这完全是std::forward做的事情:

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

    ...
};

简而言之,当把右值引用转发给其他函数时,右值引用应该无条件转换为右值(借助std::move),因为右值引用总是绑定右值。而当把通用引用转发给其他函数时,通用引用应该有条件地转换为右值(借助std::forward),因为通用引用只是有时候会绑定右值。

条款23说明对右值引用使用std::forward会表现出正确的行为,但是源代码会是冗长的、易错的、不符合语言习惯的,因此你应该避免对右值引用使用std::forward。更糟的想法是对通用引用使用std::move,因为它可以对不希望被改变的左值使用(例如,局部变量):

class Widget {
public:
    template<typename T>
    void setName(T&& newName)  // 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被传递给setName,调用者的行为是可以被原谅的,毕竟这里是个只读操作。但是因为在setName里面使用了std::move,它无条件地把引用参数转换为右值,那么n的值将会被移动到w.name,当n从setName返回时,它的值是未知的。这种行为会让调用者绝望——很可能暴怒。

你可能觉得setName的参数不应该声明为通用引用,这样的引用不能是const的(看条款24),而setName是肯定不会修改参数的。你可能会指出如果setName简单地对const左值和右值重载,所有的问题都可以避免。就像这样:

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

    void setName(std::string&& newName)  // 由右值设置
    { name = std::move(newName); } 

    ...
};

这是可以工作的,不过它有缺点。第一,它有更多的源代码要写和维护(用了两个函数代替一个模板函数),第二,它效率更低。例如,这样使用setName:

w.setName("Adela Novak");

在接受通用引用的setName版本中,字符串“Adela Novak”将会传递给setName,然后在w里面表达为std::string的赋值操作。因此w里的name成员变量直接被字符串赋值,没有产生std::string临时对象。而在重载的setName版本中,会创建一个临时对象(用const char*创建string),然后setName的参数绑定到这个临时对象,再把这个临时对象移动到w的成员变量中。因此,调用一次setName需要执行一次std::string的构造函数(为了创建临时对象),和一次std::string的移到赋值操作符(为了把newName移到到w.name),还有一次std::string的析构函数(为了销毁临时string对象)。这种执行顺序几乎肯定会比单独使用接受const char*指针的std::string移动构造函数昂贵。额外的开销可能会根据实现的不同而不同,而这笔开销是否值得又要根据应用和库的不同而不同,但事实是,使用一个接受通用引用的模板代替这两个重载函数可能会在某些情况下减少运行时开销。如果我们的例子中的Widget的成员变量可以任意类型(而不是被人熟知的std::string),那么性能的差距可能会进一步拉大,因为不是所有类型的移动操作都像**std::string那么便宜(看条款29)。

但是,使用两个重载函数的最严重的问题,不是冗长易错的源代码,也不是代码的运行时效率,而是这种设计的可扩展性差。Widget::setName只接受一个参数,所以只需重载两个函数,但如果函数有更多的参数,每个参数都可以是左值或右值,那么重载函数的数量会成几何增加:n个参数需要2^n个重载。然而这根本不值得,一些函数——实际上是模板函数——可以接受无限个参数,每个都可以是左值和右值。典型的代表是std::make_shared,还有在C++14中的std::make_unique。它们的声明如下:

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

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

对于这种函数,用左值和右值重载根本不可能:只有通用引用才是正确的方式。而在这些函数里面,我向你保证,当把通用引用转发给其他函数时,都使用了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转换为右值
}

在这里,我们要确保text的值不会被sign.setText改变,因为我们在调用signHistory.add时还想要用这个值。因此,在最后一次使用这个通用引用时才对它使用std::forward

对于std::move,想法是一样的(即最后一次使用右值引用时才对它使用std::move),但在你要注意再极少数情况下,你需要用std::move_if_noexcept来代替std::move。想知道什么时候和为什么的话,去看条款14。


如果你有个函数是通过值返回,然后你函数内返回的是被右值引用或通用引用绑定的对象,那么你应该对你返回的对象使用std::movestd::forward。想知道为什么,考虑一个把两个矩阵相加的operator+函数,而左边的矩阵参数被指定为右值(因此可以用这个参数来存储相加的结果):

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

在返回语句中,通过把lhs转换为一个右值(借助std::move),lhs会被移动到函数的返回区。如果省略了std::move的调用,

Matrix
operator+(Matrix&& lhs, const Matrix& rhs)
{
    lhs += rhs;
    return lhs;            // 把lhs拷贝到返回值
}

事实上,lhs是个左值 ,它会强迫编译器把它的值拷贝到返回区。假如Matrix类型支持移动构造,它比拷贝构造效率更高,那么在返回语句中使用std::move会产生更高效的代码。

如果Matrix类型不支持移动构造,把它转换为右值也是无伤害的,因为此事右值会简单地作为参数来调用拷贝构造函数(看条款23)。如果Matrix后来被修改为支持移动,那么在下次编译operator+会自动地提高效率。这种情况下,返回值为右值引用,对该右值引用使用std::move不会损失任何东西(而且可能得到一些好处)。

这种情况,对于通用引用和std::forward是相似的。考虑一个模板函数reduceAndCopy,它的参数是一个可能没减少过的Fraction对象,然后函数的行为是把它减少,然后返回减少后的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(意思是直接在返回区创建w,这样返回时就不用把局部变量w拷贝到返回区了)。这称为return value optimization(RVO),这被标准库明文规定了。

制定这样的规定是件很麻烦的事情,因为你只有在不影响程序行为的情况下才想要允许这样的拷贝省略(copy elision)。把标准库那墨守成规(可以说是有毒的)的规则进行意译,这个特殊的规则讲的是在通过值返回的函数中,如果(1)一个局部变量的类型和返回值的类型相同,而且(2)这个局部变量是被返回的对象,那么编译器可能会省略局部变量的拷贝(或移动)。记住这点,再看一次“拷贝”版本的makeWidget:

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

两个条件都满足,你要相信我,这份代码在每个正规的C++编译器面前,都会进行RVO优化来避免拷贝w。这意味着“拷贝”版本的makeWidget实际上没有拷贝任何东西。

移动版本的makeWidget只是做了它名字意义上的事情(假定Widget提供移动构造):把w的内容移动到makeWidget的返回区。但为什么编译器不使用RVO来消除移动,直接在分配给函数返回值的内存中构造w呢?答案很简单:它们不行。条件(2)明确规定返回的是个局部对象,但移动版本的makeWidget的行为与此不同。再看一次返回语句:

return std::move(w);

这里返回的不是局部变量w,而是个对w的引用——std::move(w)的结果。返回一个对局部变量的引用不满足RVO的条件,所以编译器必须把w移动到函数的返回区。开发者想要对返回的局部变量使用std::move来帮助编译器优化,实际上却是限制了编译器可选的优化选项!

不过RVO只是一种优化方式,编译器有时候不会省略拷贝和移动操作,尽管优化被允许。你可能会过分猜疑,然后你担心你的编译器会严厉对待拷贝操作,因为它们可以这样。或者你有足够的洞察力来辨认那些情况RVO难以实现,例如,当一个函数中有不同的控制流返回不同的局部变量时。(编译器会在分配给返回值的内存中构建合适的局部变量,但是编译器怎么知道返回哪个局部变量合适呢?)如果是这样的话,比起拷贝的开销,你可能更乐意使用移动。那样的话,你依然觉得对返回的局部变量使用std::move是合情理的,因为你知道这样绝对不用拷贝。

遗憾的是,在那种情况下,对局部变量使用std::move依然是个糟糕的想法。一部分标准RVO的规则讲述:如果RVO条件满足,但编译器没有省略拷贝操作,那么返回的对象一定会被视为右值。实际上,标准库要求当RVO被许可时,要么发生拷贝省略,要么对返回的局部变量隐式使用std::move。因此“拷贝”版本的makeWidget,

Widget makeWidget()   // 如前
{
    Widget w;
    ...
    return w;
}

编译器必须是要么把拷贝省略,要么把这个函数看作是这样写的:

Widget makeWidget()
{
    Widget w;
    ...
    return std::move(w);    // 把w视为右值,因为没有省略拷贝.
}

这种情况和以值传递的函数参数很像,关于函数返回值,它们没有资格省略拷贝,但是当它们返回时,编译器一定会把它看作右值。结果是,如果你的源代码是这样的,

Widget makeWidget(Widget w)  // 以值传递的参数,类型和返回值一样
{
    ...
    return w;
}

而编译器会把代码视为这样写的:

Widget makeWidget(Widget w) 
{
    ...
    return std::move(w);      // 把w视为右值
}

这意味着,如果你对返回的局部变量(局部变量的类型和返回值类型相同,函数是通过值返回)使用std::move,你并不能帮助你的编译器(如果编译器不能省略拷贝,就会把局部变量视为右值),不过你可以阻碍它们(通过阻碍RVO)。存在对局部变量使用std::move的合适的场合(即当你把变量传递给一个函数,而且你知道不会再使用这个局部变量了),但在有资格进行RVO的return语句,或者返回以值传递的从参数时,std::move不适用。


总结

需要记住的3点:

  • 在最后一次使用右值引用或者通用引用时,对右值引用使用std::move,对通用引用使用std::forward
  • 在一个通过值返回的函数中,如果返回的是右值引用或通用引用,那么对它们做同样的事情(对右值引用使用std::move,对通用引用使用std::forward)。
  • 如果局部变量有资格进行返回值优化(RVO),不要对它们使用std::movestd::forward
©️2020 CSDN 皮肤主题: 大白 设计师:CSDN官方博客 返回首页