C++ copy control

概述

除了构造函数外,一个类通过定义五种特殊的成员函数:

  1. 拷贝构造函数
  2. 拷贝赋值运算符
  3. 移动构造函数
  4. 移动赋值运算符
  5. 析构函数

控制类对象的拷贝,移动,赋值和销毁时执行的各种动作,这些操作称为拷贝控制操作(copy control)。

拷贝,赋值和销毁

在含有指针数据成员的类中,通常需要定义拷贝构造函数,拷贝赋值运算符和析构函数。下面介绍一些它们的主要特点,学习它们通过例子更容易。

拷贝构造函数

  1. 拷贝构造函数通常不应该是explicit的。
  2. 在含有指针数据成员的类中,默认的拷贝构造函数会失效。
  3. 拷贝初始化和直接初始化的区别。使用=号初始化是拷贝初始化,不使用=号就是直接初始化。直接初始化时,实际上是根据传入的参数调用相应的构造函数,是函数匹配。而拷贝初始化是使用拷贝构造函数将右侧的对象拷贝到正在创建的对象那个。
  4. 什么时候会用到拷贝构造函数:
    • 使用=初始化对象时;
    • 将一个对象作为实参传递给非引用的形参时
    • 将一个对象作为实参传递给一个非引用类型时;
    • 用花括号从列表初始化一个数组中元素或者一个聚合类的成员时;
    • 某些标准容器调用insert或者push成员时。

拷贝赋值函数

  1. 在含有指针数据成员的类中,默认的赋值运算=只能将指针指向被拷贝元素的数据。
  2. 赋值运算符通常应该返回一个指向其左侧运算对象的引用。

析构函数

  1. 释放对象使用的资源,销毁对象的非static数据成员。
  2. 析构函数没有返回值,也不接受重载。

=default

=default将函数定义为合成的。只能对默认构造函数或者拷贝控制函数使用=default=default可以出现在类外定义成员函数时。

阻止拷贝

有时候某些拷贝控制函数不需要定义,比如iostream类不能拷贝,以避免多个对象写入或者读取相同的IO缓冲。可以通过将拷贝构造函数和拷贝赋值函数定义为删除的函数阻止拷贝。
对于删除的函数:我们虽然声明了它们,但是不能以任何方式使用它们。在函数的参数列表之后加上=delete将函数定义为删除的,这会通知编译器,我们不希望定义这些成员。=delete必须出现在函数第一次声明的时候,而且除了析构函数之外的任何函数都可以指定=delete。对于析构函数来说,如果声明为=delete的,那么这个对象就不能被释放了。而对于除了拷贝控制和默认构造函数之外的其他函数,定义=delete就是多次一举了,如果你不需要,直接不定义就是了,但是实际上这种做法是合法的。
**合成的拷贝控制成员可能是删除的。**如果一个类有一个数据成员不能默认构造,拷贝,复制或者销毁,则对应的成员函数被定义为删除的。比如:

  1. 类的某个成员的析构函数 是删除的或不可访问的,类的合成析构函数被定义为删除的。
  2. 类的某个成员的拷贝构造函数 是删除的或不可访问的,类的合成拷贝构造函数也被定义为删除的。
  3. 类的某个成员的拷贝赋值运算符是删除的或不可访问的,类的合成拷贝赋值运算符被定义为删除的。
  4. 类的某个成员的析构函数是删除的或者不可访问的,类的合成析构函数,合成默认构造函数,合成拷贝构造函数也被定义为删除的。
  5. 类的某个成员是没有类内初始化器的引用,或者有一个没有类内初始化器的const成员,类的默认构造函数和合成拷贝赋值运算符被定义为删除的。

拷贝控制和资源管理

通过定义拷贝操作,可以使得类的行为看起来像一个值或者像一个指针。
类的行为像值,就是说拷贝一个像值的对象时,副本和原来的对象是完全独立的,改变副本不会对原来的对象有任何影响,反之亦然。
类的行为像指针,就是说拷贝一个向指针的对象时,副本和原来的对象使用相同的底层数据,改变副本也会影响原来的对象,反之亦然。

行为像值的类

  1. 定义拷贝构造函数,完成对象内容的拷贝,而不是指针的拷贝。
  2. 定义拷贝赋值函数,需要考虑自赋值的情况。一般来说,拷贝赋值运算符组合了析构函数和拷贝构造函数的工作。
  3. 定义析构函数,释放对象占用的空间。

行为像指针的类

通过引用计数来实现。

swap函数

这个的目的就是交换指针而不是交换对象内容。

对象移动

移动而不是拷贝对象,可能会大幅度提高性能。比如vector在扩容的时候,将元素从旧内存拷贝到新内存是不必要的;另一方面,IO类或者unique_ptr等类,包含不能共享的资源,这些资源不能拷贝但是可以移动。
标准库容器,string和shared_ptr,即支持移动也支持拷贝。IO类和unique_ptr类可以移动但是不能拷贝。

右值引用

  1. 右值引用只能绑定到临时对象,所以它有两个属性:它所引用的对象将要被销毁,并且该对象没有其他用户。
  2. 左值引用不能绑定到要求转换的表达式,字面常量或者是返回右值的表达式。而右值引用可以绑定到上述三类表达式,但是不能将一个右值绑定到一个左值上。
    返回左值表达式的例子:返回左值引用的函数,赋值,下标,解引用和前置递增递减运算符。可以将一个左值引用绑定到这类表达式的结果上。
    返回右值表达式的例子:返回非引用类型的函数,算符,关系,位和后置递增递减运算符。不能将左值引用绑定到这类表达式上,但是可以将一个const的左值引用或者右值引用绑定到这类表达式上。
  3. 变量是左值。变量可以看做只有一个运算对象而没有运算符的表达式。变量表达式都是左值。
  4. move函数。不能将一个右值引用直接绑定到左值上,但是可以使用utility头文件中的move函数将一个左值转换成对应的右值引用类型。

移动构造函数和移动赋值函数

  1. 不抛出异常的移动构造函数和移动赋值运算符必须标记为noexcept。
    移动操作通常不抛出异常,但是抛出异常也是允许的;当异常发生时,标准库容器能够为自身的行为提供保障。比如,vector保证,调用push_back发生异常时,vector自身不会改变。所以,在调用push_back需要重新分配内存时,使用拷贝构造函数抛出异常时,会保证原来的vector不受影响,但是如果调用能够抛出异常的移动构造函数,在调用失败的时候,原来的vector会受到影响,不能保证vector自身不变。
    所以,只有将移动构造函数声明为noexcept时,明确说明调用移动构造函数不会抛出异常,在vector扩容重新进行内存分配的时候,才会使用拷贝构造函数。
  2. 合成的移动操作。如果一个类定义了自己的拷贝构造函数,拷贝赋值运算符或者析构函数,编译器就不会为它合成移动构造函数和移动赋值运算符。当一个类没有定义任何自己版本的拷贝控制函数,并且类的每个非static数据成员都可以移动时,才会合成移动构造函数或者移动赋值函数。
  3. 如果类定义了一个移动构造函数或者一个移动赋值运算符,类的合成拷贝构造函数和合成拷贝赋值运算符会被定义成删除的。

成员函数和右值引用

成员函数的参数可以是左值或右值

除了构造函数和赋值运算符之外,一个成员函数也可以同时提供拷贝和移动版本:一个版本接收一个指向const的左值引用,一个版本接收一个指向非const的右值引用。

左值或者右值调用成员函数

  1. C++ 可以对右值进行赋值。C++ 11中可以阻止这种用法,通过引用限定符强制左侧运算对象是一个左值。
  2. 在参数列表后放置一个引用修饰符,&或者&&,分别指出this可以指向一个左值或者右值。
  3. 类似const限定符,引用限定符只能用于非static的成员函数,并且需要同时出现在声明和定义中。
  4. 对于&修饰的函数,只能将它用于左值,对于&&修饰的函数,只能将它用于右值。
  5. 一个函数既可以用const也可以用引用限定,当它们同时出现时,引用限定符必须跟随在const限定符之后。
  6. 引用限定符可以区分重载函数。当出现两个或者两个以上具有相同名字或者参数列表的函数时,必须同时有引用修饰符或者同时没有。

参考文献

1.《C++ Primer》第五版