zoukankan      html  css  js  c++  java
  • C++ Primer 5th 第13章 拷贝控制

    当定义一个类时,我们显式或者隐式地指定该类的对象在拷贝、移动、赋值和销毁时做什么。一个类通过定义五个特殊成员来控制这些操作,包括:拷贝构造函数、拷贝赋值运算符、移动构造函数、移动赋值运算符和析构函数。

    拷贝构造/移动构造:定义用同一类型的一个对象初始化另一个对象时做什么;

    拷贝赋值/移动赋值:定义用一个对象对另一个对象进行赋值运算时做什么;

    析构函数:定义对象销毁时做什么。

    以上这些函数称为拷贝控制操作,如果一个类没有定义这些拷贝控制成员,编译器会自动定义缺失的操作。很多用户自定义类会忽略这些拷贝控制操作,这是允许的,但是对于某些类来说,以来编译器定义的会造成很大问题。

    拷贝构造函数

    对于这样的构造函数我们认为它是拷贝构造函数:构造函数的第一个形参是自身类类型的引用,并且没有其余的形参,或者剩余的形参都具有默认值。例如:

    class Foo
    {
    public:
        Foo();
        Foo(const Foo &f);               //拷贝构造函数
        Foo(const Foo &f, int i = 0);    //拷贝构造函数
    
    };        

    拷贝构造函数的第一个参数总是引用类型,如果我们没有为一个类定义拷贝构造函数,编译器会替我们定义一个,唯一一个例外是:如果我们定义了移动构造函数,则不会合成拷贝构造函数。除非我们显式delete拷贝构造函数或者定义了移动构造函数,否则类一定会有一个拷贝构造函数。

    对于默认构造函数来说,如果我们任意定义了一种类型的构造函数,编译器不会定义生成默认构造函数,这会导致该类不能默认初始化。

    对于拷贝构造函数来说,如果我们任意定义了一种类型的构造函数,但缺少拷贝构造函数,编译器会定义生成一个拷贝构造函数,使得我们能够用一个类对象初始化另一个对象。

    拷贝构造函数会逐一拷贝每个非static数据成员,对于类类型成员使用它的拷贝构造函数来拷贝,对内置类型直接拷贝,对于数组类型数据成员会逐元素拷贝。

     

    拷贝初始化

    关于拷贝初始化,在我的博客:C++的各种初始化方法,这里有做说明。至于拷贝初始化,我们记住它的形式是使用“=”进行的,以及几个额外情况会进行拷贝初始化,比如传值形式参结合,传值返回值,异常抛出等。另外就是编译器可以对拷贝初始化进行优化。

    拷贝赋值运算符

    拷贝赋值运算符是用来控制已存在的类对象间如何赋值。拷贝赋值运算符是通过运算符重载实现的,运算符重载本质上是一些特殊的函数,总是以关键词operator后接要定义的运算符组成。赋值运算符就是operator=函数,和普通函数一样,运算符函数一样有形参列表和返回值。

    重载运算符的参数表示运算对象,运算符函数可以作为成员函数,也可以作为友元函数。

    拷贝赋值运算符通常是将运算符左侧的对象以引用方式返回。例如:

    test& test::operator=(const test &t)
    {
        //成员拷贝
        return *this;
    }

    同拷贝构造函数类似,如果我们未定义自己的拷贝赋值运算符,编译器会自动定义一个合成拷贝赋值运算符。拷贝赋值运算符会逐成员拷贝,对于数组类型成员,会逐元素拷贝。合成拷贝赋值运算符会返回一个指向左侧运算对象的引用。

    析构函数

    析构函数和构造函数刚好相反,析构函数用于释放对象所使用的资源,并销毁非static的数据成员。

    析构函数是类的成员函数,由一个波浪号~后跟类名构成,它没有返回值,也不能接收参数,即它不可被重载,因此一个类只有一个析构函数,形式如下:

    test::~test()
    {
        //destructor
    }

    构造函数有用于初始化的初始化列表,并且先执行初始化部分,再执行构造函数体。析构函数不能像构造函数那样接受参数,也没有所谓的“析构列表”,析构函数的析构部分是隐式的。析构函数的执行顺序是先执行函数体,再执行析构部分销毁成员,成员销毁的顺序是逆序的。

    无论何时,一个对象被销毁都会调用析构函数。以下几种情况会调用:

    1.对象超出作用域;

    2.当一个类含有类成员,该类对象被销毁,类成员也销毁;

    3.容器被销毁时,类对象元素也被销毁;

    4.临时对象使用后。

    以下情况析构函数不会调用: 

    1.当一个对象的引用或者指针离开作用域时,析构函数不会执行。

    类似拷贝构造函数和拷贝赋值运算符,当类未定义析构函数时,编译器会为它定义合成析构函数。再次说明,析构函数体不直接销毁成员,成员是在函数体执行之后的隐式析构部分销毁的。

    三五法则

    在新标准下,一共可以定义五个关于类的拷贝控制操作。C++语言并不要求我们定义所有这些操作:可以只定义一个或两个,而不必定义所有。但通常这些操作应该被视为一个整体。

    如果一个类需要一个析构函数,那么基本上必然需要一个拷贝构造和拷贝赋值操作。一个显然的例子是,如果我们在类中分配了动态内存,那么我们就必须手动在析构函数中进行释放。同样的,我们也需要提供拷贝构造和拷贝赋值来正确处理动态内存的指针。

    如果一个类需要拷贝构造或者拷贝赋值,它不一定需要析构函数。举例来说,一个类为每个对象分配一个独一无二的序号,那么在拷贝构造函数初始化对象时,需要避免直接拷贝序号,而是要新生成一个序号。另外,类还需要一个拷贝赋值操作以避免序号被直接赋值拷贝给另一个对象。一般来说,类如果需要拷贝构造,那么它也需要拷贝赋值,反之亦然。

    =default

    我们可以使用=default来让编译器替我们生成“合成版本”的拷贝控制成员。当在类内部使用=default时,合成的成员函数是内联函数,且属于编译器合成版本。当在类外部使用=default时,合成的成员函数不是内联的,且属于用户自定义版本。

    阻止拷贝

    大多数类应该定义拷贝构造、拷贝赋值这些操作,但对于某些特殊的类,这些操作却属于无意义操作,需要加以阻止。比如流类型不允许拷贝构造和赋值,这种类型就需要对拷贝构造和拷贝赋值操作加以阻止。

    如果我们不定义拷贝构造和拷贝赋值,编译器会自行合成。在新标准下,我们可以使用=delete来将函数声明为删除的。删除函数是声明了的函数,但是不能以任何方式使用它。删除函数只允许声明而不允许定义。

    与=default不同,=delete可以用于任何成员函数。

    析构函数不应该是delete的。如果析构函数被声明为=delete,该类的对象无法销毁,那么编译器会拒绝创建该类型的对象(包括临时对象)。如果类的类成员的析构函数被声明为=delete,那么该成员不能销毁,进而导致含有该类成员的类对象也不能定义和销毁。如果要创建析构函数被声明为=delete的类对象,只能通过new动态分配内存来创建,但该动态内存不允许释放。

    合成的拷贝控制成员可能是删除的

    编译器自动为我们合成的拷贝控制成员并不一定总是可以正常使用的,也可能合成的是被删除版本的拷贝控制成员,这些操作虽然被编译器生成,但并不能使用。以下几种情况都是不可用的拷贝控制成员:

    1.如果类的类成员的析构函数是delete或private的,则类的合成析构函数和合成拷贝构造函数是delete的;(因为成员无法析构,导致包含该成员的对象也无法析构)

    2.如果类的类成员的拷贝构造函数是delete或private的,则类的合成拷贝构造函数是delete的;(因为成员无法拷贝,导致包含该成员的对象也无法拷贝)

    3.如果类的类成员的拷贝赋值运算符是delete或private的,则类的合成拷贝赋值运算符是delete的;(因为成员无法拷贝赋值,导致包含该成员的对象也无法拷贝赋值)

    4.如果类的数据成员是const类型或者是引用类型,则类的合成拷贝赋值运算符是delete的;(因为拷贝赋值运算符会逐成员赋值,而const成员和引用类型成员只允许初始化)

    5.如果类的类成员的析构函数是delete或private的,则类的合成默认构造函数是delete的;(因为不允许定义该类成员,导致包含该成员的对象也无法定义)

    这些规则比较多且复杂,简单记忆为:如果一个类的数据成员不能默认构造、拷贝、赋值、析构,那么该类对应版本的拷贝控制成员被定义为delete的。

    在C++11新标准之前,阻止拷贝控制操作的方法是将拷贝控制成员声明为private的。这使得该类或者继承了该类的派生类都无法拷贝构造或者拷贝赋值类的对象,但是友元函数和类的成员函数依然可以进行拷贝构造和拷贝赋值。为了彻底阻止拷贝构造和拷贝赋值,只能对它们声明,而不定义它们,以使得有友元函数或者成员函数在使用它们时将被编译器报告错误。

    拷贝控制和资源管理

    如前所述,一个类一旦需要析构操作,那他们基本上也需要一个拷贝构造和拷贝赋值操作。类的拷贝构造和拷贝赋值操作有两种语义:类值(like a value)行为类指针(like a pointer)行为。

    类值行为:类的每个对象有自己的状态。当拷贝一个类对象时,原对象和副本是完全独立的,对任意一个的改变不会影响另一个,类似于一个电视机一个遥控器。string标准库是类值行为类。

    类指针行为:类的对象之间共享状态。当拷贝一个类对象时,原对象和副本共享同一份数据,对任意一个的改变会影响到另一个,类似于一个电视剧多个遥控器。shared_ptr标准库是类指针行为类。

    对于既不能拷贝构造,又不能拷贝赋值的类,它的行为既不是类值,也不是类指针。

    交换操作

    除了定义拷贝控制成员,管理资源的类一般还会定义一个swap函数用于代码优化。通过标准库提供的泛型算法来对该类的对象们进行排序时,swap函数尤为重要。由于标准库的std::swap不可能访问我们类的内部,因此std::swap为了实现交换必然要经过拷贝对象和赋值对象。对于类值行为的类,拷贝和赋值其对象会引起内存的重新分配。理论上,这些内存分配是不必要的,因为我们完全可以通过交换指针达到结果。我们可以为自己的类定义一个友元swap函数用于交换。

    拷贝交换的赋值运算符能很巧妙的处理自赋值情况,且天生异常安全。

    书本中关于swap的作用域和重载的相关知识将在16章和17章中学习。

    对象移动

    C++11新标准一个重要改进是提供对象的移动而非拷贝的能力。移动语义通常与类的类值行为相关。

    在学习右值的内容之前,先回忆一下左值。左值是具名的、持久的,我们可以使用&对其进行取地址运算,一般出现在赋值运算符=的左侧。左值绑定时,要求所绑定的对象不能是字面常量,也不能是返回右值的表达式。例如:

    int i;        //i是左值
    int *p = &i;     //可以对i进行取地址运算
    i = 5;        //i可以出现在赋值运算符的左侧
    int &ri = i;    //i可以被ri绑定    

      

    右值有着和左值完全相反的特性:右值可以绑定到一个返回右值的表达式,或者绑定到字面常量,但不能绑定到左值。它使用&&来进行右值绑定,如下:

    int f();          //函数声明
    f();              //f()返回值是右值
    f() = 5;          //错误,f()返回值不能出现在赋值运算符的左侧
    int &&ri = f();   //f()返回值可以被ri绑定

    一个特殊的点是,对于const左值引用既可以绑定到左值,又可以绑定到右值。

    通常赋值运算符、下标运算符、解引用和前置递增/递减都是返回左值,对于有些表达式可能返回的是左值,比如一个返回值方式为引用的函数,当对该函数使用调用运算符( )进行计算时,返回值的左值。

    算术运算、关系运算、后置递增/递减,以及普通方式返回值的函数都生成右值。

    右值只能绑定到临时对象,要么是即将销毁的对象,要么是该对象没有其他用户(或者说匿名对象)。

    右值可以绑定到字面常量,当一个右值引用绑定到字面常量之后,该变量就成为一个左值。例如:

    int &&ri = 5;
    ri = 6

    ri右值绑定到字面常量5,随后ri是一个普通的左值。以上的过程是,首先编译器在内存中为字面常量5开辟一块一个int类型的内存,然后用5初始化该匿名内存,由于该内存中的对象无名,没有对象使用它,因此它是一个右值,随后我们使用ri对此右值进行绑定,赋予该内存一个名字ri,之后我们可以通过ri来引用这个变量,因此ri理所当然的是一个左值。

    右值引用只能绑定到一个右值上,无法绑定到左值上,但有时编写代码的用户知道自己代码中的左值对象连同对象所使用的资源很快会被销毁,从逻辑上来说,该左值应该允许被绑定到右值引用上,这些我们需要显式进行类型转换。标准库提供了一个叫做std::move的工具,它定义在下列头文件中:

    #inlcude <utility>

     std::move的作用是将传递给它的参数从左值转换为右值类型,使用std::move之后,原对象只可写不可读,因为其内容已被移走,要么我们销毁原对象,要么我们重新写入新的数据。

    移动构造函数和移动赋值运算符

    为了让我们自己的类类型能够支持移动操作,需要为其定义移动构造函数和移动赋值运算符。这两个成员函数对应类似的拷贝操作,但是它们从给定对象中”窃取“资源而不是拷贝资源。

    类似拷贝构造函数,移动构造函数的第一个参数应该是该类的引用,但不是左值引用,而是右值引用。如果构造函数除了第一个参数是右值引用,还存在其他参数,但其他参数都有默认实参,那么该构造函数也是一个移动构造函数。

    移走原对象包含的资源之后,原对象必须能够正常析构。可以借助Windows编程或者Java语言中的一个惯用的术语”句柄“来理解移动资源,句柄和句柄所关联的资源是一个整体,通过句柄来管理其关联的资源,就像遥控器和电视,我们可以使用遥控器来任意控制电视。通过移动资源功能,我们把一个句柄管理的资源转移给另一个句柄,原句柄失去了资源,新句柄获得了资源,这个资源不需要先拷贝一份,然后再把旧资源销毁,我们仅仅转移了资源的所有权。移动构造除了移走资源,还要负责做好原对象的资源再分配(一般置为空),以便能正常析构原对象。

    移动操作绝不应该抛出异常。因为它并不分配任何新资源,因此它不太可能会抛出异常。当我们编写一个不抛出异常的移动操作时,我们应该事先通知编译器它不抛出异常。除非编译器知道我们的移动构造函数不会抛出异常,否则它会认为移动构造函数可能会抛出异常,并为这种可能性做额外处理工作。

    如果通知移动操作不抛出异常?方法是在函数形参列表后面使用noexcept,而且必须在函数的声明和定义处都使用该关键词。

    虽然移动操作不应该抛出异常,但是它本身是能够被抛出异常的。

    移动赋值运算符执行两部分工作,一是移动构造,二是析构函数,第一个用来移动运算符右侧元素,第二个用来销毁运算符左侧元素。

    合成的移动操作

    和拷贝构造函数和拷贝赋值运算符一样,编译器也有可能替我们合成移动构造函数和移动赋值运算符。如果我们自己不声明拷贝构造函数和拷贝赋值运算符,编译器一定会替我们合成拷贝控制操作和移动操作,合成的操作要么是具有类指针行为的拷贝控制成员,要么是由于含特殊成员而合成的阻止拷贝控制的拷贝控制成员。

    如果一个类定义了自己的拷贝控制成员(拷贝构造、赋值运算符、析构函数),那么编译器不会合成移动操作。

    如果一个类没有定义任何拷贝控制成员,并且每个非static数据成员都可移动,此时编译器会替我们合成拷贝控制操作和移动操作。

    如果编译器替我们合成了拷贝控制成员,那么这些拷贝控制成员可能是delete的,但移动操作不同,如果编译器合成了移动操作,那么移动操作就一定可用,如果将要合成的移动操作不可用,那么编译器不会合成它们,此时,如果我们强制要求编译器生成=default,那么它们会被定义为delete的。具体情况如下:

    1.有类成员定义了自己的拷贝构造函数但没用定义移动构造函数,则=default要求合成的移动构造函数被定义为删除的。移动赋值运算符与此类似。(因为数据成员不可移动,则包含该数据成员的对象也不可移动)

    2.有类成员未定义自己的拷贝构造函数但编译器不能为该成员合成移动构造函数,则=default要求合成的移动构造函数被定义为删除的。移动赋值运算符与此类似。(因为数据成员不可移动,则包含该数据成员的对象也不可移动)

    3.如果有类成员的移动构造函数被定义为delete的或者是private的,则=default要求合成的移动构造函数被定义为删除的。移动赋值运算符与此类似。(因为数据成员不可移动,则包含该数据成员的对象也不可移动)

    4.如果有类成员的析构函数被定义为delete的或者private的,则=default要求合成的移动构造函数被定义为删除的。(因为数据成员不允许实例化导致包含该数据成员的对象也无法实例化,因为不存在移动一说)

    5.如果类的数据成员是const类型或者是引用类型,则=default要求合成的移动赋值运算符被定义为删除的。(因为移动赋值运算符会改变数据成员,而const成员和引用类型成员只允许初始化)

    如前所述,如果一个类定义了自己的移动构造函数和/或移动赋值运算符,则类的合成拷贝构造函数和/或拷贝赋值运算符会被定义为删除的。

    如果一个类既有移动构造函数,又有拷贝构造函数,编译器将会使用函数重载规则来决定最佳的匹配函数,赋值运算符的情况类似。

    如果一个类定义了拷贝构造函数,那么编译器不会合成移动构造函数,这时如果我们使用右值性质的对象来初始化另一个对象,编译器会使用拷贝构造版本的构造函数来进行初始化,因为一般情况下,拷贝构造函数会将原对象置于有效状态,且不改变原对象的值,所以我们会定义const引用类型版本的拷贝构造函数,并且const引用类型版本能够比非引用版本适用的情况更广。由于const引用既可以绑定左值,又可以绑定到右值,所以如果我们没有定义移动构造函数,且使用右值性质的对象来初始化另一个对象,那么obj1 &&能够转换到const obj2 &。

    移动迭代器

    标准库提供了一个工具,可用于将普通返回左值的迭代器转换为一个能返回右值的迭代器,它包含在头文件下列头文件中:

    #include <iterator>

    含右值引用的成员函数

    除了三五法则中的移动构造函数和移动赋值运算符能够使用右值引用来移动对象,我们自定义的普通类成员函数也可以提供拷贝和移动两种重载的版本:一个是const &版本用于拷贝,一个是&&版本,用于移动。比如提供有push_back功能标准库的容器存在两个版本:

    void push_back(const X &x);    //拷贝版本,绑定任意类型实参
    void push_back(X &&x);         //移动版本,精确匹配右值对象

     上述形参的类型是有讲究的,对于拷贝版本,通常是const &的,因为不会改变原对象。而对于移动版本,不能是const &&的,因为我们要移动意味着要改变原对象,所以右值对象必须能被改变。

    引用限定符

    对于下面的例子,可以对一个右值类型的对象进行赋值:

    1 string s1 = "hello,";
    2 string s2 = "world.";
    3 s1 + s2 = "wow."

    对于第3行中的代码,其执行顺序是首先s1+s2,得到一个右值类型的临时对象"hello,world.",然后该对象调用赋值运算符进行赋值运算。此处对一个右值进行了赋值操作,关键点在于赋值运算符是一个成员函数,成员函数运算符重载时,运算符左侧的对象隐式的绑定到this指针上,以至于我们无法控制赋值运算符左侧运算对象的性质(因为它是自动和隐式进行的)。这样一来就使得右值属性的对象能够被赋值。为了阻止这样的事情,C++中可以使用引用限定符来加以控制。

    引用限定符的语法是在成员函数的声明和定义处使用&和&&来指明该函数this指针所绑定的对象的属性,&和&&出现在函数的形参列表后面,若成员函数还有const关键词,那么&和&&必须放在const关键词后面。

    成员函数可以根据是否是const类型来进行重载,引用限定符也支持根据&或者是&&来进行重载。

  • 相关阅读:
    计算机组成原理学习总纲图
    USE RED
    既有的问题如何解决
    字符串极值题解
    扩展 KMP
    KMP
    FHQ-Treap
    STL
    iOS内存管理理论知识过一遍
    iOS中Block理论知识过一遍
  • 原文地址:https://www.cnblogs.com/pluse/p/5792146.html
Copyright © 2011-2022 走看看