美文网首页C++C++2.0
C++11新特性--右值引用与移动语义

C++11新特性--右值引用与移动语义

作者: 于天佐 | 来源:发表于2018-02-01 16:47 被阅读82次

    引子-深拷贝和浅拷贝

        在cpp11之前,我们定义一个类如果类中有指针成员,并且其指向一块堆内存,那么往往本类要负责这个指针指向内存的分配和销毁,不然会产生令人讨厌的内存泄露问题。但如果这个类的对象之间进行复制就会涉及到数据的拷贝问题,如果不加处理,使用编译器默认生成的拷贝构造函数,那么默认的行为就是按bit进行memory copy,这就是所谓的浅拷贝。浅拷贝的后果是两个对象持有两个不同的成员指针,两个指针指向同样的堆内存,如果其中一个销毁了内存,另一个再用,就会产生无法预知的错误,导致程序错误。所以针对这种情况我们往往要提供自定义版本的拷贝构造函数,来确保指针指向的内存也有一份拷贝。可以看一下代码示例:

    class PtrMemTest
    {
    public:
        int* p;
        PtrMemTest(int value)
        {
            p = new int(value);
        }
        ~PtrMemTest()
        {
            if (p)
            {
                delete p;
                p = nullptr;
            }
        }
        PtrMemTest(const PtrMemTest& obj)
        {
            p = new int(*obj.p);
        }
    };
    

        如果我们不定义拷贝构造函数,那么就会引起浅拷贝的问题,继而可能引起严重的程序错误。提供了拷贝构造函数之后,我们保证了程序的正确性,但是内存的频繁拷贝是一种性能开销,如果允许我们在一些情况下只是把指针的指向的内存所有权转移应该如何呢?比如下面的代码

    PtrMemTest obj2(PtrMemTest(12));
    

        这里只是用一个临时对象去初始化另一个对象,会产生两次堆内存的申请,其中一次实际完全没有必要,因为是个临时对象,完成了第二个对象的初始化之后就马上销毁了。这个例子比较极端,在平时程序中不常见,那么用一个函数返回值去初始化一个对象的场景就极为常见了。如代码

    PtrMemTest GetMemObj(int value)
    {
        return PtrMemTest(value);
    }
    void testMem()
    {
        PtrMemTest obj3(GetMemObj(13));
    }
    

        这种情况下跟上面情况类似,函数的返回值作为一个临时对象,去初始化obj3这个对象,完成使命后就自行销毁,产生了不必要或者说可以优化的内存拷贝。这种情况实际上会产生一次构造和两次拷贝构造(实际编译器会对这种情况做优化,从而不会产生额外的内存释放和销毁),一次是PtrMemTest(value)产生的构造,一次是函数GetMemObj中产生的一个临时对象作为函数的返回值,而它用刚才的对象进行构造,从而产生了一次拷贝构造,最后一次拷贝构造发生在构造obj3的时候。那么如果可以有效利用临时对象,把它们的内存“偷过来“就可以减少一次有可能成本十分昂贵的拷贝构造。在cpp11中,有了对这些场景的一个解决方案--右值引用。

    什么是右值

        在c中我们可以近似的认为赋值号左边的称为左值(lvalue)右边的称为右值(rvalue)。如

    int a = 3;
    int b = a + 5;
    

        其中a和b就是左值,3和a+5都是右值。
        还有一个比较被广泛认同的定义,可以被合法取地址的值称为左值,反之称为右值。如&a, &b都是合法表达式,所以他们都是左值;但是&3,&(a+5)都是不合法的表达式,所以他们不能取地址,进而它们即是右值。那么容易看出来函数的返回值是一个临时值,无法被取地址,所以是个右值。

    右值引用

        cpp11中右值引用就是对一个右值进行引用的类型。由于右值通常不具有名字,我们也只能通过引用的方式绑定它。如

    int&& a = ReturnIntRvalue();
    int&& b = 12;
    
    int& c = ReturnIntRvalue();//compile fail
    const int& d = ReturnIntRvalue();
    

        上面代码中前两句都是右值引用绑定右值的例子,右值引用只能绑定到右值上,否则会编译失败。值得一提的是,第三句无法编译通过,由于cpp不允许左值引用绑定到右值上,但是第四句却能编译通过,原因是const T&类型是万能类型,可以绑定到任何类型,左值,常量左值,右值。不过这里相对于右值引用,它是只读的,而右值引用是可以改变所引用的右值的值的。
    再看一下代码

    class PtrMemTest
    {
    public:
        int* p;
        PtrMemTest(int value)
        {
            std::cout<<"PtrMemTest"<<std::endl;
            p = new int(value);
        }
        ~PtrMemTest()
        {
            std::cout<<"~PtrMemTest"<<std::endl;
            if (p)
            {
                delete p;
                p = nullptr;
            }
        }
        PtrMemTest(const PtrMemTest& obj)
        {
            std::cout<<"PtrMemTest copy"<<std::endl;
            p = new int(*obj.p);
        }
    
        PtrMemTest(PtrMemTest&& obj)
        {
            std::cout<<"PtrMemTest move"<<std::endl;
            p = obj.p;
            obj.p = nullptr;
        }
    };
        static void execute()
        {
            PtrMemTest obj1(111);
            PtrMemTest obj2(std::move(obj1));
        }
    

        这里对前面的代码做了增加,增加了一个cpp11中新增的移动构造函数,它的作用正是前面我们希望的将一个右值(临时对象)的内容“偷”过来,用最小的代价来构造新的对象。move这个库函数,用来强制将一个值转换成右值。所以这里运行的结果是

    PtrMemTest
    PtrMemTest move
    ~PtrMemTest
    ~PtrMemTest
    

        obj2将自己的成员指针指向了obj1中开辟的内存,obj1此后变成了一个空对象。这里为了规避编译器的优化,所以写成了这种方式,这种方式是有风险的,因为在obj2构造后,程序就不应该再使用obj1了,否则可能出现问题。更合理的用法是

     PtrMemTest obj2(GetPtrMemObj());
    

        这样函数产生的临时返回值就会被“移动”到obj2中,从而减少了内存的分配和销毁。

    移动语义

        前面的例子所展示即为移动语义。标准库中提供了一个有用的函数std::move来强制将左值转换成右值,正如刚才例子中所展示的。有了这个标准库函数,我们可以更加灵活的按自己的需求来将左值转换成右值。比如继承一个有移动构造的基类,子类并不增加数据,只是扩展了一些函数,那么如果此时需要提供自己的版本的移动构造函数以延续父类的移动语义,这时你可能就明确需要move函数了。如下代码

    class PtrMemTest
    {
    public:
        int* p;
        PtrMemTest(int value)
        {
            std::cout<<"PtrMemTest"<<std::endl;
            p = new int(value);
        }
        ~PtrMemTest()
        {
            std::cout<<"~PtrMemTest"<<std::endl;
            if (p)
            {
                delete p;
                p = nullptr;
            }
        }
        PtrMemTest(const PtrMemTest& obj)
        {
            std::cout<<"PtrMemTest copy"<<std::endl;
            p = new int(*obj.p);
        }
    
        PtrMemTest(PtrMemTest&& obj)
        {
            std::cout<<"PtrMemTest move"<<std::endl;
            p = obj.p;
            obj.p = nullptr;
        }
    };
    
    class PtrMemTestDerive : public PtrMemTest
    {
    public:
        PtrMemTestDerive(int value) : PtrMemTest(value)
        {
            std::cout<<"PtrMemTestDerive"<<std::endl;
        }
    
        PtrMemTestDerive(const PtrMemTestDerive &obj) : PtrMemTest(obj)
        {
            std::cout<<"PtrMemTestDerive copy"<<std::endl;
        }
    
        PtrMemTestDerive(PtrMemTestDerive &&obj) : PtrMemTest(obj)
        {
            std::cout<<"PtrMemTestDerive move"<<std::endl;
        }
    
        //........
    
    };
    void test()
    {
        PtrMemTestDerive obj1(111);
        PtrMemTestDerive obj3(std::move(obj1));
    }
    

        这段代码并没有按照你所期望的行使移动语义,因为PtrMemTestDerive(PtrMemTestDerive &&obj) : PtrMemTest(obj)虽然子类中传入了右值引用,但是将obj像父类的移动构造(其实是拷贝构造)传递的时候,obj是个左值,所以并没有如预期的调用父类的移动构造函数,而是调用了父类的拷贝构造函数。所以这里move可以显示它的作用了。

    class PtrMemTestDerive : public PtrMemTest
    {
    public:
        PtrMemTestDerive(int value) : PtrMemTest(value)
        {
            std::cout<<"PtrMemTestDerive"<<std::endl;
        }
    
        PtrMemTestDerive(const PtrMemTestDerive &obj) : PtrMemTest(obj)
        {
            std::cout<<"PtrMemTestDerive copy"<<std::endl;
        }
    
        PtrMemTestDerive(PtrMemTestDerive &&obj) : PtrMemTest(std::move(obj))
        {
            std::cout<<"PtrMemTestDerive move"<<std::endl;
        }
    
        //........
    
    };
    

    新的子类用了move将obj继续作为右值传递给父类,顺利的调用父类的移动构造函数。

    std::move

        上面的示例用到了很多STL库提供的新函数move函数,前面提到过的它的作用是强制将左值转换成右值引用。注意返回的是一个右值引用,而不是右值。但是直接使用move的返回值即得到一个右值,而不是一个右值引用。比较绕,看一下STL库中的注释:
    Move as rvalue
    Returns an rvalue reference to arg.
    This is a helper function to force move semantics on values, even if they have a name: Directly using the returned value causes arg to be considered an rvalue.
    大概翻译一下就是返回一个右值引用,直接用它的返回值就被当成一个右值用。
    它的应用场景前面已经演示了,那么我们简单看一下它的实现。

    template <class _Tp>
    inline
    typename remove_reference<_Tp>::type&&
    move(_Tp&& __t)
    {
        typedef typename remove_reference<_Tp>::type _Up;
        return static_cast<_Up&&>(__t);
    }
    

        上面代码去掉了一些宏定义,留下了骨干内容,下面分析一下这个很短小的函数。首先它是一个模板函数,行参_Tp&& __t这里并不代表是_Tp类型的右值引用,而是有其特定规则的,这里后续在完美转发语义中会详细讲到,我们目前只需认为这里经过推倒后_Tp的类型可能是左值,左值引用,常量左值引用,和右值引用就好了。看下面的函数体通过字面意思remove_reference这个模板类的唯一作用是萃取出_Tp的原始非引用类型。也就是说_Up是个左值类型,这样最后返回的很明确,不管传入什么,最后传出都是一个右值引用。
        关于move我们在后续的完美转发中还会继续分析。

    相关文章

      网友评论

        本文标题:C++11新特性--右值引用与移动语义

        本文链接:https://www.haomeiwen.com/subject/fredzxtx.html