Effective Modern C++ 读书笔记(四):右值引用、移动语义和完美转发

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

条款 23:std::move 与 std::forward

  • std::move 执行的是无条件的向右值类型的强制类型转换,就其本身而言并不会执行移动操作。

    • 下面是 std::move 在 C++ 11 与 C++ 14 中的实现:

       1
       2
       3
       4
       5
       6
       7
       8
       9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      
      //C++11
      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
      template<typename T>
      decltype(auto) move(T&& param)          //C++14,仍然在std命名空间
      {
          using ReturnType = remove_referece_t<T>&&;
          return static_cast<ReturnType>(param);
      }
      

      可以看到,std::move 只是简单的将类型原本的引用属性去掉,再将其强转为右值版本后返回,本身并不存在移动操作。

  • std::forward 仅当传入的实参被绑定到右值时,才会将参数强制转换为右值类型。

    • 与 std::move 的无条件转换不同,std::forward 是有条件的转换:仅当参数是使用右值完成初始化时,它才会执行向右值类型的强制转换(通过解码模板参数 T 来分辨是左值还是右值)。
  • 在运行期,std::move 与 std::forward 都不会做任何操作。

条款 24:万能引用与右值引用

  • 如果函数模板形参具备 T&& 类型,且 T 的类型是推导得来的,又或者对象使用 auto&& 声明类型(必定会类型推导),则表示这个形参或者对象是一个万能引用。

    • 例如:

      1
      2
      3
      4
      5
      6
      
      //函数模板形参具备T&&类型,且T的类型是推导得来的
      template<typename T>
      void f(T&& param);                  //param是一个万能引用
      
      //使用 auto&& 声明类型
      auto&& var2 = var1;                 //var2是一个万能引用
      
  • 如果类型声明不是标准的 type&&,或者类型推导没有发生,则 type&& 就代表右值引用。

    •  1
       2
       3
       4
       5
       6
       7
       8
       9
      10
      11
      12
      13
      14
      15
      16
      
      template <typename T>			//加上了const属性,不是标准的type&&
      void f(const T&& param);        //param是一个右值引用
      
      void f(Widget&& param);         //没有类型推导,
                                      //param是一个右值引用
      
      Widget&& var1 = Widget();       //没有类型推导,
                                      //var1是一个右值引用
      
      template<class T, class Allocator = allocator<T>>   
      class vector
      {
      public:							//需要使用一个类型来实例化模板类,不存在类型推导
          void push_back(T&& x);		//右值引用
          
      }
      
  • 如果用一个右值来初始化万能引用,则会得到一个右值引用。同理,如果使用左值来初始化万能引用,就会得到一个左值引用。

条款 25:对右值引用使用 std::move,对万能引用使用 std::forward

  • 在最后一次使用时,对右值引用使用 std::move,对万能引用使用 std::forward。

    • 当转发右值引用给其他函数时,应当对它使用向右值的无条件转换(std::move),因为它们一定绑定到右值。而转发万能引用时,应该对它使用向右值的有条件转换转换(std::forward),因为它们不一定绑定到右值。
  • 对按值返回的函数,无论是右值引用和万能引用,都按照上一条采取相同的行为。

  • 若局部变量适用于返回值优化时,避免使用 std::move 或者 std::forward。

    • 在 C++ 中有返回值优化(return value optimization,RVO)机制,可以直接在函数返回值分配的内存上创建的临时对象,然后可以达到少调用拷贝/移动构造的操作目的。

      但是使用这个机制有两个前提:

      1. 局部对象类型和函数返回值类型相同。
      2. 返回的就是局部变量本身。

      而 std::move 和 std::forward 会将返回值变为该局部变量的引用,此时不满足上述的前提,因此限制了编译器优化。

条款 26:避免用万能引用类型进行重载

  • 把万能引用作为重载的参数,总是会让该重载版本在始料未及的情况下被调用。

    • 万能引用在具限过程中几乎能够和任何实参类型产生精确匹配,一旦将万能引用作为重载的参数,它们就会在预期之外的情况下进行调用,例如:

       1
       2
       3
       4
       5
       6
       7
       8
       9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      
      template<typename T>
      void logAndAdd(T&& name)
      {
          auto now = std::chrono::system_lock::now();
          log(now, "logAndAdd");
          names.emplace(std::forward<T>(name));
      }
      
      void logAndAdd(int idx)             //新的重载
      {
          auto now = std::chrono::system_lock::now();
          log(now, "logAndAdd");
          names.emplace(nameFromIdx(idx));
      }
      
      logAndAdd("Patty Dog");                 //T&&重载版本
      
      logAndAdd(22);                          //调用int重载版本
      
      short nameIdx;
                                             //给nameIdx一个值
      logAndAdd(nameIdx);                     //错误!
      

      在上面的代码中,我们传递了一个 short 类型的 index,我们的预期是将他匹配到同为整数类型的 int 版本上,但由于 short 类型在转换为 int 类型时需要进行隐式类型转换(整型提升)后才能匹配,此时就会精准的匹配到万能引用版本上,从而导致报错。

  • 完美转发构造函数的问题尤其严重,因为对于非常量的左值类型而言,它门不会调用拷贝构造,而是调用完美转发构造,并且还会劫持派生类中对基类的拷贝和移动构造的调用。

    • 对于非常量的左值类型而言,它门不会调用拷贝构造,而是调用完美转发构造,例如下面的代码:

       1
       2
       3
       4
       5
       6
       7
       8
       9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      
      class Person {
      public:
          template<typename T>            //完美转发的构造函数
          explicit Person(T&& n)
          : name(std::forward<T>(n)) {}
      
          explicit Person(int idx);       //int的构造函数
      
          Person(const Person& rhs);      //拷贝构造函数
          Person(Person&& rhs);           //移动构造函数
          
      };
      
      Person p("Nancy"); 
      auto cloneOfP(p);                   //调用完美转发构造;报错!
      
      const Person cp("Nancy");   //现在对象是const的
      auto cloneOfP(cp);          //调用拷贝构造函数!
      

      在使用非常量左值类型时,如果要调用拷贝构造函数,就需要为该类型添加 const 属性,而完美转发构造函数却不要求添加任何饰词,因此是精准匹配,此时就会调用完美转发构造函数。

    • 完美转发构造函数会劫持派生类中对基类的拷贝和移动构造的调用,例如下面的代码:

       1
       2
       3
       4
       5
       6
       7
       8
       9
      10
      
      class SpecialPerson: public Person {
      public:
          SpecialPerson(const SpecialPerson& rhs) //拷贝构造函数,调用基类的
          : Person(rhs)                           //完美转发构造函数!
          {  }
      
          SpecialPerson(SpecialPerson&& rhs)      //移动构造函数,调用基类的
          : Person(std::move(rhs))                //完美转发构造函数!
          {  }
      };
      

      这里也是一样,由于我们将派生类对象传递给了基类的构造函数,而如果要想使用基类的拷贝/移动构造,就需要进进行对象切割,而完美转发构造函数则可以直接进行精准匹配,因此调用会被劫持。

条款 27:替代万能引用类型进行重载的方案

  • 如果不使用万能引用和重载的组合,则替代方案包括使用不同的函数名、传递 const T& 类型的形参、按值传参、标签分派。

    • 使用不同的函数名

      • 最简单的方法就是舍弃重载,将重载的不同版本重新命名为不同名字的函数,这样就可以避免万能引用劫持匹配。
    • 传递 const T& 类型的形参

      • 回归 C++ 98 的写法,放弃高效率而选择保持代码逻辑的简洁,继续使用 const T& 类型。
    • 按值传参

      • 把传递的参数类型从引用类型改为值类型,代码如下:

         1
         2
         3
         4
         5
         6
         7
         8
         9
        10
        11
        12
        
        class Person {
        public:
            explicit Person(std::string n)  //代替T&&构造函数,
            : name(std::move(n)) {}         //std::move的使用见条款41
        
            explicit Person(int idx)        //同之前一样
            : name(nameFromIdx(idx)) {}
            
        
        private:
            std::string name;
        };
        
    • 标签分派

      • 如果既不想放弃重载,又不想放弃万能引用,那么我们可以采用标签分派的方式来重写代码,即使用一个标签来辨别该调用哪种类型的重载函数。我们可以使用类型萃取的方式,根据不同的类型生成不同的标签,从而调用不同的重载函数。代码如下:

         1
         2
         3
         4
         5
         6
         7
         8
         9
        10
        11
        12
        13
        14
        15
        16
        17
        18
        19
        20
        21
        22
        
        template<typename T>
        void logAndAdd(T&& name)
        {
            logAndAddImpl(
                std::forward<T>(name),
                std::is_integral<typename std::remove_reference<T>::type>()
            );
        }
        
        template<typename T>                           
        void logAndAddImpl(T&& name, std::false_type)	//std::false_type
        {
            auto now = std::chrono::system_clock::now();
            log(now, "logAndAdd");
            names.emplace(std::forward<T>(name));
        }
        
        std::string nameFromIdx(int idx);           
        void logAndAddImpl(int idx, std::true_type) //std::true_type
        {
          logAndAdd(nameFromIdx(idx)); 
        }
        
  • 经由 std::enable_if 对模板施加限制,就可以将万能引用和重载一起使用,std::enable_if 控制了编译器调用到接受万能引用的重载版本的条件。

    • 通过这种方式,我们就能够解决条款 26 中提到的使用完美转发构造函数的几个问题:

      • 对于非常量的左值类型而言,它门不会调用拷贝构造,而是调用完美转发构造。解决方法如下,我们可以使用 std::decay 来去掉参数原有的引用、const、volatile 饰词,消除隐式转换的可能,再以 std::is_same 来判断类型是否相同,来达到精确匹配。代码如下:

         1
         2
         3
         4
         5
         6
         7
         8
         9
        10
        11
        12
        13
        
        class Person {
        public:
            template<
                typename T,
                typename = typename std::enable_if<
                               !std::is_same<Person, 
                                             typename std::decay<T>::type
                                            >::value
                           >::type
            >
            explicit Person(T&& n);
            
        };
        
      • 完美转发构造函数会劫持派生类中对基类的拷贝和移动构造的调用。解决方案如下,我们可以使用 is_base_of 来区分对待派生类和基类对象:

         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
        
        //C++11
        class Person {
        public:
            template<
                typename T,
                typename = typename std::enable_if<
                               !std::is_base_of<Person, 
                                                typename std::decay<T>::type
                                               >::value
                           >::type
            >
            explicit Person(T&& n);
            
        };
        
        //C++14,可以使用别名模板简化代码
        class Person  {                                         
        public:
            template<
                typename T,
                typename = std::enable_if_t<                    
                               !std::is_base_of<Person,
                                                std::decay_t<T> 
                                               >::value
                           >                                   
            >
            explicit Person(T&& n);
            
        };
        
  • 万能引用形参在性能方面具备优势,但是在易用性方面则具有劣势。

条款 28:引用折叠

  • 引用折叠会在四种情况下发生:模板实例化、auto 类型推导、typedef 和别名声明、decltype。

  • 当编译器在引用折叠的情况下生成引用的引用时,通过引用折叠会变成单个引用。如果原始的引用中有左值引用,则结果位左值引用。反之,则为右值引用。

    •  1
       2
       3
       4
       5
       6
       7
       8
       9
      10
      11
      
      Widget widgetFactory();     //返回右值的函数
      Widget w;                   //一个变量(左值)
      func(w);                    //用左值调用func;T被推导为Widget&
      func(widgetFactory());      //用又值调用func;T被推导为Widget
      
      auto&& w1 = w;
      Widget& && w1 = w;	//等价于上一条,auto推导出Widget&
      Widget& w1 = w		//引用折叠后为左值 
      
      auto&& w2 = widgetFactory();
      Widget&& w2 = widgetFactory()	//等价于上一条,auto推导出Widget,为右值
      
  • 万能引用通过类型推导区分左值和右值,以及会发生引用折叠情况下的右值引用。

    • 万能引用的类型推导会区分左值和右值。T 类型的左值会推导为 T&,而 T 类型的右值会推导为 T 类型。接着会发生引用折叠。

条款 29:假定移动操作不存在、成本高、不可用

  • 假定移动操作不存在、成本高、不可用。

    • 在下面这几个场景中,C++ 11 的移动语义并不会有任何优势:
      1. 没有移动操作。待移动对象不支持移动操作,因此移动请求就变为拷贝请求。
      2. 移动操作成本高。移动操作的成本不比拷贝操作更快。
      3. 移动操作不可用。要求移动操作不可抛出异常,但该操作没加上 noexcept。
  • 对于那些类型或者移动语义的支持情况已知的代码,则无需做以上假定。

条款 30:完美转发的失败情形

  • 完美转发的失败原因是源于模板类型推导失败,或是推导类型错误。

    • 完美转发会在下面两个条件中的任何一个成立时失败:

      1. 编译器无法为一个或者多个转发函数模板形参推导出类型。这种情况下代码无法通过编译。
      2. 编译器为一个或多个转发函数模板的形参推导出了错误的结果。这种情况即可以指转发函数模板根据类型推导结果的实例化无法通过编译,也可以指转发函数模板推导出的类型与直接传递给转发函数模板的实参调用的行为不一致。

      例如下面这些代码:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      
      template<typename... Ts>
      void fwd(Ts&&... params)            //接受任何实参
      {
          f(std::forward<Ts>(params)...); //转发给f
      }
      
      f( expression );        //调用f执行某个操作
      
      fwd( expression );		//但调用fwd执行另一个操作,则fwd不能完美转发expression给f
      
  • 导致完美转发失败的实参种类有大括号初始化;以 0 或者 NULL 表示的空指针;仅有声明的整型 static、const 数据成员;模板或重载函数的名字;位域。

    • 大括号初始化

      • 如下面的代码,由于 fwd 的形参并未声明为 std::initializer_list<T>,因此在使用大括号初始化时,编译器就会被禁止在 fwd 的调用过程中从表达式 {1,2,3} 中推导出类型,从而导致编译错误。

        1
        2
        3
        4
        5
        
        void f(const std::vector<int>& v);
        
        f({ 1, 2, 3 });         //可以,“{1, 2, 3}”隐式转换为std::vector<int>
        
        fwd({ 1, 2, 3 });       //错误!不能编译
        

        这有一个简单的方法可以转发大括号初始化,即先用一个 auto 变量来接收初始化表达式,接着再将这个变量转发给函数:

        1
        2
        
        auto il = { 1, 2, 3 };  //il的类型被推导为std::initializer_list<int>
        fwd(il);                //可以,完美转发il给f
        
    • 以 0 或者 NULL 表示的空指针

      • 使用 0 或者 NULL 表示空指针时会被推导为整数类型而不是所传递实参的指针类型。
    • 仅有声明的整型 static、const 数据成员

      • C++ 允许我们只声明一个 static、const 成员而不去定义它。因为编译器会根据这些成员的值实行常量传播,从而不必再为它们保留内存。但是由于引用与指针的本质相同,都需要获取到对象的地址,而这些没有存储在内存中的对象自然没有地址,如果之后没有人去定义这些变量,则它们会在链接时报错。
    • 模板或重载函数的名字

      • 当向转发函数模板传递模板/重载函数名时,由于它没有任何关于类型的信息,这就导致编译器无法决定选择调用哪个重载版本。

        1
        2
        3
        4
        5
        6
        
        void f(int pf(int));
        int processVal(int value);
        int processVal(int value, int priority);
        
        f(processVal);                      //可以
        fwd(processVal);                    //错误!那个processVal?
        

        要想适用于转发函数模板,就得将函数名转换为对应类型的函数指针,再将这个明确类型的函数指针传递给转发函数模板。

        1
        2
        3
        4
        5
        6
        7
        
        using ProcessFuncType =                         //写个类型定义
            int (*)(int);
        
        ProcessFuncType processValPtr = processVal;     //指定所需的processVal签名
        
        fwd(processValPtr);                             //可以
        fwd(static_cast<ProcessFuncType>(workOnVal));   //也可以
        
    • 位域

      • C++ 标准规定非 const 引用无法绑定到位域。因为位域是由机器字的若干个任意部分组成的,这样的实体无法直接对其取址,而引用与指针的本质相同,既然无法创建指向任意比特的指针,自然也无法创建这样的引用。

         1
         2
         3
         4
         5
         6
         7
         8
         9
        10
        11
        12
        13
        14
        15
        
        struct IPv4Header {
            std::uint32_t version:4,
                          IHL:4,
                          DSCP:6,
                          ECN:2,
                          totalLength:16;
            
        };
        
        void f(std::size_t sz);         //要调用的函数
        
        IPv4Header h;
        
        f(h.totalLength);               //可以
        fwd(h.totalLength);             //错误!
        

        如果一定要转发位域也不是不行,我们可以通过强制类型转换,将位域的值提取出来:

        1
        2
        3
        4
        
        //拷贝位域值;
        auto length = static_cast<std::uint16_t>(h.totalLength);
        
        fwd(length);                    //转发这个副本
        
Built with Hugo
主题 StackJimmy 设计