zoukankan      html  css  js  c++  java
  • 对象移动、右值引用详解

    本文出自图书 > 深入理解C++11:C++ 11新特性解析与应用

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

    3.3.1指针成员与拷贝构造
    对C++程序员来说,编写C++程序有一条必须注意的规则,就是在类中包含了一个指针成员的话,那么就要特别小心拷贝构造函数的编写,因为一不小心,就会出现内存泄露。
    
    #include <iostream>
    using namespace std;
    class HasPtrMem{
        public:
            HasPtrMem(): d(new int(0)){}
            ~HasPtrMem() {
                delete d;
            }   
            int *d; //指针成员d
    };
    int main(){
        HasPtrMem a;
        HasPtrMem b(a);
        cout<<*a.d<<endl;//0
        cout<<*b.d<<endl;//0
    }
    

    我们定义了一个HasPtrMem的类。这个类包含一个指针成员,该成员在构造时接受一个new操作分配堆内存返回的指针,而在析构的时候则会被delete操作用于释放之前分配的堆内存。

    在main函数中,我们声明了HsaPtrMem类型的变量a,又使用a初始化了变量b。按照C++语法,这会调用HasPtrMem的拷贝构造函数。(这里的拷贝构造函数由编译器隐式生成,其作用

    是执行类似于memcpy的按位拷贝。这样的构造方式有一个问题,就是a.d和b.d都指向同一块堆内存。因此在main作用域结束的时候,a和b的析构函数纷纷被调用,当其中之一完成析构

    之后(比如b),那么a.d就成了一个"悬挂指针",因为其不再指向有效的内存了。那么在该悬挂指针上释放内存就会造成严重的错误。

    这样的拷贝方式,在C++中也常被称为"浅拷贝"。而在为声明构造函数的情况下,C++也会为类生成一个浅拷贝的构造函数。通常最佳的解决方案是用户自定义拷贝构造函数来实现"深拷贝":

    #include <iostream>
    using namespace std;
    class HasPtrMem{
        public:
            HasPtrMem(): d(new int(0)){
                cout<<"Construct:"<<endl;
            }
            HasPtrMem(HasPtrMem&h): d(new int(*h.d)){
                cout<<"Copy construct:"<<endl; 
            } //拷贝构造函数,从堆中分配内存,并用*h.d初始化
            ~HasPtrMem() {
                delete d;
            }   
            int *d; //指针成员d
    };
    int main(){
        HasPtrMem a;
        HasPtrMem b(a);
        cout<<*a.d<<endl;//0
        cout<<*b.d<<endl;//0
    }
    

    (问题:浅拷贝和深拷贝 的差别)
    我们为HasPtrMem添加了一个拷贝构造函数。拷贝构造函数从堆中分配内存,将该分配来的内存的指针交还给d, 又使用*(h.d)对 *d进行了初始化。通过这样的方法,就避免了悬挂指针的困扰。

    3.3.2  移动语义

    拷贝构造函数中为指针成员分配新的内存再进行内容拷贝的做法在C++编程中几乎被视为不可违背的。不过在一些时候,我们确实不需要这样的拷贝语义。

    #include <iostream>
    using namespace std;
    class HasPtrMem{
        public:
            HasPtrMem(): d(new int(0)){
                cout<<"Construct:" << ++n_cstr<<endl;
            }
            HasPtrMem(const HasPtrMem&h): d(new int(*h.d)){
                cout<<"Copy construct:"<< ++n_cptr<<endl;
            } //拷贝构造函数,从堆中分配内存,并用*h.d初始化
            ~HasPtrMem() {
                cout<<"Destruct:"<<++n_dstr<<endl;
            }
            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 a=GetTemp();
    }
    

    (回顾:静态变量和非静态变量)

    数据成员可以分静态变量、非静态变量两种.

                      静态成员:静态类中的成员加入static修饰符,即是静态成员.可以直接使用类名+静态成员名访问此静态成员,因为静态成员存在于内存,非静态成员需要实例化才会

    分配内存,所以静态成员不能访问非静态的成员..因为静态成员存在于内存,所以非静态成员可以直接访问类中静态的成员.

                    非静态成员:所有没有加Static的成员都是非静态成员,当类被实例化之后,可以通过实例化的类名进行访问..非静态成员的生存期决定于该类的生存期..而静态成员则

    不存在生存期的概念,因为静态成员始终驻留在内容中..

    我们声明了一个返回一个HasPtrMem变量的函数。为了记录构造函数、拷贝构造函数,以及析构函数调用的次数,我们用了一些静态变量。在main函数中,我们简单地声明

    了一个HasPtrMem的变量a,要求它使用GetTemp的返回值进行初始化。

    //正常情况下的输出:
    Construct:1
    Copy construct:1 //这个是临时对象的构造
    Destruct:1 //这个应该是临时对象的析构
    Copy construct:2
    Destruct:2
    Destruct:3
    但是在C++11或者非C++里面的结果
    只是一个浅拷贝
    

    这里的构造函数被调用了一次,是GetTemp函数中HasPtrMem()表达式显示地调用了构造函数而打印出来的。而拷贝构造函数则被调用了两回。一次是从GetTemp函数中HasPtrMem()生成的变量上拷贝构造出来一个临时值,以用做GetTemp的返回值,而另一次则是由临时值构造出main中变量a调用的。对应的,析构函数也就调用了3次。


    ttt.jpg-132.9kB
     

    最头疼的就是拷贝构造函数的调用。在上面的代码上,类HasPtrMem只有一个Int类型的指针。如果HasPtrMem的指针指向非常大的堆内存数据的话,那么拷贝构造函数就会非常昂贵。

    可以想象,一旦这样,a的初始化表达式的执行速度非常慢。临时变量的产生和销毁以及拷贝的发生对于程序员来说基本上是透明的,不会影响程序的正常值,因而即使该问题导致程序

    的性能不如预期,也不易被程序员察觉(事实上,编译器常常对函数返回值有专门的优化)

    然后,按照C++的语义,临时对象将在语句结束后被析构,会释放它所包含的堆内存资源。而a在拷贝构造的时候,又会被分配堆内存。这样意义不大,所以,考虑在临时对象构造a的时

    候不分配内存,即不使用拷贝构造。

    剩下的就是移动构造:

    tt1.jpg-313.6kB
     

    上半部分从临时变量中拷贝构造变量a的做法,即在拷贝时分配新的堆内存,并从临时对象的堆内存中拷贝内容至a.d。而构造完成后,临时对象将析构,因此,其拥有的堆内存资源会被析构函数释放。

    下半部分,在构造函数时使得a.d指向临时对象的堆内存资源。同时我们保证临时对象不释放所指向的堆内存,那么,在构造完成后,临时对象被析构,a就从中"偷"到了临时对象所拥有的堆内存资源。

    在 C++11 中,这样的"偷走"临时变量中资源的构造函数,就被称为"移动构造函数"。

    #include <iostream>
    using namespace std;
    class HasPtrMem{
        public:
            HasPtrMem(): d(new int(3)){
                cout<<"Construct:" << ++n_cstr<<endl;
            }
            HasPtrMem(const HasPtrMem&h): d(new int(*h.d)){
                cout<<"Copy construct:"<< ++n_cptr<<endl;
            } //拷贝构造函数,从堆中分配内存,并用*h.d初始化
            HasPtrMem(HasPtrMem &&h):d(h.d){
                h.d=nullptr;//将临时值得指针成员置空。
                cout<<"Move construct:"<<++n_mvtr<<endl; 
            }
            ~HasPtrMem() {
                delete d;
                cout<<"Destruct:"<<++n_dstr<<endl;
            }
            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(){
        HasPtrMem h;
        cout<<"Resource from"<<__func__<<":"<<hex<<h.d<<endl;
        return h; 
    }
    int main(){
        //HasPtrMem b;
        HasPtrMem a=GetTemp();
        cout<<"Resource from"<<__func__<<":"<<hex<<a.d<<endl;
     }
     
    

    这里其实,就多了一个构造函数HasPtrMem(HasPtrMem&&), 这个就是我们所谓的移动构造函数。与拷贝构造函数不同的是,移动构造函数接受一个所谓的"右值引用"的参数,

    关于右值,读者可以暂时理解为临时变量的引用。移动构造函数使用了参数h的成员d初始化了本对象的成员d(而不是像拷贝构造函数一样需要分配内存,然后将内存一次拷贝到新分配的内存中),

    随后h的成员d置为指针空值nullptr。完成了移动构造函数的全过程。

    这里所谓的“偷”堆内存,就是指将本对象d指向h.d所指的内存这一条语句,相应地,我们还将h的成员d置为指针空值这其实也是我们“偷”内存时必须做的。这是因为在移动构造完成之后,

    临时对象会立即被析构。如果不改变h.d(临时对象的指针成员)的话,则临时对象会析构掉本是我们“偷”来的堆内存。这样一来,本对象中的d指针也就成了一个悬挂指针,如果我们对指针

    进行解引用,就会发生严重的运行时错误。

    (将指针置为nullptr只是让这个指针不再指向任何对象,并没有释放原来这个指针指向的对象的内存)

     

    //理论上的结果:
    Construct:1
    Resource from GetTemp:0x603010
    Move construct:1
    Destruct:1
    Move construct:2
    Destruct:2
    Resource from main:0x603010
    Destruct:3
    //实际上的结果:似乎只要涉及到需要临时变量的生成的时候,都会有问题。
    Construct:1
    Resource from GetTemp:0x603010
    Resource from main:0x603010
    Destruct:1
    

    如果堆内存不是一个int长度的数据,而是以MBty为单位的堆空间,那么这样的移动带来的性能提升是非常惊人的。

    如果传了引用或者指针到函数里面作为参数,效果虽然不差。但是从使用的方便性上来看效果却不好,如果函数返回临时值的话,可以在单条语句里面完成很多计算,比如可以很自然地写出如下语句:

    Caculate(GetTemp(), SomeOther(Maybe(),Useful(Values,2)));
    

    但如果通过传引用或者指针的方法而不返回值的话,通常就需要很多语句来完成上面的工作。

    string*a; vector b;//事先声明一些变量用于传递返回值
    ...
    Useful(Values,2,a);//最后一个参数是指针,用于返回结果
    SomeOther(Maybe(),a,b);//最后一个参数是引用,用于返回结果
    Caculate(GetTemp(), b);
    

    当声明这些传递返回值的变量为全局的,函数再将这些引用和指针作为返回值返回给调用者,我们也需要Caculate调用之前声明好所有的引用和指针。函数返回临时变量的好处就是不需要声明变量,也不需要知道生命期。程序员只需要按照最自然的方式,使用最简单语句就可以完成大量的工作。

    然后,移动语义何时会被触发。之前我们只是提到了临时对象,一旦我们用到的是个临时变量,那么移动构造语义就可以得以执行。**那么,在C++中如何判断产生了临时对象?如何将其用于移动构造函数?是否只有临时变量可以用于移动构造?.....

    3.3.3 左值、右值与右值引用

    在C语言中,我们常常会提起左值(lvalue)、右值(rvalue)这样的称呼。而在编译程序时,编译器有时也会在报出的错误信息中会包含左值、右值的说法。不过左值、右值通常不是通过一个严谨的定义而为人所知的,大多数时候左右值的定义与其判别方法是一体的。一个最为典型的判别方法就是,在赋值表达式中,出现在等号左边的就是“左值”,而在等号右边的,则称为“右值”。比如:

    a = b + c;

    在这个赋值表达式中,a就是一个左值,而b + c则是一个右值。这种识别左值、右值的方法在C++中依然有效。不过C++中还有一个被广泛认同的说法,那就是可以取地址的、有名字的就是左值,反之,不能取地址的、没有名字的就是右值。那么这个加法赋值表达式中,&a是允许的操作,但&(b + c)这样的操作则不会通过编译。因此a是一个左值,(b + c)是一个右值。

    这些判别方法通常都非常有效。更为细致地,在C++11中,右值是由两个概念构成的,一个是将亡值(xvalue,eXpiring Value),另一个则是纯右值(prvalue,Pure Rvalue)。

    其中纯右值就是C++98标准中右值的概念,讲的是用于辨识临时变量和一些不跟对象关联的值。比如非引用返回的函数返回的临时变量值(我们在前面多次提到了)就是一个纯右值。一些运算表达式,比如1 + 3产生的临时变量值,也是纯右值。而不跟对象关联的字面量值,比如:2、‘c’、true,也是纯右值。此外,类型转换函数的返回值、lambda表达式(见7.3节)等,也都是右值。

    而将亡值则是C++11新增的跟右值引用相关的表达式,这样表达式通常是将要被移动的对象(移为他用),比如返回右值引用T&&的函数返回值、std::move的返回值(稍后解释),或者转换为T&&的类型转换函数的返回值(稍后解释)。而剩余的,可以标识函数、对象的值都属于左值。在C++11的程序中,所有的值必属于左值、将亡值、纯右值三者之一。

    注意 事实上,之所以我们只知道一些关于左值、右值的判断而很少听到其真正的定义的一个原因就是—很难归纳。而且即使归纳了,也需要大量的解释。

    在C++11中,右值引用就是对一个右值进行引用的类型。事实上,由于右值通常不具有名字,我们也只能通过引用的方式找到它的存在。通常情况下,我们只能是从右值表达式获得其引用。比如:
    T && a = ReturnRvalue();

    这个表达式中,假设ReturnRvalue返回一个右值,我们就声明了一个名为a的右值引用,其值等于ReturnRvalue函数返回的临时变量的值。

    为了区别于C++98中的引用类型,我们称C++98中的引用为“左值引用”(lvalue reference)。右值引用和左值引用都是属于引用类型。无论是声明一个左值引用还是右值引用,都必须立即进行初始化。而其原因可以理解为是引用类型本身自己并不拥有所绑定对象的内存,只是该对象的一个别名。左值引用是具名变量值的别名,而右值引用则是不具名(匿名)变量的别名。

    在上面的例子中,ReturnRvalue函数返回的右值在表达式语句结束后,其生命也就终结了(通常我们也称其具有表达式生命期),而通过右值引用的声明,该右值又“重获新生”,其生命期将与右值引用类型

    变量a的生命期一样。只要a还“活着”,该右值临时量将会一直“存活”下去。

    所以相比于以下语句的声明方式:
    T b = ReturnRvalue();

    我们刚才的右值引用变量声明,就会少一次对象的析构及一次对象的构造。因为a是右值引用,直接绑定了ReturnRvalue()返回的临时量,而b只是由临时值构造而成的,而临时量在表达式结束后会析构因应就会多一次析构和构造的开销。

    不过值得指出的是,能够声明右值引用a的前提是ReturnRvalue返回的是一个右值。通常情况下,右值引用是不能够绑定到任何的左值的。比如下面的表达式就是无法通过编译的。
    int c;
    int && d = c;

    相对地,在C++98标准中就已经出现的左值引用是否可以绑定到右值(由右值进行初始化)呢?比如:
    T & e = ReturnRvalue();
    const T & f = ReturnRvalue();

    这样的语句是否能够通过编译呢?这里的答案是:e的初始化会导致编译时错误,而f则不会。

    出现这样的状况的原因是,在常量左值引用在C++98标准中开始就是个“万能”的引用类型。它可以接受非常量左值、常量左值、右值对其进行初始化。而且在使用右值对其初始化的时候,常量左值引用还可以

    像右值引用一样将右值的生命期延长。不过相比于右值引用所引用的右值,常量左值所引用的右值在它的“余生”中只能是只读的。相对地,非常量左值只能接受非常量左值对其进行初始化。

    既然常量左值引用在C++98中就已经出现,读者可能会努力地搜索记忆,想找出在C++中使用常量左值绑定右值的情况。不过可能一切并不如愿。这是因为,在C++11之前,左值、右值对于程序员来说,一直呈透明状态。不知道什么是左值、右值,并不影响写出正确的C++代码。引用的是左值和右值通常也并不重要。不过事实上,在C++98通过左值引用来绑定一个右值的情况并不少见,比如:
    const bool & judgement = true;

    就是一个使用常量左值引用来绑定右值的例子。不过与如下声明相比较看起来似乎差别不大。
    const bool judgement = true;

    可能很多程序员都没有注意到其中的差别(从语法上讲,前者直接使用了右值并为其“续命”,而后者的右值在表达式结束后就销毁了)。

    事实上,即使在C++98中,我们也常可以使用常量左值引用来减少临时对象的开销,如代码清单3-20所示。

    代码清单3-20
    #include <iostream>
    using namespace std;

    struct Copyable {
        Copyable() {}
        Copyable(const Copyable &o) {
            cout << "Copied" << endl;
        }
    };

    Copyable ReturnRvalue() { return Copyable(); }
    void AcceptVal(Copyable) {}
    void AcceptRef(const Copyable & ) {}

    int main() {
        cout << "Pass by value: " << endl;
        AcceptVal(ReturnRvalue()); // 临时值被拷贝传入
        cout << "Pass by reference: " << endl;
        AcceptRef(ReturnRvalue()); // 临时值被作为引用传递
    }
    // 编译选项:g++ 3-3-5.cpp -fno-elide-constructors

    在代码清单3-20中,我们声明了结构体Copyable,该结构体的唯一的作用就是在被拷贝构造的时候打印一句话:Copied。而两个函数,AcceptVal使用了值传递参数,而AcceptRef使用了引用传递。在以ReturnRvalue返回的右值为参数的时候,AcceptRef就可以直接使用产生的临时值(并延长其生命期),而AcceptVal则不能直接使用临时对象。

    编译运行代码清单3-20,可以得到以下结果:
    Pass by value:
    Copied
    Copied
    Pass by reference:
    Copied

    可以看到,由于使用了左值引用,临时对象被直接作为函数的参数,而不需要从中拷贝一次。读者可以自行分析一下输出结果,这里就不赘述了。而在C++11中,同样地,如果在代码清单3-20中以右值引用为参数声明如下函数:
    void AcceptRvalueRef(Copyable && ) {}

    也同样可以减少临时变量拷贝的开销。进一步地,还可以在AcceptRvalueRef中修改该临时值(这个时候临时值由于被右值引用参数所引用,已经获得了函数时间的生命期)。不过修改一个临时值的意义通常不大,除非像3.3.2节一样使用移动语义。
    就本例而言,如果我们这样实现函数:
    void AcceptRvalueRef(Copyable && s) {
       Copyable news = std::move(s);
    }

    这里std::move的作用是强制一个左值成为右值(看起来很奇怪?这个我们会在下面一节中解释)。该函数就是使用右值来初始化Copyable变量news。当然,如同我们在上小节提到的,使用移动语义的前提是Copyable还需要添加一个以右值引用为参数的移动构造函数,比如:
    Copyable(Copyable &&o) { /* 实现移动语义 */ }

    这样一来,如果Copyable类的临时对象(即ReturnRvalue返回的临时值)中包含一些大块内存的指针,news就可以如同代码清单3-19一样将临时值中的内存“窃”为己用,从而从这个以右值引用参数的AcceptRvalueRef函数中获得最大的收益。事实上,右值引用的来由从来就跟移动语义紧紧相关。这是右值存在的一个最大的价值(另外一个价值是用于转发,我们会在后面的小节中看到)。

    对于本例而言,很有趣的是,读者也可以思考一下:如果我们不声明移动构造函数,而只声明一个常量左值的构造函数会发生什么?如同我们刚才提到的,常量左值引用是个“万能”的引用类型,无论左值还是右值,常量还是非常量,一概能够绑定。那么如果Copyable没有移动构造函数,下列语句:

    Copyable news = std::move(s);

    将调用以常量左值引用为参数的拷贝构造函数。这是一种非常安全的设计—移动不成,至少还可以执行拷贝。因此,通常情况下,程序员会为声明了移动构造函数的类声明一个常量左值为参数的拷贝构造函数,以保证在移动构造不成时,可以使用拷贝构造(不过,我们也会在之后看到一些特殊用途的反例)。

    为了语义的完整,C++11中还存在着常量右值引用,比如我们通过以下代码声明一个常量右值引用。
    const T && crvalueref = ReturnRvalue();

    但是,一来右值引用主要就是为了移动语义,而移动语义需要右值是可以被修改的,那么常量右值引用在移动语义中就没有用武之处;二来如果要引用右值且让右值不可以更改,常量左值引用往往就足够了。

    因此在现在的情况下,我们还没有看到常量右值引用有何用处。(所以移动构造函数的形参不能是const的)


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

    在C++11中,标准库在<utility>中提供了一个有用的函数std::move,这个函数的名字具有迷惑性,因为实际上std::move并不能移动任何东西,它唯一的功能是将一个左值强制转化为右值引用,继而我们可以通过右值引用使用该值,以用于移动语义。从实现上讲,std::move基本等同于一个类型转换:
    static_cast<T&&>(lvalue);

    值得一提的是,被转化的左值,其生命期并没有随着左右值的转化而改变。如果读者期望std::move转化的左值变量lvalue能立即被析构,那么肯定会失望了。我们来看代码清单3-21所示的例子。

    代码清单3-21
    #include <iostream>
    using namespace std;

    class Moveable{
    public:
        Moveable():i(new int(3)) {}
        ~Moveable() { delete i; }
        Moveable(const Moveable & m): i(new int(*m.i)) { }
        Moveable(Moveable && m):i(m.i) {
            m.i = nullptr;
        }
        int* i;
    };

    int main() {
        Moveable a;

        Moveable c(move(a));    // 会调用移动构造函数
        cout << *a.i << endl;   // 运行时错误
    }
    // 编译选项:g++ -std=c++11 3-3-6.cpp -fno-elide-constructors

    在代码清单3-21中,我们为类型Moveable定义了移动构造函数。这个函数定义本身没有什么问题,但调用的时候,使用了Moveable c(move(a));这样的语句。这里的a本来是一个左值变量,通过std::move将其转换为右值。这样一来,a.i就被c的移动构造函数设置为指针空值。由于a的生命期实际要到main函数结束才结束,那么随后对表达式*a.i进行计算的时候,就会发生严重的运行时错误。

    这是个典型误用std::move的例子。当然,标准库提供该函数的目的不是为了让程序员搬起石头砸自己的脚。事实上,要使用该函数,必须是程序员清楚需要转换的时候。比如上例中,程序员应该知道被转化为右值的a不可以再使用。不过更多地,我们需要转换成为右值引用的还是一个确实生命期即将结束的对象。我们来看看代码清单3-22所示的正确例子。

    代码清单3-22
    #include <iostream>
    using namespace std;

    class HugeMem{
    public:
        HugeMem(int size): sz(size > 0 ? size : 1) {
            c = new int[sz];
        }
        ~HugeMem() { delete [] c; }
        HugeMem(HugeMem && hm): sz(hm.sz), c(hm.c) {
            hm.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)) {      // 强制转为右值,以调用移动构造函数
            m.i = nullptr;
        }
        int* i;
        HugeMem h;
    };

    Moveable GetTemp() {
        Moveable tmp = Moveable();
        cout << hex << "Huge Mem from " << __func__
            << " @" << tmp.h.c << endl; // Huge Mem from GetTemp @0x603030
        return tmp;
    }

    int main() {
        Moveable a(GetTemp());
        cout << hex << "Huge Mem from " << __func__
            << " @" << a.h.c << endl;   // Huge Mem from main @0x603030
    }
    // 编译选项:g++ -std=c++11 3-3-7.cpp -fno-elide-constructors

    在代码清单3-22中,我们定义了两个类型:HugeMem和Moveable,其中Moveable包含了一个HugeMem的对象。在Moveable的移动构造函数中,我们就看到了std::move函数的使用。该函数将m.h强制转化为右值,以迫使Moveable中的h能够实现移动构造。这里可以使用std::move,是因为m.h是m的成员,既然m将在表达式结束后被析构,其成员也自然会被析构,因此不存在代码清单3-21中的生存期不对的问题。另外一个问题可能是std::move使用的必要性。这里如果不使用std::move(m.h)这样的表达式,而是直接使用m.h这个表达式将会怎样?

    其实这是C++11中有趣的地方:可以接受右值的右值引用本身却是个左值。这里的m.h引用了一个确定的对象,而且m.h也有名字,可以使用&m.h取到地址,因此是个不折不扣的左值。不过这个左值确确实实会很快“灰飞烟灭”,因为拷贝构造函数在Moveable对象a的构造完成后也就结束了。那么这里使用std::move强制其为右值就不会有问题了。而且,如果我们不这么做,由于m.h是个左值,就会导致调用HugeMem的拷贝构造函数来构造Moveable的成员h(虽然这里没有声明,读者可以自行添加实验一下)。如果是这样,移动语义就没有能够成功地向类的成员传递。换言之,还是会由于拷贝而导致一定的性能上的损失。

    事实上,为了保证移动语义的传递,程序员在编写移动构造函数的时候,应该总是记得使用std::move转换拥有形如堆内存、文件句柄等资源的成员为右值,这样一来,如果成员支持移动构造的话,就可以实现其移动语义。而即使成员没有移动构造函数,那么接受常量左值的构造函数版本也会轻松地实现拷贝构造,因此也不会引起大的问题。

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

    我们在前面多次提到,移动语义一定是要修改临时变量的值。那么,如果这样声明移动构造函数:
    Moveable(const Moveable &&)

    或者这样声明函数:
    const Moveable ReturnVal();

    都会使得的临时变量常量化,成为一个常量右值,那么临时变量的引用也就无法修改,从而导致无法实现移动语义。因此程序员在实现移动语义一定要注意排除不必要的const关键字。
    在C++11中,拷贝/移动构造函数实际上有以下3个版本:
    T Object(T &)
    T Object(const T &)
    T Object(T &&)

    其中常量左值引用的版本是一个拷贝构造版本,而右值引用版本是一个移动构造版本。默认情况下,编译器会为程序员隐式地生成一个(隐式表示如果不被使用则不生成)移动构造函数。不过

    如果程序员声明了自定义的拷贝构造函数、拷贝赋值函数、移动赋值函数、析构函数中的一个或者多个,编译器都不会再为程序员生成默认版本。默认的移动构造函数实际上跟默认的拷贝构造函数一样,只

    能做一些按位拷贝的工作。这对实现移动语义来说是不够的。通常情况下,如果需要移动语义,程序员必须自定义移动构造函数。当然,对一些简单的、不包含任何资源的类型来说,实现移动语义与否都无关

    紧要,因为对这样的类型而言,移动就是拷贝,拷贝就是移动。

    同样地,声明了移动构造函数、移动赋值函数、拷贝赋值函数和析构函数中的一个或者多个,编译器也不会再为程序员生成默认的拷贝构造函数。

    所以在C++11中,拷贝构造/赋值和移动构造/赋值函数必须同时提供,或者同时不提供,程序员才能保证类同时具有拷贝和移动语义。只声明其中一种的话,类都仅能实现一种语义。

    其实,只实现一种语义在类的编写中也是非常常见的。比如说只有拷贝语义的类型—事实上在C++11之前我们见过大多数的类型的构造都是只使用拷贝语义的。而只有移动语义的类型则非常有趣,因为只有移动语义表明该类型的变量所拥有的资源只能被移动,而不能被拷贝。那么这样的资源必须是唯一的。因此,只有移动语义构造的类型往往都是“资源型”的类型,比如说智能指针,文件流等,都可以视为“资源型”的类型。在本书的第5章中,就可以看到标准库中的仅可移动的模板类:unique_ptr。一些编译器,如vs2011,现在也把ifstream这样的类型实现为仅可移动的。

    在标准库的头文件<type_traits>里,我们还可以通过一些辅助的模板类来判断一个类型是否是可以移动的。比如is_move_constructible、is_trivially_move_constructible、is_nothrow_move_constructible,使用方法仍然是使用其成员value。比如:
    cout << is_move_constructible<UnknownType>::value;

    就可以打印出UnknowType是否可以移动,这在一些情况下还是非常有用的。

    而有了移动语义,还有一个比较典型的应用是可以实现高性能的置换(swap)函数。看看下面这段swap模板函数代码:
    template <class T>
    void swap(T& a, T& b)
    {
        T tmp(move(a));
        a = move(b);
        b = move(tmp);
    }

    如果T是可以移动的,那么移动构造和移动赋值将会被用于这个置换。代码中,a先将自己的资源交给tmp,随后b再将资源交给a,tmp随后又将从a中得到的资源交给b,从而完成了一个置换动作。整个过程,代码都只会按照移动语义进行指针交换,不会有资源的释放与申请。而如果T不可移动却是可拷贝的,那么拷贝语义会被用来进行置换。这就跟普通的置换语句是相同的了。因此在移动语义的支持下,我们仅仅通过一个通用的模板,就可能更高效地完成置换,这对于泛型编程来说,无疑是具有积极意义的。

    另外一个关于移动构造的话题是异常。对于移动构造函数来说,抛出异常有时是件危险的事情。因为可能移动语义还没完成,一个异常却抛出来了,这就会导致一些指针就成为悬挂指针。因此程序员应该尽量编写不抛出异常的移动构造函数,通过为其添加一个noexcept关键字,可以保证移动构造函数中抛出来的异常会直接调用terminate程序终止运行,而不是造成指针悬挂的状态。而标准库中,我们还可以用一个std::move_if_noexcept的模板函数替代move函数。该函数在类的移动构造函数没有noexcept关键字修饰时返回一个左值引用从而使变量可以使用拷贝语义,而在类的移动构造函数有noexcept关键字时,返回一个右值引用,从而使变量可以使用移动语义。我们来看一下代码清单3-23所示的例子。

    代码清单3-23
    #include <iostream>
    #include <utility>
    using namespace std;

    struct Maythrow {
        Maythrow() {}
        Maythrow(const Maythrow&) {
            std::cout << "Maythorow copy constructor." << endl;
        }
        Maythrow(Maythrow&&) {
            std::cout << "Maythorow move constructor." << endl;
        }
    };

    struct Nothrow {
        Nothrow() {}
        Nothrow(Nothrow&&) noexcept {
            std::cout << "Nothorow move constructor." << endl;
        }
        Nothrow(const Nothrow&) {
            std::cout << "Nothorow move constructor." << endl;
        }
    };

    int main() {
        Maythrow m;
        Nothrow n;

        Maythrow mt = move_if_noexcept(m);  // Maythorow copy constructor.
        Nothrow nt = move_if_noexcept(n);   // Nothorow move constructor.
        return 0;
    }
    // 编译选项:g++ -std=c++11 3-3-8.cpp

    在代码清单3-23中,可以清楚地看到move_if_noexcept的效果。事实上,move_if_noexcept是以牺牲性能保证安全的一种做法,而且要求类的开发者对移动构造函数使用noexcept进行描述,否则就会损失更多的性能。这是库的开发者和使用者必须协同平衡考虑的。

    还有一个与移动语义看似无关,但偏偏有些关联的话题是,编译器中被称为RVO/NRVO的优化(RVO, Return Value Optimization,返回值优化,或者NRVO,Named Return Value optimization)。事实上,在本节中大量的代码都使用了-fno-elide-constructors选项在g++/clang++中关闭这个优化,这样可以使读者在代码中较为容易地利用函数返回的临时量右值。

    但若在编译的时候不使用该选项的话,读者会发现很多构造和移动都被省略了。对于下面这样的代码,一旦打开g++/clang++的RVO/NRVO,从ReturnValue函数中a变量拷贝/移动构造临时变量,以及从临时变量拷贝/移动构造b的二重奏就通通没有了。
    A ReturnRvalue() { A a(); return a; }
    A b = ReturnRvalue();

    b变量实际就使用了ReturnRvalue函数中a的地址,任何的拷贝和移动都没有了。通俗地说,就是b变量直接“霸占”了a变量。这是编译器中一个效果非常好的一个优化。不过RVO/NRVO并不是对任何情况都有效。比如有些情况下,一些构造是无法省略的。还有一些情况,即使RVO/NRVO完成了,也不能达到最好的效果。但结论是明显的,移动语义可以解决编译器无法解决的优化问题,因而总是有用的。

  • 相关阅读:
    洛谷P3513 [POI2011]KON-Conspiracy
    柱状图 三分法+树状数组
    CF习题集三
    CF习题集二
    CF习题集一
    单调队列总结
    SP688 SAM
    lemon使用方法
    洛谷 P2403 [SDOI2010]所驼门王的宝藏 题解
    字符串学习笔记二
  • 原文地址:https://www.cnblogs.com/FengZeng666/p/9364394.html
Copyright © 2011-2022 走看看