第十三章 拷贝控制

一些术语

  • 构造函数:
    • (合成的)[[ch07-类#^15282e|默认构造函数]]:编译器创建或是使用=default修饰的构造函数
    • 一般的构造函数
    • 拷贝构造函数
    • 转换构造函数(或称为[[ch07-类#隐式的类型转换|隐式的类型转换]])
    • 移动构造函数
    • [[ch07-类#委托构造函数 (delegating constructor)|委托构造函数]]
  • 初始化类型:
    • 默认初始化:int* a = new int;
    • 值初始化:int *a = new int(); // 默认a=0
    • 直接初始化:int *a = new int(1);
    • 拷贝初始化:=
    • 列表初始化:{}
  • 拷贝控制操作(copy control)(或者称为拷贝控制成员):一个类通过定义五种特殊的成员函数来控制对象的拷贝、移动、赋值、和销毁操作
    • 拷贝构造函数(copy constructor)
    • 拷贝赋值运算符(copy-assignment operator)
    • 移动构造函数(move constructor)
    • 移动赋值函数(move-assignement operator)
    • 析构函数(destructor)

13.1 拷贝、赋值和销毁

拷贝构造函数

  • 拷贝构造函数:
    • 第一个参数是自身类型的引用(而且一般是const引用,否则导致无限递归,因为传递实参本身就是拷贝),且其他参数都有默认值
    • 通常不会声明为explicit的
  • 合成的拷贝构造函数:
    • 编译器将参数的非static成员逐个拷贝到正在创建的对象中。
    • 对于某些类,合成的拷贝构造函数使用=delete来禁止对该类型对象的拷贝
  • 拷贝初始化:
    • 通常使用拷贝构造函数完成,但也可能使用移动构造函数
    • 出现场景:
      • =定义变量时。
      • 将一个对象作为实参传递给一个非引用类型的形参。
      • 从一个返回类型为非引用类型的函数返回一个对象。
      • 用花括号列表初始化一个数组中的元素或者一个聚合类中的成员。

拷贝赋值运算符

  • 重载赋值运算符:
    • 通常返回一个指向其左侧运算对象(即自身)的引用:return *this;
  • 合成拷贝赋值运算符:
    • 将右侧运算对象的每个非static成员赋予左侧运算对象的对应成员,之后返回左侧对象的引用
    • 对于某些类,合成的拷贝赋值运算符使用=delete来禁止对该类型对象的赋值

析构函数

  • 析构顺序:
    • 先执行析构函数体:因为销毁指针并不会delete它所指的对象,因此需要手动释放空间
    • 再执行析构部分,按初始化顺序的逆序销毁非static的数据成员

三/五法则

  • 背景:一个类通常需要拷贝构造函数、拷贝赋值运算符、析构函数(和移动构造函数、移动赋值运算符),虽然通常不会全部自定义,但是有时需要将这些拷贝控制成员看作一个整体
  • 三五法则:
    • 一个需要自定义析构函数的类,一定也需要一个拷贝构造函数和拷贝赋值运算符(比如类中有一个指向动态内存的指针,使用合成版本的构造函数只会复制指针的值)
    • 一个需要自定义拷贝构造函数的类,也一定需要一个拷贝赋值运算符,反之亦然;但是未必需要析构函数(比如每个类需要有一个唯一id)

显式合成=default

  • 可以通过将拷贝控制成员定义为=default来显式地要求编译器生成合成的版本。
    • 只能对具有合成版本的成员函数(即默认构造函数或拷贝控制成员)使用=default
    • 在类内部使用=default修饰成员声明时,合成的函数是隐式内联的;如果不希望合成的是内联函数,应该只对成员的类外定义使用=default

阻止拷贝=delete

  • 删除的函数=delete:虽然声明了该函数,但是不能使用它们
  • 语法相关:
    • =delete只能出现在函数第一次声明的地方(即告诉编译器不定义这些函数)
    • 可以对任何函数(除了析构函数,否则动态分配了对象后无法释放)使用
    • 如果一个类有数据成员不能默认构造、拷贝、复制或销毁,则对应的构造、拷贝、复制、析构函数被定义为删除的。原文
      • 如果类的某个数据成员的析构函数是删除的或不可访问的(如 private 的),则该类的合成析构函数、合成拷贝构造函数和默认构造函数被定义为删除的
      • 如果类的某个数据成员的拷贝构造函数是删除的或不可访问的,则类的合成拷贝构造函数被定义为删除的。
      • 如果类的某个数据成员的拷贝赋值运算符是删除的或不可访问的,则类的合成拷贝赋值运算符被定义为删除的。
      • 如果类有一个 const 成员或引用成员,则类的合成拷贝赋值运算符被定义为删除的。(但是拷贝构造函数在初始化时执行)
      • 如果类有一个没有类内初始化器且未显式定义默认构造函数的 const 成员或没有类内初始化器的引用成员,则该类的默认构造函数被定义为删除的
  • 老版本的阻止拷贝
    • 将拷贝控制成员设置为private,阻止普通用户拷贝对象(编译期报错)
    • 将拷贝控制成员只声明不定义,友元和成员函数调用时报错(链接时报错)

13.2 拷贝控制和资源管理

  • 通常管理类外资源的类必须定义拷贝控制成员
  • 类的行为可以像一个值,也可以像一个指针,主要是依据拷贝指针成员的行为
    • 不允许拷贝和赋值的类,行为既不像值,也不像指针

行为像值

  • 对象有自己的状态,副本和原对象是完全独立的,需要定义一个拷贝构造函数、一个析构函数、一个拷贝赋值运算符
    • 赋值运算符通常组合析构函数(销毁左侧对象的资源)和构造函数(从右侧对象拷贝构造)的操作
    • 拷贝赋值运算符要考虑到【自赋值】的正确性:
  • 好的方法是先将右侧对象(动态内存的指针对象)拷贝到一个局部临时对象,再销毁左侧对象的资源。(P453例子

行为像指针

  • 共享状态,拷贝一个这种类的对象时,副本和原对象使用相同的底层数据,需要定义一个拷贝构造函数、一个析构函数、一个拷贝赋值运算符
  • 最好使用shared_ptr管理资源,或者使用一个引用计数来直接管理(引用计数和资源一样是共享的,应该保存在动态内存中)。引用计数的工作方式
    • 除了初始化对象外,每个构造函数(拷贝构造函数除外)还要创建一个引用计数,用来记录有多少对象与正在创建的对象共享状态。创建一个对象时,计数器初始化为1。
    • 拷贝构造函数不创建新的引用计数,而是拷贝对象的计数器并递增它。
    • 析构函数递减计数器,如果计数器变为 0,则析构函数释放状态。
    • 拷贝赋值运算符递增右侧运算对象的计数器,递减左侧运算对象的计数器。如果左侧运算对象的计数器变为 0 就销毁状态。
  • 拷贝赋值运算符类似于shared_ptr,需要递增右侧对象的引用计数,递减左侧对象的引用计数
  • 此时处理【自赋值】问题:先是右侧对象引用计数递增,后是左侧对象引用计数递减,自赋值时引用计数不变(P457例子

13.3 交换操作

  • 管理资源的类通常还定义一个名为swap的函数,经常用于重排元素顺序的算法。
  • 优先使用自定义的swap,否则使用标准库的std::swap
  • 通常可以使用swap来实现赋值运算符
    • 右侧对象传值,然后将左侧对象与右侧对象的副本进行交换(copy and swap),可以正确处理自赋值的情况
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
class HasPtr{ // 行为类似值
    friend void swap(HasPtr&, HasPtr&);
public:
    HasPtr(const std::string &s = std::string()) : ps(new std::string(s)), i(0) {}
    HasPtr(const HasPtr& p): ps(new std::string(*p.ps)), i(p.i) {} // 拷贝构造函数
    HasPtr(HasPtr &&p) noexcept: ps(p.ps), i(p.i) {p.ps 0;} // 移动构造函数
    HasPtr& operator = (const HasPtr& rhs){ // 拷贝赋值操作符
        std::string* tmp = new std::string(*rhs.ps);
        delete ps;
        ps = tmp;
        i = rhs.i;
        return *this;
    }
    
    // 特点:
    // 1.用swap实现赋值运算符
    // 2.既是拷贝赋值运算符(参数是左值),又是移动赋值运算符(参数是右值)
    HasPtr& operator = (HasPtr rhs){ // copy and swap
        // 传入的是rhs的副本(假设是rhs-copy),this还是指向本身(假设是lhs)
        swap(*this, rhs); // 交换左侧对象(lhs)和右侧对象的副本(rhs-copy)
        // 此时this指向rhs-copy(更准确来说还是指向lhs地址,但是原来lhs的内容已经被swap为了rhs-copy)
        // swap后的lhs没有变量来接管,因此被析构
        // 因此实现了this指向从lhs改变为rhs
        return *this;
    }
    
    ~HasPtr() {delete ps;}
private:
    std::string *ps;
    int i; 
};

inline void swap(HasPtr &lhs, HasPtr &rhs){
    using std::swap; // 当某个成员没有自定义的swap时,使用标准库版本的swap
    swap(lhs.ps, rhs.ps);
    swap(lhs.i, rhs.i);
}

class Foo{
    friend void swap(Foo&, Foo&);
private:
    HasPtr h;    
};

void swap(Foo &lhs, Foo &rhs){
    using std::swap; // 优先使用自定义版本的swap
    swap(lhs.h, rhs.h); // 使用HasPtr版本的swap
}

HasPtr p1("test), p2, p3;
p2 = p1; // 使用拷贝赋值运算符
p3 = std::move(p1); // 使用移动赋值运算符

13.4 拷贝控制示例

13.5 动态内存管理类

13.6 对象移动

使用对象移动的原因:

  • 一些拷贝操作后,原对象会被立即销毁,因此引入移动操作可以大幅度提升性能。
  • C++11可以用容器保存不可拷贝的类型,只要它们可以被移动即可。
    • 标准库容器、stringshared_ptr类既可以支持移动也支持拷贝。IO类和unique_ptr类可以移动但不能拷贝。

左值与右值

  • 左值:
    • 返回左值的表达式:返回左值引用的函数,赋值、下标、解引用、前置递增递减运算符,
    • 左值引用:可以绑定到变量(包括右值引用变量)、返回左值的表达式
    • const的左值引用可以绑定到右值
  • 右值:
    • 右值要么是字面常量(没有其他用户),要么是表达式求值过程中创建的临时变量(即将被销毁)
    • 返回右值的表达式:返回非引用类型的函数,算数、关系、位、后置递增递减运算符
    • 右值引用:可以绑定到要求转换的表达式、字面常量、返回右值的表达式
  • move函数: ^d3a2a0
    • 可以将一个右值引用绑定到左值上 int a=1; int &&r = std::move(a);
    • 定义在头文件utility中
    • move告诉编译器,我们有一个左值,但我希望像右值一样处理它。
    • 对左值调用move意味着:不在使用该左值的值,除非销毁它或者对它重新赋值
    • 使用move的代码应该使用std::move而不是move,可以避免潜在的名字冲突

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

  • 移动构造函数
    • 第一个参数是该类类型的一个右值引用,比如StrVec::StrVec(StrVec &&s) noexcept{}
    • 在形参列表后添加关键字noexcept可以指明该函数不会抛出任何异常,在声明和定义中均应该指明noexcept
      • 不抛出异常的移动构造函数和移动赋值运算符必须标记为noexcept
      • 原因:如果用移动构造函数,移动到一半抛出异常,容器不能满足即使发生异常也保持自身不变的要求,因此需要显式标记noexcept;否则编译器基于上面的考虑,会调用拷贝构造函数而非移动构造函数
    • 除了完成资源移动,还要确保移动后源对象是可以安全销毁的(比如将源对象中数组的指针指向nullptr,然后源对象进行正确析构,否则会释放掉刚才移动的对象),用户不能使用移动后源对象的值
  • 移动赋值运算符
    • StrVec& StrVec::operator=(StrVec && rhs) noexcept{}
    • 使用非引用参数的单一赋值运算符可以实现拷贝赋值和移动赋值两种功能,依赖于实参的类型:实参是左值,则实参被拷贝;实参是右值,则实参被移动:StrVec& StrVec::operator=(StrVec rhs);
  • 移动迭代器
    • 普通迭代器的解引用运算符返回一个指向元素的左值,移动迭代器的解引用运算符生成一个右值引用
    • make_move_iterator函数将一个普通迭代器转换为一个移动迭代器。
    • 因此,可以将移动迭代器传递给算法或是allocator的伴随算法
    • 但是,标准库不能保证哪些算法适用于移动迭代器,哪些不适用。由于移动一个对象可能销毁掉源对象,因此要确定以后不再访问这个元素时,才能将移动迭代器传递给算法。
    • 移后源对象具有不确定的状态,必须确认移后源对象没有其他用户,因此要小心使用
  • 合成的移动操作
    • 如果一个类定义了自己的拷贝构造函数、拷贝赋值函数或者析构函数,编译器不会为它合成移动构造函数和移动赋值运算符
    • 如果一个类没有移动操作,类会用对应的拷贝操作来代替移动操作,即使使用move函数也是如此
    • 只有当一个类没有自定义的拷贝控制成员,且类的每个非static数据成员都可以移动(内置类型可以移动,类类型要有对应的移动操作)时,编译器才会为类合成移动构造函数和移动赋值运算符;否则即使显式要求合成移动操作=default,编译器也会将移动操作定义为=delele
    • 与拷贝操作不同,移动操作永远不会隐式定义为=delete
    • 如果一个类定义了一个移动构造函数和/或一个移动赋值运算符,则该类的合成拷贝函数和拷贝赋值运算符会被定义为删除的
    • 定义了一个移动构造函数或移动赋值运算符的类必须也定义自己的拷贝操作。否则,这些成员默认地被定义为删除的

引用限定符

  • 成员函数一般有接受拷贝的const T&版本和接受右值的T&&版本
  • 引用限定符&&&
    • 限制调用者必须是左值还是右值
    • 语法相关:
      • 引用限定符只能用于非static成员函数
      • 引用限定符必须同时出现在函数的声明和定义中
      • 一个函数可以同时使用const和引用限定,即引用限定符必须在const限定符之后
      • 如果一个成员函数有引用限定符,则具有相同参数列表的所有重载版本都必须有引用限定符
    • 可以综合使用引用限定符和const限定符来区分一个函数的重载版本
      • 使用const &&进行限定时,调用者必须是右值
      • 使用const &进行限定时,调用者可以是左值,也可以是右值
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    
    class Test{
    public:
        Test(int v): val(v) {}
        int show() & {std::cout<<1<<std::endl;return val;}
        // const int show() & {} // 报错:返回值与重载无关,见6.4
        int show() && {std::cout<<2<<std::endl;return val;}
        int show() const & {std::cout<<3<<std::endl;return val;}
        int show() const && {std::cout<<4<<std::endl;return val;} // 右值本来就是常量,这种方法无法被调用(被show() &&)覆盖
    private:    
        int val;
    };
    
    int main(){
        Test t1(0); t1.show();
        Test(0).show();
        const Test t3(0); t3.show(); // const对象或是const对象的指针/引用,只能调用const成员函数
        // 输出1 2 3 
    }