Effective Modern C++读书笔记(三)

右值引用,移动语义和完美转发

  • *移动语义(Move semantics)*往往可以用来替代copy操作,减小开销。而且还要某些对象(比如std::unique_ptr, std::futrue, std::thread)只允许move,禁止copy。可是在某些情况下,它的开销并不一定比copy小。

  • *完美转发(Perfect forwarding)*可以让模板接受参数,然后转发给其它函数,保持参数的类型不变。在某些时候,它也并不是完美的。

而*右值引用(Rvalue reference)*就是使上述两个看似不相干的特性连接起来的胶水。它是支撑着移动语义和完美转的潜在语言机制。


Item 23

std::move不移动任何东西。std::forward不转发任何东西。在运行期间,不产生可执行的代码,不产升任何字节。它们只是是执行类型转换的函数模板(function templates)。

std::move

  • std::mvoe无条件地将它的参数转化成右值。可以看下一个接近标准库的简单实现

    1
    2
    3
    4
    5
    6
    7
    template<typename T>
    typename remove_reference<T>::type&&
    move(T&& param)
    {
    using ReturnType = typename remove_reference<T>::type&&;
    return static_cast<ReturnType>(param);
    }

    并且在c–14中,,std::move还能实现得更精炼:

    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);
    }

    由于std::move只做了将参数转化成右值的工作,甚至有人建议将它的名字改成ralue_cast。当然,右值是可移动的,所以将std::move用于一个对象,可以告诉编译器这个对象是可被移动的。可能这就是命名为std::move的原因。

  • std::mvoe并不一定真正能引发移动操作。举个例子:

    1
    2
    3
    4
    5
    6
    7
    8
    class Annotation {
    public:
    explicit Annotation(const std::string text)
    : value(std::move(text)) { }

    private:
    std::string value;
    }

    你可能预想value(std::move(text))讲引发std::string的移动构造函数。但是,这里却发生了copy。原因在于text是个const对象。std::move作用于const对象时,返回值是个const限定的右值。而std::string构造函数的签名却大概是如下这样的:

    1
    2
    3
    4
    5
    6
    7
    class string {                          // typedef for std::basic_string<char>
    public:
    std::string(const string& rhs); // copy ctor
    std::string(string&& rhs); // move ctor

    // ...Other code
    };

    const std::string的右值不能传给rvalue reference to a non-const std::string(即不会调用移动构造函数),却可以传给lvalue-reference-to-const(即会调用拷贝构造函数)。因为移动一个对象会改变它的某些值,所以c–不允许const对象传给可能会改变它们值的函数。

    所以,有两个教训:

    • 如果你想要移动某个对象,不要将它声明为const。否则实际将会引发copy操作。
    • std::move不移动任何东西。它只是保证它转化的对象将会有资格被移动,即被转化成右值。

std::forward

  • std::forward的转化只在某些特定情况下工作。它最常用的场景是在universal reference作为参数的函数模板(function template)中,用于将参数传给另一个函数。比如说:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    void process(const Widget& lvalArg);
    void process(Widget&& rvalArg);

    template<typename T>
    void logAndProcess(T&& param)
    {
    auto now = std::chrono::system_clock::now();
    makeLogEntry("Calling 'process'", now);
    process(std::forward<T>(param));
    }

    Widget W;
    logAndProcess(w); // (1) call with lvalue
    logAndProcess(std::move(w)); // (2) call with rvalue

    试想一下,如果没有std::forward会如何。param会被推断成左值或者右值的引用(详情见第一章)。但是,无论如何,param本身都是一个左值,所以调用process的参数匹配的时候,都会调用参数是lvalue reference的重载版本。我们需要一个机制,只有当传给param的对象是右值的时候,将param从rvalue reference(是左值)转换右值。事实上,这就是上述提到的希望std::forward工作的特定情况。

    BTW,你可能会奇怪std::forward如何区分传给它的对象被初始化之前的类型。秘密就藏在模板参数T里面。后面Item 28会详细讲。

    事实上,某些时候,我们可以用std::forward来代替std::move

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    class Widget {
    public:
    Widget(Widget&& rhs)
    : s(std::move(rhs.s)) { }
    // 等价于下面这么写
    Widget(Widget&& rhs)
    : s(std::forward<std::string>(rhs)) { }
    private:
    std::string s;
    };

    注意到std::forward的模板参数是std::string,这可以让它的返回值是右值。但是这么写很不方便。所以还是用std::move来得好。


Item 24

  • univsersialcv reference可以绑定到任意类型,const或volatile限定的rvalue或lvalue。通常有两个运用的地方,并且都伴随着类型推断:

    • 模板中:

      1
      2
      template <typename T>
      void f(T&& param); // param is a universal reference
    • auto 表达式

      1
      auto&& var2 = val1;         // var2 is a universal reference
  • 如果加了const,就会绑定到rvalue reference

    1
    2
    template<typename T>
    void f(const T&& param); // param is a rvalue reference
  • 不要在模板里看到T&&就以为是universal reference。比如在vector中:

    1
    2
    3
    4
    5
    6
    7
    8
    template<typename T, class Allocator = allocator<T>>
    class vector {
    public:
    void push_back(T&& x); // x is a reference to rvalue

    template<class... Args>
    void emplace_back(Args&&... args); // args are universial references
    }
  • C++14的lambda表达式可以声明 auto&& 参数:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    auto timeFuncInvocation = 
    [](auto&& func, auto&&... params)
    {
    //start timer;
    std::forward<decltype(func)>(func)(
    std::forward<decltype(params)>(params)...
    )
    //stop timer and record elapased time
    }
  • universal reference只是一层抽象,背后的原理是 reference collapsing(引用折叠)。


Item 25

  • 合理运用universal reference可以有许多好处。考虑下以下两端代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    // With universial reference
    class Widget{
    public:
    template<typename T>
    void setName(T&& newName)
    {
    name = std::forward<T>(newName);
    }
    // ... Other codes

    private:
    std::string name;
    }

    // With two override functions
    class Widget{
    public:
    void setName(const std::string& newName)
    {
    name = newName;
    }

    void setName(std::string&& newName)
    {
    new = newName;
    }
    // ... Other codes

    private:
    std::string name;
    }

    前者就比后者好一些。首先,后者增加了源代码的编写以及要维护的代码量。其次,它可能更加低效。比如说:

    1
    w.setname("cyyzero");

    使用universal reference版本,那么它被转发给std::string,并且只调用 std::string 的一次赋值函数。而对于两个重载版本的setName,将会创建一个临时的std::string对象,然后临时对象移动,最后析构。Last but not least,对于后者不仅源代码体积膨胀和执行期的效率低下,而且它是一种可拓展性很差的设计。可能需要重载的数量会很多。

  • 在某些情况下,你想要用绑定到 rvalue reference 或者 universal reference 的值多次,那么在最后一次才用std::move或者std::forward.

  • RVO, 即return value optimization,是指编译器对于返回值需要copy 的情况进行了优化,让copy避免。通常当local对象和返回值类型相同,并且这个local对象被返回,则会进行RVO优化。如果不符合消除拷贝的条件,返回值会被视为右值。所以对于return std::move(ret);这样试图对返回局部变量进行优化的情况,属于画蛇添足。它不会帮到编译器,还可能阻碍了优化(因为所返回对象的类型变成了rvalue reference)。

  • 对于返回值是传值,但返回的对象是左值或者右值引用的情况,那么把 std::move 用在右值引用上, std::forward 用在 universal reference 上。


Item 26

  • 对参数是 universal reference 的模板函数的重载,将会导致几乎都只调用参数是 universal reference 的版本。

  • 完美转发的构造函数一般会导致很多问题,因为对于参数是non-const lvalue的拷贝构造,和它的继承类调用它的拷贝或移动构造函数的时候,都会调用它。

  • 总而言之,尽量别对参数是universal reference的函数重载。


Item 27

对于上述的问题,有一些办法可以解决。

  • 放弃重载。定义多个不同的函数。

  • 通过const T&传参数。缺陷就在于效率可能会降低(因为不能move)。但是有时候选择放弃效率带来代码的简单,不失为一种方案。

  • 传值。这可能是反直觉的,因为传值意味着拷贝对象,很多时候都会带来很大的开销。但是知道需要拷贝的时候,就可以考虑传值,然后再将副本move。

  • Tag dispath(标签分派)。函数A参数设置为universal reference,然后通过对参数类型的分类,分派给其他函数。举个例子:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    std::vector<std::string> names;

    std::string nameFromIdx(int idx);

    void logAndAddImpl(int idx, std::true_type)
    {
    logAndAdd(nameFromIdx(idx));
    }

    template<typename T>
    void logAndAddImpl(T&& name, std::false_type)
    {
    auto now = std::chrno::system_clock::now();
    log(now, "logAndAdd");
    names.emplace(std::forward<T>(name));
    }

    template<typename T>
    void logAndAdd(T&& name)
    {
    logAndAddImpl(
    std::forward<T>(name),
    std::is_integral<typename std::remove_referene<T>::type>()
    );
    }
  • 约束接收universal reference作为参数的模板。这是后需要用到std::enable_if。它的工作原理基于SFINAE。配合type traits,可以出色地完成工作:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    class Person
    {
    public:
    template
    <
    typename T,
    typename = typename std::enable_if
    <
    !std::is_base_of
    <
    Person,
    typename std::decay<T>::type
    >::type
    &&
    !std::is_integeral<typename std::remove_reference<T>::type>::value
    >::type
    >
    Person(T&& t)
    :name(std::forward<T>(t)) { }

    Person(int Idx)
    :name(nameFromIdx(Idx)) { }

    private:
    std::string name;
    }

    不太好理解的地方:std::enable_if,std::is_base_of,std::decay,std::is_integeral.

  • 权衡。使用universal reference通常能减小开销,但是随之而来的也有许多劣势。一来有些参数不能玩么转发。二来有时候完美转发后的出错信息可读性差。比如说上面的例子,传给Person的参数不能构造std::string,此时的报错将很感人。所以最好用static_assert确定它时候符合要求。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    class Person
    {
    public:
    template
    <
    typename T,
    typename = typename std::enable_if
    <
    !std::is_base_of
    <
    Person,
    typename std::decay<T>::type
    >::type
    &&
    !std::is_integeral<typename std::remove_reference<T>::type>::value
    >::type
    >
    Person(T&& t)
    :name(std::forward<T>(t))
    {
    static_assert
    (
    std::is_constructible<std::string, T>::value,
    "Parameter n can;t be usedd to construct a std::string"
    )
    }


    private:
    std::string name;
    }

Item 28

C++禁止声明引用的引用。但是编译器可能在特定上下文,以及模板实例化的时候遇到它们。这时候,引用折叠(reference collapsing)就派上用场了。引用折叠大概的规则就是:如果有一个是左值引用,结果就是左值引用。否则(即都是右值引用),结果为右值引用

  • T&& && => T&&
  • T&& & => T&
  • T& && => T&
  • T& & => T&

引用折叠主要在四种情况中出现:

  • 模板实例化。其实主要就是参数是universal reference的函数模板实例化的时候。universal reference配上std::forward,主要就是引用折叠的规则才使得完美转发成为可能。比如说有个完美转发的例子:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    //std::forward的简单实现
    template<typename T>
    T&& forward(typename remove_refrence<T>::type& param)
    {
    return static_cast<T&&>(param);
    }

    template<typename T>
    void f(T&& fParam)
    {
    someFunc(forward<T>(fParam));
    }

    //返回Widget对象的工厂函数
    Widget getWidget();
    int main()
    {
    Widget w;
    f(w); // 情况(1)
    f(getWidget()); // 情况(2)
    }

    对于情况(1),f函数和forward函数会被推断成这样:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    void f(Widget& && fParam)
    {
    someFunc(forward<Widget&>(fParam));
    }

    Widget& && forward(Widget &param)
    {
    return static_cast<Widget& &&>(param);
    }

    正是由于引用折叠的存在,它事实上是这样的:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    void f(Widget& fParam)
    {
    someFunc(forward<Widget&>(fParam));
    }

    Widget& forward(Widget &param)
    {
    return static_cast<Widget&>(param);
    }

    函数返回左值引用,返回值是个左值。所以 forward 返回值是 Widget 左值。于是完美转发成功。

    对于情况(2), f 函数和 forward 函数会被推断成这样:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    void f(Widget&& fParam)
    {
    someFunc(forward<Widget>(fParam));
    }

    Widget&& forward(Widget& param)
    {
    static_cast<Widget&&>(param);
    }

    *函数返回右值引用,返回值是 xvalue (一种右值)*。所以forward的返回值是 Widget 右值。完美转发也成功了。

    ps:一开始我对于在f函数体内somefunc函数匹配规则不太理解,不太理解引用作为参数模板怎么推断。后来查了下,从函数调用推导模板参数 P 的时候,若 P 是引用类型,则用 P 所引用的类型推导。那么一切问题都迎刃而解了。

  • auto类型推导的时候。这个也不难想到,因为 auto 推导规则和模板类型推导差不多(见 Item 3)。还是来看一些例子:

    1
    2
    3
    4
    5
    auto&& wi = w;
    // 等价于 Widget& && w1 = w; => Widget &w1 = w;

    auto&& w2 = getWidget();
    // 等价于 Widget&& w2 = getWidget();
  • typedefalias declarations。在模板中声明等价类型的时候,也经常运用引用折叠。

    1
    2
    3
    4
    5
    template<typename T>
    class Widget
    {
    typedef T&& RvalueRefToT; // RvalueRefToT类型可能和预期的右值引用不太一样,由于T类型未知,如果发生引种折叠,它还可能是左值引用
    }
  • decltype。当使用decltype配合引用的时候,也可可能发生引用折叠。

最后总结:universal reference能工作就是依靠两点,一是类型推导能区分左值和右值,二是引用折叠的作用。


Item 29

  • 有时候,移动不一定比拷贝快。举个例子:

    • std::arraystd::array 的数据不会存放在堆上,基本就是内置数组类型的简单封装。所以移动也是O(n)复杂度。
    • 很多 std::string 的实现都采用了 *small string optimization(SSO)*。比较短的字符串可能不会在存在堆上。这样移动的话效率也不会比拷贝高。
  • 有些时候,为了保证老代码在C++98下的强异常安全,如果有移动操作但移动操作没有声明 noexcept,编译器也会选择采用拷贝操作。


Item 30

1
2
3
4
5
template<typename... T>
void fwd(T&&... params)
{
f(std::forward<T>(params)...);
}

这是一个很简答的完美转发的例子。但是实际上,它有时候并非如“完美”。在以下情况中完美转发会失败:

  • 编译器无法推断类型。
  • 推断的类型并非预期。这可能会造成编译失败,或者匹配重载函数的其他版本。

当参数比较特殊的收就很容易出错。举一些例子:

  • Branced initializers 。假设 f 声明为如下:

    1
    2
    3
    void f(const std::vector<int>& v);
    f({1,2,3}); // Ok, 列表隐式转化为向量
    fwd({1,2,3}); // 编译失败

    这是因为如果直接调用 f ,编译器将会对实参和形参类型匹配,但必要的时候会进行隐式转化。而通过 fwd 调用的时候,标准规定不能推断表达式 {1,2,3} 的类型(std::initializer_list 是 “non-deduced context”)。只有fwd的参数被声明为 std::initializer_list的时候,才可以这么传参数。而 auto 就不受这个限制了,可以这么写:

    1
    2
    auto il = {1,2,3};
    fwd(il); // It works.
  • 0 或者 NULL。很可能会被腿短成int类型,而非空指针。所以用 nullptr 代替它们。

  • 只有声明的 static const 成员变量。建议写上定义(不要重复初始化)。否则可能会在参数是引用的函数传参时链接失败。

  • 重载函数的名字或者模板的名字。这通常对于 f 参数是函数指针的时候,给 f 传重载函数的函数名,编译器会选择签名相同函数传指针过去。而对于 fwd ,来说,如果给它的参数是重载函数或者模板的名字,就无法推断,编译出错。解决的办法就是先定义一个函数指针,然后再作为参数传递给 fwd

  • 位域(Bitfields)。因为位域的成员是无法引用的,所以将它作为参数传递给 fwd 就会出问题。(原因在于位域的成员可能是任意的bit,比如说一个32位int类型的第3到第5个bit,这就无法寻址)。