美文网首页
深入理解C++11 3.3 右值引用:移动语义和完美转发

深入理解C++11 3.3 右值引用:移动语义和完美转发

作者: zinclee123 | 来源:发表于2019-08-14 19:10 被阅读0次

    首先,本章很长,也较难理解,建议读者有大段连续的时间看这个。。。

    3.3.1 指针成员与拷贝构造

    关于拷贝构造函数的调用时间,可以看这篇文章
    如果类中包含了指针,需要小心处理,下面是一段有问题的代码

    class C {
    public:
        C():i(new int(0)){
            cout << "none argument constructor called" << endl;
        }
        
        ~C(){
           cout << "destructor called" << endl;
            delete i;
        }
        
        int* i;
    };
    
    int main(){
        C c1;
        C c2 = c1;
        
        cout << *c1.i << endl;
        cout << *c2.i << endl;
        
        return 0;
    }
    

    XCode代码执行输出

    none argument constructor called
    0
    0
    destructor called
    destructor called
    CppTest(50956,0x1000ad5c0) malloc: *** error for object 0x10070c510: pointer being freed was not allocated
    CppTest(50956,0x1000ad5c0) malloc: *** set a breakpoint in malloc_error_break to debug
    

    原因是编译期会默认为类创建拷贝构造函数,而默认的拷贝构造函数只是简单的赋值,对类C,系统默认生成的拷贝构造函数如

    C(const C& c):i(c.i){
    }
    

    导致c1和c2的i值一样,即指向同一片地址,当c1析构之后,c2.i就成为了一个“悬挂指针”(dangling pointer),不再指向有效的内存了,如果对悬挂指针再次进行delete就会出现严重的错误。
    以上系统生成的默认拷贝构造函数做的是浅拷贝(shallow copy),为了解决这个问题,通常是用户自定义拷贝构造函数实现深拷贝(deep copy),修正如下

    class C {
    public:
        C():i(new int(0)){
            cout << "none argument constructor called" << endl;
        }
        
        //增加此拷贝构造函数,根据传入的c,new一个新的int给i变量
        C(const C& c) :i(new int(*c.i)){
            
        }
        
        ~C(){
           cout << "destructor called" << endl;
            delete i;
        }
        
        int* i;
    };
    

    执行代码后如下

    none argument constructor called
    0
    0
    destructor called
    destructor called
    Program ended with exit code: 0
    

    3.3.2 移动语义

    拷贝函数中为指针成员分配新的内存再进行内容拷贝的方法在C++中几乎被视为不可违背的,不过有些时候却是不必要的。如下代码:

    //这是一个成员包含指针的类
    class HasPtrMem {
    public:
        HasPtrMem() : d(new int(0)) {
            cout << "Construct:" << ++n_cstr << endl;
        }
        
        HasPtrMem(const HasPtrMem& h) {
            cout << "Copy construct:" << ++n_cptr << endl;
        }
        
        ~HasPtrMem() {
            cout << "Destruct:" << ++n_dstr << endl;
        }
        
    private:
        int* d;
        static int n_cstr;
        static int n_dstr;
        static int n_cptr;
    };
    
    int HasPtrMem::n_cstr = 0;
    int HasPtrMem::n_dstr = 0;
    int HasPtrMem::n_cptr = 0;
    
    HasPtrMem GetTemp() {
        return HasPtrMem();//①
    }
    
    int main(){
        HasPtrMem m = GetTemp();//②
        
        return 0;
    }
    

    这里我没用Xcode编译运行,因为在Build Setting里增加-fno-elide-constructors编译器依然还是优化了,所以根据教材用命令行执行

    g++ -std=c++11 main.cpp -fno-elide-constructors
    

    会在cpp文件下生成一个a.out文件,在命令行执行./a.out输出

    Construct:1
    Copy construct:1
    Destruct:1
    Copy construct:2
    Destruct:2
    Destruct:3
    

    构造函数被调用1次,是在①处,第一次调用拷贝构造函数是在GetTemp return的时候,将①生成的变量拷贝构造出一个临时值,来当做GetTemp的返回,第二次拷贝构造函数是在②处。同时就有了于此对应的三次析构函数的调用。例子里用的是一个int类型的指针,而如果该指针指向的是非常大的堆内存数据的话,那没拷贝过程就会非常耗时,而且由于整个行为是透明且正确的,分析问题时也不易察觉。

    在C++中,我们可以通过移动构造函数解决此问题,修改代码如下:

    //这是一个成员包含指针的类
    class HasPtrMem {
    public:
        HasPtrMem() : d(new int(0)) {
            cout << "Construct:" << ++n_cstr << endl;
        }
        
        HasPtrMem(const HasPtrMem& h) {
            cout << "Copy construct:" << ++n_cptr << endl;
        }
        
        HasPtrMem(HasPtrMem&& h):d(h.d) {
            h.d = nullptr; //③注意对之前的h赋空指针
            cout << "Move construct:" << ++n_mvtr << endl;
        }
        
        ~HasPtrMem() {
            cout << "Destruct:" << ++n_dstr << endl;
        }
        
    private:
        int* d;
        static int n_cstr;
        static int n_dstr;
        static int n_cptr;
        static int n_mvtr;
    };
    
    int HasPtrMem::n_cstr = 0;
    int HasPtrMem::n_dstr = 0;
    int HasPtrMem::n_cptr = 0;
    int HasPtrMem::n_mvtr = 0;
    
    HasPtrMem GetTemp() {
        return HasPtrMem();
    }
    
    int main(){
        HasPtrMem m = GetTemp();
        
        return 0;
    }
    

    输出

    Construct:1
    Move construct:1
    Destruct:1
    Move construct:2
    Destruct:2
    Destruct:3
    

    这里通过指针赋值的方式,将d的内存直接偷了过来,避免了拷贝构造函数的调用。注意③,这里需要对原来的d进行赋空值,因为在移动构造函数完成之后,临时对象会立即被析构,如果不改变d,那临时对象被析构时,因为偷来的d和原本的d指向同一块内存,会被释放,成为悬挂指针,会造成错误。

    为什么不用函数参数里带个指针或者引用当返回结果呢?不是性能的问题,而是代码编写效率及可读性不好,如:

    string *a;
    int c = 1
    int &b = c;
    Calculate(GetTemp(),b);//最后一个参数用于返回结果
    

    最后说明一下移动构造函数被调用的时机:一旦用到的是临时变量,那么移动语义就可以得到执行。下一节讲下C++的值是如何分类的。未完待续,后面还有4节。。。

    3.3.3 左值、右值与右值引用

    关于左值(lvalue)和右值(rvalue)的判别方法:

    • 在赋值表达式中,出现在等号左边的是“左值”,等号右边的是“右值”,如a = b + c;中,a是左值,而b+c是右值;
    • 可以取地址的、有名字的是左值,反之是右值,对于a = b + c;&a是允许的操作,&(b+c)是不允许的操作,所以a是左值,b+c是右值。

    而在C++11中右值是由两个概念构成的,一个是将亡值(xvalue, eXpriring Value),另个一个则是纯右值(prvalue, Pure Rvalue)。
    其中纯右值包括:

    • 非引用返回的函数返回的临时变量值
    • 运算表达式,如1+3产生的临时变量值
    • 不跟对象关联的字面量,如2、’c‘、true
    • 类型转换函数的返回值
    • lamda表达式

    将亡值贼是C++11新增的跟右值引用相关的表达式,包括:

    • 返回右值引用T&&的函数返回值
    • std::move的返回值
    • 转换为T&&的类型转换函数的返回值

    而剩余的,可以标识函数、对象的值都属于左值。在C++11的程序中,所有的值必属于左值、将亡值、纯右值三者之一。

    在C++11中,右值引用就是对一个右值进行引用的类型。由于右值不具有名字,我们也只能通过引用的方式找到它的存在。通常我们只能是从右值表达式获得其引用。比如:

    T&& a = ReturnRvalue();①
    

    右值引用和左值引用都是引用类型,都必须立即进行初始化。引用类型本身并不拥有绑定对象的内存,只是该对象的一个别名。左值引用是具名变量值的别名,右值引用则是匿名变量的别名。

    在上面①的例子中,ReturnRvalue函数返回的右值在表达式语句结束后,其生命也就终结了,而通过右值引用的声明,该右值又“重获新生”,其生命期将于右值引用类型a的生命期一样。只要a还“活着”,该右值临时量将会一直“存活”下去。
    所以相比于一下语句:

    T b = ReturnRvalue();
    

    ①的声明方式会少一次对象的析构和一次对象构造。因为a是右值引用,直接绑定了ReturnRvalue()返回的临时量,而b是由临时值构造的,而临时量在表达式结束后会析构因而会多一次析构和构造的开销。
    注意,能够声明右值引用a的前提是ReturnRvalue返回的是一个右值。通常右值引用是不能够绑定到任何左值的,如下代码会导致编译无法通过:

    int c;
    int &&d = c;
    

    有的时候,我们可能不知道一个类型是否是引用类型,以及是左值引用还是右值引用。标准库<type_traits>头文件中提供了3个类模板:is_rvalue_reference、is_lvalue_reference和is_reference,比如:

    cout << is_rvalue_reference<string &&>::value;
    

    3.3.4 std::move 强制转化为右值

    C++11中,<utility>中提供了函数std::move,功能是将一个左值强制转化为右值引用,继而我们可以通过右值引用使用该值,用于移动语义。std::move基本等同于一个类型转换:

    static_cast<T&&>(lvalue);
    

    被转化的左值,其生命期并没有随着左右值的转化而改变。下面是一个正确使用std::move的例子

    class HugeMem {
    public:
        HugeMem(int size): sz(size>0 ? size: 1) {
            c = new int[size];
        }
        
        ~HugeMem() {
            delete [] c;
        }
        
        HugeMem(HugeMem&& h) : sz(h.sz), c(h.c) {
            h.c = nullptr;
        }
        
        int* c;
        int sz;
    };
    
    class Moveable {
    public:
        Moveable(): i(new int[3]), h(1024) {}
        
        ~Moveable() {
            delete [] i;     
        }
        
        Moveable(Moveable&& m) : i(m.i), h(move(m.h)) { //使用move将m.h转为右值引用,继而调用HugeMem的移动构造函数
            m.i = nullptr;
        }
        
        int *i;
        HugeMem h;
    };
    
    Moveable getTemp() {
        Moveable tmp = Moveable();
        cout << hex << "Huge Mem from " << __func__ << "@" << tmp.h.c << endl;
        return tmp;
    }
    
    int main(){
        Moveable a(getTemp());//因为getTemp()返回的是右值,所以会调用Moveable的移动构造函数
        cout << hex << "Huge Mem from " << __func__ << "@" << a.h.c << endl;
        return 0;
    }
    

    输出

    Huge Mem from getTemp@0x104002000
    Huge Mem from main@0x104002000
    

    需要注意的是,在编写移动构造函数的时候,应该总是使用std::move转换拥有形如堆内存、文件句柄的等资源的成员为右值,这样一来,如果成员支持移动构造的话,就可以实现其移动语义,即使成员没有移动构造函数,也会调用拷贝构造,因为不会引起大的问题。

    3.3.5 移动语义的一些其他问题

    移动语义一定是要改变临时变量的值(这里有以为,需要解决,目前没看出哪里一定要改变,先这么硬背吧)。如声明:

    Moveable(const Moveale &&);//这个对应3.3.4的例子,如果这样声明移动构造函数会报错
    
    image.png

    而如果是将3.3.4的例子中的Moveable getTemp()改为const Moveable getTemp(),再执行命令

    g++ -std=c++11 main.cpp -fno-elide-constructors
    

    注意上面的改动在Xcode中是可以运行的,可以正确调用到移动构造函数,但是通过命令行会提示

    copy constructor is implicitly deleted because 'Moveable' has a user-declared move constructor
    

    可见Moveable a(getTemp());实际是要调用Moveable的拷贝构造函数。报错原因显示声明了移动构造函数,编译器就不会为类生成默认的拷贝构造函数了,所以提示没有显示声明拷贝构造函数。

    在C++11中,拷贝/移动改造函数有以下3个版本:

    • T Object(T&)
    • T Object(const T&)
    • T Object(T&&)

    其中常量左值引用的版本是一个拷贝构造函数版本,右值引用参数的是一个移动构造函数版本。默认情况下,编译器会为程序员隐式地生成一个移动构造函数,但是如果声明了一自定义的拷贝构造函数、拷贝赋值函数、移动构造函数、析构函数中的一个或者多个,编译器都不会再生成默认版本。所以在C++11中,拷贝构造函数、拷贝赋值函数、移动构造函数和移动赋值函数必须同时提供,或者同时不提供,只声明其中一种的话,类都仅能实现一种语义。

    只实现一种语义在类的编写中也是非常常见的,比如如果只实现移动语义,则表明该类型的变量拥有的资源只能被移动,不能被拷贝,那么这样的资源必须是唯一的,如智能指针、文件流。

    在<type_traits>里,可以通过一些辅助的模板类来判断一个类型是否是可以移动的,如:

    • is_move_constructible
    • is_trivially_move_constructible
    • is_nothrow_move_constructible

    使用方法都是使用value成员,如

    cout << is_move_constructible<UnknowTYpe>::value;
    

    有了移动语义,可以实现高性能的置换函数,如:

    template <class T>
    void swap(T& a, T& b) {
        T tmp(move(a));
        a = move(b);
        b = move(tmp);
    }
    

    如果T是可以移动的,则不会有资源的释放和申请,如果T不可移动但是可以拷贝,则和普通声明一样了。

    要注意的是,尽量不要编写会抛出异常的移动构造函数,因为有可能移动没完成,会导致一些指针成为悬挂指针,通过添加noexcept关键字,可以保证移动构造函数抛出异常直接终止程序。

    3.3.6 完美转发

    完美转发(perfect forwarding),是指在模板函数中,完全依照模板的参数类型讲参数传递给模板中调用的另外一个函数,如:

    template <typename T>
    void IamForwarding(T t) {
        IrunCodeActually(t);
    }
    

    这是一个参数透传的实现,但是因为使用最基本类型转发,会在传参的时候产生一次额外的临时对象拷贝,因为只能说是转发,但不完美。所以通常需要的是一个引用类型餐护士,不会有拷贝的开销。其次需要考虑函数对类型的接受能力,因为目标函数可能需要既接受左值引用,又接受右值引用,如果转发函数只能接受其中的一部分,也不完美。

    对应代码

    typedef const A T;
    typedef T& TR;
    TR& v = 1;
    

    在C++11中引入了一条所谓“引用折叠”的新语言规则,规则如下

    TR的类型定义 声明v的类型 v的实际类型
    T& TR A&
    T& TR& A&
    T& TR&& A&
    T&& TR A&&
    T&& TR& A&
    T&& TR&& A&&

    规则就是一单定义中出现了左值引用,引用折叠总是优先将其折叠为左值引用。前三行TR定义为T&,则v世界类型为A&,第五行的v的类型为TR&,则v的实际类型也为A&,其他则为右值引用。于是我们把转发函数改为:

    template <typename T>
    void IamForwarding(T&& t) {
        IrunCodeActually(static_cast<T&&>(t));
    }
    

    对于传入的左值引用

    void IamForwarding(X& && t) {
        IrunCodeActually(static_cast<X& &&>(t));
    }
    

    折叠后是

    void IamForwarding(X& t) {
        IrunCodeActually(static_cast<X&>(t));
    }
    

    对于右值引用

    void IamForwarding(X&& && t) {
        IrunCodeActually(static_cast<X&& &&>(t));
    }
    

    折叠后是

    void IamForwarding(X&& t) {
        IrunCodeActually(static_cast<X&&>(t));
    }
    

    此处的static_cast类似std::move的作用,将左值转换为右值引用。不过在C++11中,用于完美转发的函数不叫move,叫forward,所以也可以这么写

    void IamForwarding(X&& t) {
        IrunCodeActually(forward(t));
    }
    

    move和forward实现差别不大,但是为了不同用途,有了不同命名。
    下面是完美转发的例子:

    void run(int && m) { cout << "rvalue ref" << endl; }
    void run(int & m) { cout << "lvalue ref" << endl; }
    void run(const int && m) { cout << "const rvalue ref" << endl; }
    void run(const int & m) { cout << "const lvalue ref" << endl; }
    
    template <typename T>
    void perfectForward(T&& t) {
        run(forward<T>(t));
    }
    
    int main(){
        int a;
        int b;
        const int c = 1;
        const int d = 0;
        
        perfectForward(a);
        perfectForward(move(b));
        perfectForward(c);
        perfectForward(move(d));
        
        return 0;
    }
    

    输出

    lvalue ref
    rvalue ref
    const lvalue ref
    const rvalue ref
    

    相关文章

      网友评论

          本文标题:深入理解C++11 3.3 右值引用:移动语义和完美转发

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