第13章 拷贝控制(上)

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

(copy constructor)、拷贝赋值运算符(copy-assignment operator)、移动构造函数
(move constructor)、移动赋值运算符(move-assignment operator)和析构函数(destructor)。
拷贝和移动构造函数定义了当用同类型的另一个对象初始化本对象时做什么。
拷贝和移动赋值运算符定义了将一个对象赋予同类型的另一个对象时做什么。
析构函数定义了当此类型对象销毁时做什么。
我们称这些操作为拷贝控制操作(copy control)。

13.1 拷贝、赋值和销毁
  • 拷贝构造函数
  1. 如果一个构造函数的第一个参数是自身类类型的引用,且任何额外参数都有默认值,则此构造函数是拷贝构造函数。

    拷贝构造函数的第一个参数必须是一个引用类型,虽然我们可以定义一个接受非const引用的拷贝构造函数,但此参数几乎总是一个const引用。
    拷贝构造函数在几种情况下都会被隐式地使用。因此,拷贝构造函数通常不应该是explicit的。
  2. 合成拷贝构造函数(synthesized copy constructor)
    如果我们没有为一个类定义拷贝构造函数,编译器会为我们定义一个。与合成默认构造函数不同,即使我们定义了其他构造函数,编译器也会为我们合成一个拷贝构造函数。
    每个成员的类型决定了它如何拷贝:对类类型成员,会使用其拷贝构造函数来拷贝;内置类型的成员则直接拷贝。Sales_data类的合成拷贝构造函数等价于:
  3. 拷贝初始化(copy initialization)
    现在,我们可以完全理解直接初始化和拷贝初始化之间的差异了

    当我们使用拷贝初始化时,我们要求编译器将右侧运算对象拷贝到正在创建的对象中,如果需要的话还要进行类型转换。拷贝初始化通常通过拷贝构造函数来完成,但是,如果一个类有一个移动构造函数,则拷贝初始化有时会使用移动构造函数而非拷贝构造函数来完成。
    拷贝初始化不仅在外面用=定义变量时会发生,在下列情况下也会发生:
    (1)将一个对象作为实参传递给一个非引用类型的形参。
    (2)从一个返回类型为非引用类型的函数返回一个对象。
    (3)用花括号列表初始化一个数组中的元素或一个聚合类中的成员。
    某些类类型还会对它们所分配的对象使用拷贝初始化。例如,当我们初始化标准库容器或是调用其insert或push成员时,容器会对其元素进行拷贝初始化。与之相对,用emplace成员创建的元素都进行直接初始化。
  4. 参数和返回值
    在函数调用过程中,具有非引用类型的参数要进行拷贝初始化。
    拷贝构造函数被用来初始化非引用类类型参数,这一特性解释了为什么拷贝构造函数自己的参数必须是引用类型。如果其参数不是引用类型,则调用永远也不会成功——为了调用拷贝构造函数,我们必须拷贝它的实参,但为了拷贝实参,我们又需要调用拷贝构造函数,如此无线循环。
  • 拷贝赋值运算符

与控制其对象如何初始化一样,类也可以控制其对象如何赋值:

  1. 重载赋值运算符
    重载运算符本质上是函数,其名字由operator关键字后接表示要定义的运算符的符号组成。因此,赋值运算符就是一个名为operator=的函数。
    某些运算符,包括赋值运算符,必须定义为成员函数。如果一个运算符是一个成员函数,其左侧运算对象就绑定到隐式的this参数。对于一个二元运算符,例如赋值运算符,其右侧运算对象作为显式参数传递。

    为了与内置类型的赋值保持一致,赋值运算符通常返回一个指向其左侧运算对象的引用。
    TIP:赋值运算符通常应该返回一个指向其左侧运算对象的引用。
  2. 合成拷贝赋值运算符
    作为一个例子,下面的代码等价于Sales_data的合成拷贝赋值运算符:
  3. 析构函数
    析构函数执行与构造函数相反的操作:构造函数初始化对象的非static数据成员,还可能做一些其他工作;析构函数释放对象使用的资源,并销毁对象的非static数据成员。
    析构函数是类的一个成员函数,名字由波浪号接类名构成,没有返回值,也不接受参数:

    由于析构函数不接受参数,因此它不能被重载。对一个给定类,只会有唯一一个析构函数。
    NOTE:当指向一个对象的引用或指针离开作用域时,析构函数不会执行。
  4. 合成析构函数(synthesized destructor)
    当一个类未定义自己的析构函数时,编译器会为他定义一个合成析构函数。
    例如,下面的代码等价于Sales_data的合成析构函数:

    在(空)析构函数体执行完毕后,成员会被自动销毁。特别的,string的析构函数会被调用,它将释放bookNo成员所用的内存。
    认识到析构函数本身并不直接销毁成员是非常重要的。成员是在析构函数体之后隐含的析构阶段中被销毁的。在整个对象销毁过程中,析构函数体是作为成员销毁步骤之外的另一部分而进行的。
  • 三/五法则

如前所述,有三个基本操作可以控制类的拷贝操作:拷贝构造函数、拷贝赋值运算符和析构函数。而且,在新标准下,一个类还可以定义一个移动构造函数和一个移动赋值运算符。

  1. 需要析构函数的类也需要拷贝和赋值操作
    当我们决定一个类是否要定义它自己版本的拷贝控制成员时,一个基本的原则是首先确定这个类是否需要一个析构函数。如果这个类需要一个自定义析构函数,我几乎可以肯定它也需要自定义拷贝构造函数和自定义拷贝赋值运算符。
  2. HasPtr这个类在构造函数中分配动态内存。合成析构函数不会delete一个指针数据成员。因此,此类需要定义一个析构函数来释放构造函数分配的内存。
    TIP:如果一个类需要自定义析构函数,几乎可以肯定它也需要自定义拷贝赋值运算符和拷贝构造函数。
  3. 需要拷贝操作的类也需要赋值操作,反之亦然
    虽然很多类需要定义所有(或是不需要定义任何)拷贝控制成员,但某些类所要完成的工作,只需要拷贝或赋值操作,不需要析构函数。
    TIP:第二个基本原则——如果一个类需要一个拷贝构造函数,几乎可以肯定它也需要一个拷贝赋值运算符。反之亦然。
  • 使用=default
    我们可以通过将拷贝控制成员定义为=default来显示地要求编译器生成合成的版本

    当我们在类内使用=default修饰成员的声明时,合成的函数将隐式地声明为内联的。如果我们不希望合成的成员是内联函数,应该只对成员的类外定义使用=default,就像对拷贝赋值运算符所做的那样。
    NOTE:我们只能对具有合成版本的成员函数使用=default(即,默认构造函数或拷贝控制成员)。
  • 阻止拷贝

大多数类应该定义默认构造函数、拷贝构造函数和拷贝赋值运算符,无论是隐式地还是显示地。

但是,在某些情况下,定义类时必须采用某种机制阻止拷贝或赋值。例如,iostream类阻止了拷贝,以避免多个对象写入或读取相同的IO缓冲。
为了阻止拷贝,看起来可能应该不定义拷贝控制成员。但是,这种策略是无效的:如果我们的类未定义这些操作,编译器为它生成合成的版本。

  1. 定义删除的函数
    在新标准下,我们可以通过将拷贝构造函数和拷贝赋值运算符定义为删除的函数(deleted function)来阻止拷贝。删除的函数是这样一种函数:我们虽然声明了它们但不能以任何方式使用它们:

    (1)=delete通知编译器(以及我们代码的读者),我们不希望定义这些成员;(2)与=default不同,=delete必须出现在函数第一次声明的时候;(3)与=default的另一不同之处是,我们可以对任何函数指定=delete(我们只能对编译器可以合成的默认构造函数或拷贝控制成员使用=default)。
  2. 析构函数不能是删除的成员
    值得注意的是,我们不能删除析构函数。如果析构函数被删除,就无法销毁此类型的的对象了。
    WARNING:对于析构函数已删除的类型,不能定义该类型的变量或释放指向该类型动态分配对象的指针。
  3. 合成的拷贝控制成员可能是删除的(P450)
    如果一个类有数据成员不能默认构造、拷贝、复制或销毁,则对应的成员函数将被定义为删除的。
    NOTE:本质上,当不可能拷贝、赋值或销毁类的成员时,类的合成拷贝控制成员就被定义为删除的。
  4. private拷贝控制
    在新标准发布之前,类是通过将其拷贝构造函数和拷贝赋值运算符声明为private的来阻止拷贝:

    为了阻止友元和成员函数进行拷贝,我们将这些拷贝控制成员声明为private的,但并不定义它们。
    声明但不定义一个成员函数是合法的。
    NOTE:希望阻止拷贝的类应该使用=delete来定义它们自己的拷贝构造函数和拷贝赋值运算符,而不应该将它们声明为private的。
13.2 拷贝控制和资源管理

为了定义这些成员,我们首先必须确定此类型对象的拷贝语义。一般来说,有两种选择:可以定义拷贝操作,使类的行为看起来像一个值或者像一个指针。
类的行为像一个值,意味着它应该也有自己的状态。当我们拷贝一个像值的对象时,副本和原对象是完全独立的。改变副本不会对原对象有任何影响,反之亦然;
行为像指针的类则共享状态。当我们拷贝一个这种类的对象时,副本和原对象使用相同的底层数据。改变副本也会改变原对象,反之亦然。

  1. 行为像值的类类值版本的HasPtr如下所示:

    在类内定义了除赋值运算符之外的所有成员函数。第一个构造函数接受一个(可选的)string参数,这个构造函数动态分配它自己的string副本,并将指向string的指针保存在ps中;拷贝构造函数也分配它自己的string副本;析构函数对指针成员ps执行delete,释放构造函数中分配的内存。
  2. 类值拷贝赋值运算符赋值运算符通常组合了析构函数和构造函数的操作:(1)类似析构函数,赋值操作会销毁左侧运算对象的资源;(2)类似拷贝构造函数,赋值操作会从右侧运算对象拷贝数据。
    本例中,通过先拷贝右侧运算对象,我们可以处理自赋值情况,并能保证异常发生时代码也是安全的。在完成拷贝后,我们释放左侧运算对象的资源,并更新指针指向新分配的string:

    关键概念:赋值运算符
    当你编写赋值运算符时,有两点需要记住:
    (1)如果将一个对象赋予它自身,赋值运算符必须能正确工作。
    (2)大多数赋值运算符组合了析构函数和拷贝构造函数的工作。
    当你编写一个赋值运算符时,一个好的模式是先将右侧运算对象拷贝到一个局部临时对象中。当拷贝完成后,销毁左侧运算对象的现有成员就是安全的了。一旦左侧运算对象的资源被销毁,就只剩下将数据从临时对象拷贝到左侧运算对象的成员中了。
    为了说明防范自赋值操作的重要性,看下面一个错误的例子

    如果rhs和本对象是同一个对象,delete ps会释放*this和rhs指向的string。接下来,当我们在new表达式中试图拷贝*(rhs.ps)时,就会访问一个指向无效内存的指针,其行为和结果是未定义的。
  3. 定义行为像指针的类(P455)

发表评论

电子邮件地址不会被公开。 必填项已用*标注