zoukankan      html  css  js  c++  java
  • C++ 11中的右值引用

    C++ 11中的右值引用

    左值引用

    C++中,有一个C语言没有的概念叫做引用,也就是

    int i = 10;
    int& j = i;
    

    所谓引用,可以理解成指针常量,及它的指向无法更改,在初始化时便被确定下来,但可以修改地址中的内容。指针与引用还是有差别的,但本文不予以说明,具体可以参考百度

    const int* p1 = &a;		//常量指针,p1与p2等价,个人偏爱p1
    int const* p2 = &b;		//常量指针
    int *const p3 = &c;		//指针常量,引用
    

    右值引用

    int&& i = 10;
    

    此即为右值引用,绑定了纯右值10,而非为引用的引用。右值引用在C++ 11中被加入,主要是为了解决两个问题

    1. 临时对象的非必要的拷贝操作
    2. 模板函数中的非完美转发

    首先在C++ 11后,值分为左值,将亡值与纯右值三种。像是非引用的返回的临时变量,运算表达式产生的临时变量,lambda表达式等等都属于纯右值,纯右值在表达式结束后将会被销毁。而std::move中的参数(或是将要被移动的对象)等,都属于将亡值

    临时对象的非必要的拷贝操作

    临时对象

    临时对象便是上文所说到的纯右值了。不可见的匿名对象(不出现在我们的源码当中)的临时对象通常出现在以下两种情况

    1. 传递函数参数时发生的隐式转换
    2. 函数返回值对象时
    传递函数参数时发生的隐式转换
    int GetLength(const std::string& str);
    char c[];
    //调用上面的函数
    std::cout << GetLength(c) << std::endl;
    

    应该一眼就能看出,在将c传入GetLength函数中时发生了一次隐式转换。具体的来说,编译器会生产出一个std::string的临时变量(暂且称作s1),然后调用s1的构造函数,以c为参数进行构造,然后再将s1作为函数参数传入GetLength中。也就是说GetLength中的str将绑定在这个临时变量s1上,直到函数返回时销毁。这种转换只会出现在函数参数以值传递或以常量引用(reference - to - const)传递上

    int GetLength(std::string str);
    int GetLength(const std::string& str);
    

    那么为什么必须是reference - to - const而不是reference - to - non - const呢

    int GetLength(std::string& str);
    char c[];
    //std::cout << GetLength(c) << std::endl;	//编译器报错,非const引用传递参数不能完成隐式转化
    

    回忆一下值传递和引用传递,后者是可以在函数体内更改原对象的值的。对于隐式转换来说,参数str将会绑定在一个临时变量上,而我们在函数中做出的修改是在这个临时变量上的,与原对象没有任何关系,像是一头替罪羊。这就是C++中禁止reference - to - non - const参数产生临时变量的原因

    函数返回值对象时

    当函数返回一个值对象的时候,会创建一个该对象的拷贝,然后再将该值return回去,这种情况一般是无法避免的,但是现代编译器会采用返回值优化策略(Return Value Optimization)

    MyClass GetMyClass() {
        MyClass temp = MyClass();
        return temp;
    }
    MyClass GetMyClass_Directly() {
        return MyClass();
    }
    auto a = GetMyClass_Directly();
    
    //题外话
    //MyClass Myc1 = GetMyClass();		//对Myc1拷贝构造
    //MyClass Myc2;
    //Myc2 = GetMyClass();				//对Myc2拷贝赋值 
    

    第一个函数会创建一个左值来接受一个临时变量,再将该左值返回,第二个函数则直接返回一个临时变量。作为一名合格的程序员,抛开RVO不谈,应该能很直观的看出第二个函数的性是高于第一个的,因为避免了一次拷贝构造函数以及析构函数的调用

    以C++的角度来看,GetMyClass_Directly以值传递的方式返回了一个MyClass对象。也就是说,再return这行代码中,先调用了MyClass的构造函数,构建了一个临时对象(c1),然后再把c1拷贝到另一块临时对象c2上,这时函数将保存好t2的地址(存放在eax寄存器中),返回。返回后GetMyClass_Directly的栈区间被撤销(离开了作用域,此时t1的生命周期结束,被析构)。此时a接受到了t2的地址,根据地址找到了t2这个临时对象,接着利用t2进行拷贝构造,最后构造完成,再调用t2的析构函数将其销毁。

    //调用构造函数构建c1
    //调用拷贝构造函数构建c2
    //调用析构函数销毁c1
    
    //调用拷贝构造函数构建a
    //调用析构函数销毁c2
    
    //调用析构函数销毁a
    

    可以看得出来,c1,c2这两个临时变量的存在是十分不必要的,他们为了构建a而被生成,在构建结束之后又被销毁。为什么不能直接把MyClass构建出来的对象直接交给a呢?这其实就是RVO正在做的事情,编译器在设法提高C++的性能

    MyClass GetMyClass_Directly() {
        return MyClass();
    }
    auto a = GetMyClass_Directly();
    

    首先编译器会在GetMyClass_Directly中 “偷偷” 添加一个引用传值,然后把a传进去(此时a的内存空间已经存在,但还没被开始构造,利用断点可以很清楚的看到这点),然后再用a去替换函数体中的临时对象,在函数体内部就完成了a的构造。

    也就是说在我们现在的编译器中运行上面的代码,我们只会观察一次构造函数的调用(函数内部的构建)和一次析构函数的调用(程序结束时的析构a)

    以上为RVO优化,还有一种叫做NRVO(Named Return Value Optimization,具名返回值优化)

    MyClass GetMyClass() {
        MyClass temp = MyClass();
        return temp;
    }
    auto a = GetMyClass();
    

    GetMyClass,编译器将使用NRVO策略。虽然t1已经无法避免(被我们使用左值temp接住了),但编译器会把自动把t2优化掉,优化的方法有两种,下面来看看具体的优化流程

    1. 第一种做法与RVO类似,也就是将即将被构造的a当作一个引用参数传入函数中去构造,以下是优化后的调用情况

      //调用构造函数构建一个临时对象
      //调用拷贝构造函数构造temp
      //调用析构函数销毁临时对象
      
      //调用析构函数销毁a
      
    2. 第二种涉及到汇编的层面,即就是在函数返回后,不把temp析构掉,而是直接把temp的地址存放到eax寄存器中,返回到GetMyClass的调用点上,a再用eax寄存器中的地址进行构造,构造结束之后再将temp析构。这里发现到temp已经超出了他的作用域,虽然GetMyClass这块栈已经失效,但是还没有其他内容去擦写这篇内存,所以说temp值实际上还有有效的。

      各位应该都听说过 “绝对不要返回局部变量的引用” 这一条款

      int& RF_test() {
          int a = 10;
          return a;
      }
      

      按照我们以往的了解,a在函数返回后将被销毁,引用返回后就得不到a本身了,将会得到不确定的结果

      int& a = RF_test();
      std::cout << a << std::endl;	// 10
      

      但是很奇怪的是,我们仍然能正确的在控制台得到输出。这是因为局部变量在栈空间中分配内存,函数返回时栈指针回退,此时尚可调用改内存上的值(对象虽然被销毁了,但是内存还在),而当主调函数继续调用其它被调函数时,栈指针上移,上一次函数调用所分配的空间会被本次调用覆盖,此时再引用原来的局部变量就会出现不可预见的结果。

      void NT_test() { int b = 100; }
      
      int& a = RF_test();
      NT_test();			//调用函数,使栈指针上移
      std::cout << a << std::endl;	//出现不确定结果
      

      总觉:不要返回局部变量的引用。编译器可能没有报错,但可能出于各种因素,出现不确定的结果

    在实际编程的时候,我们会发现编译器并不能保证所有的返回值都能够优化,比如

    1. 不同的返回路径上返回不同名的对象(比如if XXX 的时候返回x,else的时候返回y)
    2. 引入 EH 状态的多个返回路径(就算所有的路径上返回的都是同一个具名对象)
    3. 在内联asm语句中引用了返回的对象名
    4. ...

    也就是说RVO,NRVO等方法也不能完全解决因为函数返回对象时导致的效率问题,直到C++ 11中出现了右值引用,令其特殊情况能用std::move与std::forward解决,具体后文中会讲到。在一般情况下,若局部对象可能适用于返回值优化,那么绝对不使用std::move与std::forward(两者的成本虽然低于拷贝,但仍高于RVO,且可能会 “帮倒忙” )

    MyClass GetMyClass_Directly() {
        return MyClass();
    }
    auto&& a = GetMyClass_Directly();
    

    如果我们这次采用右值引用来接受这个返回值,同时忽略RVO,有以下结果

    //调用构造函数构建临时对象c1
    //调用拷贝构造函数构建另一个临时对象c2
    //调用析构函数销毁临时对象c1
    
    //a绑定到了c2
    //调用析构函数销毁c2
    

    右值引用会延长右值(c2)的生命周期,直到程序结束时再销毁

    临时对象到这里应该算是讲完了,右值引用除了绑定右值外,还可以用来实现移动语义

    移动语义

    移动语义可以理解为转换所有权。

    在此之前需要明确一个定义

    void f(Widget&& w);
    

    很明显的,这是一个右值引用,但是需要注意的是,形参(w)永远是左值,即使它的类型是右值引用。

    MyClass(const MyClass& Myc) : m_ptr(new int(*Myc.m_ptr)) {}				//深拷贝构造函数
    MyClass(MyClass&& Myc) : m_ptr(Myc.m_ptr), m_str(std::move(Myc.m_str)) { Myc.m_ptr=nullptr; }	
    //移动构造函数	右值引用
    

    就是类中的移动构造函数,实现了移动语义,它接受了一个右值,将它的资源所有权转到自己类中的成员上,同时该代码展示了调用std::string的移动构造函数

    auto a = GetMyClass_Directly();		//调用移动构造函数
    

    由于函数的返回值为右值,故会调用到移动构造函数。但若不实现移动语义,将会匹配到拷贝构造函数上(常量左值引用是个 “万能”的引用类型,可以接受左值、右值、常量左值和常量右值),导致不必要的拷贝操作

    std::move

    std::move本质上与移动语义(move语义)并没有什么联系,即使它叫做std::move,但是它并不会做出所谓的移动操作,它只做一件事:把实参强制转换成右值,然后返回一个右值引用

    MyClass Myc;
    auto a1 = Myc;				//调用到拷贝构造函数
    auto a2 = std::move(Myc);	 //调用到移动构造函数
    

    这里可以看出,通过std::move,对Myc做出了强制型别转换,然后交给移动构造函数去移动。应该注意的是,Myc的所有权已经被转移,若此时再访问则将会出现不确定结果。所以我们应当确保该变量不再使用了,才能将它转移。

    还有一点是,我们应该避免std::move一个常量左值,因为在经历强制转换后,const属性并不会被去掉,而是会被转换成为一个常量右值引用,导致类成员(例如m_str)移动构造函数无法调用,使得最后仍然执行了拷贝构造函数

    MyClass(const std::string text) : m_str(std::move(text)) {}		//在构建m_str时还是调用到拷贝构造
    

    模板函数中的非完美转发

    通用引用(Universal Reference)

    首先应明确两点

    1. 右值引用一定为type&&
    2. type&&不一定是右值引用,还可能是通用引用
    template<typename T>
    void func1(T&& param);		//通用引用
    
    void func2(MyClass&& Myc);	//右值引用
    
    template<typename T>
    class Test_Class {
    public:
        Test_Class(Test_Class&& tcs);  //不存在类型推导,故为右值引用
        void Test_Func(T&& param);	   
        //T在构建Test_Class的时候就已经确定了,所以在调用Test_Func的时候不存在类型推导,故为右值引用	
    };
    
    

    关键在于区分何时为通用引用何时为右值引用。当参数的类型为T&&格式时,且需要对T的类型进行推导,此即为通用引用,除了以上使用模板的例子,还有以下使用auto的情况

      MyClass Myc;
      MyClass&& Myc1 = MyClass();		//右值引用
    //MyClass&& Myc2 = Myc;				//错误,无法绑定左值
    auto&& Myc3 = Myc;				    //通用引用,被左值初始化,相当于MyClass& Myc3 = Myc;
    auto&& Myc4 = MyClass();			//通用引用,被右值初始化,相当于MyClass&& Myc4 = MyClass();
    

    总结一下

    1. 如果一个变量或参数的声明类型是T&&,并且需要推导出类型T, 为通用引用(且不能被const修饰)

    2. 通用引用是需要初始化的,如果是左值,那就是左值引用,如果是右值,那就是右值引用

    3. 经过推导的T&&类型,所发生的相较于右值引用(&&)的变化,叫做引用折叠(引用坍缩)

      引用折叠的规则如下

      1. 所有右值引用折叠到右值引用上仍然是一个右值引用。(T&& && 变成 T&&)

      2. 所有的其他引用类型之间的折叠都将变成左值引用。 (& & == & && == && & == &)

    std::forward

    正如我们知道的,std::move并未进行任何移动,同样的std::forward也不进行任何转发,两者的区别为,std::move会无条件的将实参强制转换成为右值,而std::forward仅在特定情况下才实施这样的转换

    首先我们应该了解到std::forward除了取用一个函数实参外,还需要一个模板类型实参,以用来得到其需要转换的类型

    void processVal(const MyClass& Myc);
    void processVal(MyClass&& Myc);
    
    template<typename T>
    void forwardVal(T&& param)
    {
        //Some codes..
        processVal(std::forward<T>(param));
    }
    

    前文中提到的,形参param是一个左值,而若直接当作参数传递给processVal时,必然会调用到常量引用的重载版本,这就是所谓的非完美转发。而若调用到std::forward,通过模板实参T,std::forward获取到param是通过何种类型完成初始化的。若调用到forwardVal时传入的为左值,则在调用processVal时仍为左值;若调用forwardVal时传入的为右值,则调用processVal时,std::forward会将param强制转换为右值,以调用到正确的重载版本。前文中提到的 “特定情况才实施转换” 便是这样的效果

    以上即为通过std::forward实现的完美转发,同std::move与移动语义一样,应牢牢记住std::forward只是用于类型转换,它自身与转发并无联系

    结合移动语义与完美转发,再加上一点通用引用和变参模板,我们可以实现这样的一个工厂函数

    template<typename... Args>
    MyClass* CreateIns(Args&&... arg) {
        return new MyClass(std::forward<Args>(arg)...);
    }
    

    Modern C++漫漫谈

    通过上面的例子,我们发现到std::move常出现在右值引用中,而std::forward则常出现在通用引用中,事实上,这正是Effective Modern C++中的一个条款

    如果我们偏偏不按照条款说的去做,在右值引用中使用std::forward

    MyClass(MyClass&& Myc) : m_str(std::forward<std::string>(Myc.m_str)) {}
    MyClass(MyClass&& Myc) : m_str(std::move(Myc.m_str)) {}
    

    对比一下可以发现,虽然实现的效果相同,但是调用到std::forward的明显会更加麻烦,而且若程序员错误的使用了std::string&作为模板实参,会导致构建m_str调用到的是拷贝构造。总的来说,在右值引用中使用std::forward,不仅打得字变多了,还更容易出错

    同样的来看看在通用引用中使用std::move

    template<typename T>
    void setName(T&& _name) {
        name = std::move(_name);	//假设该函数为类内成员函数,name是该类中的成员
    }
    

    这是一份很糟糕的代码,若传入函数的是一个左值,虽然该函数成功调用,但是事后该左值将变成一个不确定的值,将不能够再被调用。所以正确的方法应该是这样

    template<typename T>
    void setName(T&& _name) {
        name = std::forward<T>(_name);
    }
    

    如果我们尝试着使用重载的方法实现以上代码

    void setName(const std::string& _name) {
        name = _name;
    }
    void setName(std::string&& _name) {
        name = std::move(_name);
    }
    

    下方的代码虽然可行,但是首先我们需要编写并维护更多的代码,若有n个形参,我们将必须实现2^n个重载函数,而这个问题在通用引用方法中只需要书写一个变参模板即可解决(参考std::make_XXX的实现)。其次是当发生隐式转换的时候,效率会打折扣

    w.setName("Chen");
    

    这其实是const char*类型,而此类型将会隐式转换为一个std::string的临时变量,再将该变量进行移动赋值,最后再析构这个临时变量。

  • 相关阅读:
    python(三):python自动化测试定位
    python(二):用python抓取指定网页
    python(一):介绍网络爬虫
    oracle批量增加数据
    oracle数据库基本操作
    web安全测试要点
    linux下安装jdk、tomcat
    在linux上编译C
    linux基本操作
    对首次性能测试的总结与思考
  • 原文地址:https://www.cnblogs.com/tuapu/p/13876150.html
Copyright © 2011-2022 走看看