avatar

Catalog
Effective Modern C++ 笔记(5):Rvalue References, Move Semantics and Perfect Forwarding

Chapter 5 Rvalue References, Move Semantics and Perfect Forwarding

Item 23 Understand std::move and std::forward

  • std::movestd::forward 在运行时刻不做任何事(它们不会生成哪怕一个字节的可执行代码),只是在编译时刻转换参数的左右值属性:

    c++
    1
    2
    3
    4
    5
    6
    template<typename T>
    decltype(auto) move(T&& param)
    {
    using ReturnType = remove_reference_t<T>&&;
    return static_cast<ReturnType>(param);
    }

    注意:作为返回值的右值引用是右值!

  • move 无条件地将参数转换成右值,而 forward 可以根据模板参数来决定是否将参数转换成右值。尽管用 forward 也可以实现 move 的功能,但是需要多指定一个模板参数,并且语义上也不太合适:

    c++
    1
    2
    3
    A a;
    std::move(a);
    std::forward<A>(a);

Item 24 Distinguish universal reference from rvalue reference

  • universal reference 需要满足两个条件:有形如 T&& 的类型以及发生了类型推断。

    • 仅发生类型推断但没有 T&& 类型的情况:

      c++
      1
      2
      template<typename T>
      void f(std::vector<T>&& param); // rvalue reference

      注意必须是严格的 T&&(当然不一定非得是 T),多一个 const 也不行:

      c++
      1
      2
      template<typename T>
      void f(const T&& param); // rvalue reference
    • 仅有 T&& 类型但没有发生类型推断的情况:

      c++
      1
      2
      3
      4
      5
      template<class T, class Allocator = allocator<T>
      class vector {
      public:
      void push_back(T&& x); // rvalue reference
      };

      该情况中,实际调到 push_back 前类模板已经被实例化了,所以 T 已经是一个具体的类型了,不会发生类型推断。

    T&& 也可以是 auto&&

    c++
    1
    auto&& var2 = var1;  // universal reference

    这在 C++14 的 lambda 表达式中尤其有用:

    c++
    1
    2
    3
    4
    5
    auto timeFuncInvocation = [](auto&& func, auto&&... params) {  // universal reference
    // start timer
    std::forward<decltype(func)>(func)(std::forward<decltype(params)>(params)...);
    // stop timer and record elapsed time
    };
  • universal reference 表现成 lvalue reference 还是 rvalue reference 由初始化表达式是左值还是右值来决定:

    c++
    1
    2
    3
    4
    5
    6
    template<typename T>
    void f(T&& param); // param is a universal reference

    Widget w;
    f(w); // lvalue passed to f; param's type is Widget& (lvalue reference)
    f(std::move(w)); // rvalue passed to f; param's type is Widget&& (rvalue reference)

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

  • 对于函数入参,将 move 作用于 rvalue reference,将 forward 作用于 universal reference,需要注意的是如果多次使用该入参,则只能将 moveforward 作用在最后一次上:

    c++
    1
    2
    3
    4
    5
    6
    template<typename T>
    void setSignText(T&& text) {
    sign.setText(text);
    auto now = std::chrono::system_clock::now();
    signHistory.add(now, std::forward<T>(text));
    }

    当然,这里说的 “最后一次” 也可以是返回值(如果函数的返回值是 return by value 的话):

    c++
    1
    2
    3
    4
    Matrix operator+(Matrix&& lhs, const Matrix& rhs) {
    lhs += rhs;
    return std::move(lhs);
    }

    这样做的好处在于省去一次 copy 的开销。

  • 但是将 move 作用于返回值这一操作对于 local 对象来说,情况有所不同。首先需要明白为什么要将 move 作用于返回值?因为想省下一次 copy 操作的开销。那么返回一个 local 对象真的会 copy 吗?其实大部分情况下是不会的,因为编译器做了 RVO(返回值优化),即直接在存放函数返回值的内存位置处构造这个 local 对象:

    c++
    1
    2
    3
    4
    Widget makeWidget() {
    Widget w; // 构造在存放函数返回值的内存位置,而非存放普通 local 对象的位置
    return w;
    }

    所以即使不加 move,编译器也不会 copy。相反,如果加了 move,编译器将不会进行 RVO,因为 RVO 需要满足两个条件:local 对象类型要和返回值类型一样且 local 对象就是被返回的对象。而被返回的对象(std::move(w))是 w 的引用,并非 w 本身,所以不满足 RVO 的条件。

    那有的人也许会说,我加了 move 虽然放弃了 RVO 的机会(这里说是 “机会” 是因为不是说不加 move 就一定会 RVO,仍然有很多其他情况阻止编译器做 RVO,比如有多处返回值返回不同的 local 变量),但至少保证了不会 copy。这个观点也是不对的,因为如果满足 RVO 的条件而编译器由于种种原因没有做 RVO 的话,它也会将返回值作为右值来处理,即看上去像是编译器帮你加了 move 一样。

    总结一下就是,当函数满足 RVO 的条件时,不要将 move 作用于返回值。因为如果确实做了 RVO,那就白白多了一次 move 操作;而就算没有做 RVO,编译器也会将其当做右值处理,手动加上 move 并没有任何优化。

  • 同样地,对于返回值是值传递进来的参数的情况,编译器也会将其当做右值处理(这种情况做不了 RVO),不用程序员加上 move

    c++
    1
    2
    3
    Widget makeWidget(Widget w) {
    return w;
    }

Item 26 Avoid overloading on universal references

  • 尽量避免对 universal reference 进行重载:

    c++
    1
    2
    3
    4
    template<typename T>
    void logAndAdd(T&& name);

    void logAndAdd(int idx);

    在这个例子中,只要参数类型不是 int,对 logAndAdd 的调用都会被有 universal reference 的版本捕获,因为它可以实例化出一个完美匹配的模板函数。在 C++ 重载函数的版本选择中,类型能完美匹配的优于需要转型的,如果类型都能完美匹配,那么非模板函数优于实例化出来的模板函数。

  • 除此以外,当类的构造中含有 universal reference 时,情况会变得更糟。它会捕获所有非常量拷贝构造(编译器自动生成的拷贝构造参数具有 const,不能算完美匹配了),还会捕获所有来自子类的拷贝构造(子类的类型被父类的拷贝构造接受需要转型)

Item 27 Familiarize yourself with alternatives to overloading on universal references

如果不得不利用重载 universal reference 提供的功能,有几种方式可以避免它带来的问题:

  • 不重载,用不同名的函数。
  • 不使用 universal reference,可以使用常量左值引用或值传递。

(以上两种方法都不是很好)

  • Tag Dispatch。重载 universal reference 带来的问题就是选择重载版本时,它往往会捕获到比我们想象的多得多的调用,那只要解决这个问题就可以了。注意到选择重载版本是以参数类型为依据的,universal reference 只是其中一个参数,我们可以使用另一个参数(tag)来决定调用(dispatch)哪个版本:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    template<typename T>
    void logAndAdd(T&& name) {
    logAndAddImpl(
    std::forward<T>(name),
    std::is_integral<typename std::remove_reference<T>::type>()
    // 使用 remove_reference 是因为 int& 不是 integral type
    );
    }

    template<typename T>
    void logAndAddImpl(T&& name, std::false_type);

    void logAndAddImpl(int idx, std::true_type);

    std::true_typestd::false_type 是编译时的 bool 类型。选择重载版本时它们能 “屏蔽” universal reference 的完美匹配(就好比是在 “完美匹配 + 完美不匹配” 和 “需要转型 + 完美匹配” 中选一个)。

  • 还有一种方法,用到模板可以在某些条件下被禁用的机制,condition 不满足时,该模板就好像不存在一样:

    c++
    1
    template<typename T, typename = typename std::enable_if<condition>::type>

    为了不让 universal reference 捕获到和自定义类型仅 cvr 属性不同的参数、是自定义类型派生类的参数或者 integral type,可以这样指定自定义类型的含有 universal reference 的构造函数:

    c++
    1
    2
    3
    4
    5
    6
    7
    8
    template<
    typename T,
    typename = std::enable_if_t<
    !std::is_base_of<Person, std::decay_t<T>>::value &&
    !std::is_integral<std::remove_reference_t<T>>::value
    >
    >
    explicit Person(T&& n) : name(std::forward<T>(n)) {}

    这种方法解决了 Tag Dispatch 的一个问题:即使是含有 universal reference 的构造函数也不能捕获所有调用(还存在其他重载版本,比如默认拷贝构造),所以就没办法作为一个 dispatcher。

  • universal reference 的确效率很高,但是往往存在易用性上的问题,比如报错信息太隐晦。

Item 28 Understand reference collapsing

reference collapsing 是说当编译器生成了一个引用的引用时(程序员是不能定义引用的引用的),会按照以下规则将其折叠成单引用:

c++
1
2
3
4
T& &   -> T&
T& && -> T&
T&& & -> T&
T&& && -> T&&

有四种情况会发生 reference collapsing:

  • 模板实例化(universal reference),这也是用的最多的一个情形:

    c++
    1
    2
    template<typename T>
    void func(T&& param);

    当实参为左值时,T 推断为左值引用 &,发生 reference collapsing(当实参为右值时,T 被推断为普通类型,没有 reference collapsing)

  • auto 类型推断(universal reference),如 Item 24 中所述,这也是 universal reference:

    c++
    1
    auto&& w1 = w;
  • typedef 和类型别名,上面两种情况是根据参数的左右值属性来推断不同的 Tauto,推断的结果有两种:普通类型和左值引用,只有推断为左值引用时才会发生 reference collapsing,但是这种情况(以及下面那种情况)不同,它们没有发生类型推断:

    c++
    1
    2
    3
    4
    5
    6
    7
    8
    template<typename T>
    class Widget {
    public:
    typedef T&& RvalueRefToT;
    };

    Widget<int&> w1; // int&
    Widget<int&&> w2; // int&&
  • decltype,和第三种情况类似:

    c++
    1
    decltype(a) &&b;

Item 29 Assume that move operations are not present, not cheap and not used

move 操作并非总是优于 copy 操作,例如:

  • 有些类不支持 move 操作;
  • 对于小对象,copy 操作有时也会比 move 操作快(比如 std::string 的 SSO,Small String Optimization);
  • move 操作会抛出异常,而接口要求 noexcept 时。

Item 30 Familiarize yourself with perfect forwarding failure cases

perfect forwarding 是说有一个转发函数和目标函数,转发函数需要将其接受的参数原封不动地(保留类型,cvr 属性)传递给目标函数。而如果将这些参数传递给转发函数让其转发和直接传递给目标函数得到的行为不一样的话,perferct forwarding 就被认为失败了。

导致 perfect forwarding 失败可能有以下几种情况:

  • 参数为 braced initializers,即花括号括起来的一组值。将 braced initializers 直接传递给目标函数没有问题,但是转发函数的参数是 universal reference,无法从 braced initializers 推断出类型。但是有一个 trick 可以解决这个问题:先用 braced initializers 初始化一个 auto 的 initializer_list 然后将其传进来。

  • 参数为希望被当成空指针的 0 或 NULL。当转发函数接受到 0 或 NULL 时,会优先转发给目标函数接受 integral type 的重载版本,而非接受指针的重载版本。

  • 参数为未定义的 static const 成员变量。需要先明确一下,“定义” 成员变量是写在类外的,“声明” 成员变量才是类内的:

    c++
    1
    2
    3
    4
    5
    6
    class Widget {
    public:
    static const std::size_t MinVals = 28; // declaration
    };

    const std::size_t Widget::MinVals; // definition

    如果不对未定义的 static const 成员变量取地址,那不定义没有什么问题,但是作为转发函数的参数,需要引用这个成员变量,而对编译器生成的代码来说,引用和指针通常没什么区别。即不定义就不占内存,就没有地址,就没办法引用。

  • 参数为有重载版本的函数名或者函数模板。将函数名或函数模板直接传递给目标函数时,可以通过目标函数的参数类型决定用哪个重载版本或模板实例,但是转发函数的参数类型是 universal reference,没有办法选择重载版本或模板实例。解决的方法是先将函数名赋值给一个新定义的确定类型的变量,然后将新定义的变量传递进来或者直接将函数名转型成指定的类型。

  • 参数是位域。C++ 不允许非常量引用绑定到位域,因为没法直接修改 bit。解决的方法和之前类似,先用位域初始化一个 auto 变量,然后将这个新定义的变量传递进来。

Reference

Author: Gusabary
Link: http://gusabary.cn/2020/05/24/Effective-Modern-C++-Notes/Effective-Modern-C++-Notes(5)-Rvalue-References,Move-Semantics-and-Perfect-Forwarding/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.

Comment