C++ Primer:Sec 13:拷贝控制

Sec13 拷贝控制

当定义一个类,我们显示 或者隐式指定此类型的对象拷贝、移动、赋值或者销毁时做什么

  • 五种特殊成员函数
    • 拷贝构造函数 copy constructor
    • 拷贝赋值运算符 copy-assignment operator
    • 移动构造函数 move constructor
    • 移动赋值运算符 move-assignment operator
    • 析构函数 destructor

13.1 拷贝、赋值与销毁

  • 拷贝构造函数

    class Foo {
    public:
        Foo();				// 默认构造函数
        Foo(const Foo&);	// 拷贝构造函数
    }
    

    拷贝构造函数第一个参数必须是引用类型,而且基本上是const 的引用

    而且,在几种情况都会被隐式地使用,所有通常不应该为explict

    • 合成拷贝构造函数 synthesized copy constructor

    • 拷贝初始化

      • 直接初始化时,本质是函数匹配

      • 拷贝初始化时,本质是拷贝,可能还有类型转换

      • 注意:

        string s1(dots);	// 直接初始化
        string s2 = dots;	// 拷贝初始化
        
    • 参数和返回值
      函数调用过程中,具有非引用类型的参数要进行拷贝初始化。
      如果不是引用类型,调用永远不会成功(死循环)

  • 拷贝赋值运算符
    类可以要控制其对象如何赋值

    Sales_data trans, accum;
    trans = accum;	// 使用Sales_data的拷贝赋值运算符
    
    • 重载赋值运算符
      overloaded operator
      本质上就是一个名为operator=的函数

      class Foo {
      public:
          Foo& operator=(const Foo&);	// 赋值运算符
      }
      

      赋值运算符通常应该返回一个指向其左侧运算对象的引用

    • 合成拷贝赋值运算符
      与处理拷贝构造函数一样,如果一个类未定义自己的拷贝赋值运算符,编译器会为它生成一个合成拷贝赋值运算符 synthesized copy-assignment operator

      // 等价代码
      Sales_data&
      Sales_data::operator=(const Sales_data &rhs)
      {
          bookNo = rhs.bookNo;	// 调用 string::operator=
          units_sold = rhs.units_sold;
          revenue = rhs.reenue;
          return *this;
      }
      
  • 析构函数
    构造函数初始化对象的非static数据成员,而析构函数释放对象使用的资源,并销毁对象的非static数据成员。
    无返回值也无参数

    class Foo {
    public:
        ~Foo();	// 析构函数
        // ...
    };
    

    如同构造函数有一个初始化部分和一个函数题,析构函数也有一个函数体和一个析构部分,但是析构函数是首先执行函数体,然后执行析构部分。即首先执行函数体,然后销毁成员,成员按初始化顺序逆序销毁。

    析构函数中,析构部分是隐式的。成员销毁时发生什么完全依赖于成员的类型。
    而且,隐式销毁一个内置指针类型的成员不会delete它所指向的对象
    所以建议用智能指针

    • 什么时候调用析构函数?

    • 合成析构函数

      // 等价代码
      class Sales_data {
      public:	
          ~Sales_data() { }
          // 其他成员的定义
      }
      
  • 三/五法则
    三个基本操作可以控制类的拷贝操作:

    • 拷贝构造函数

    • 拷贝赋值运算符

    • 析构函数

    • 需要析构函数的类也需要拷贝和赋值操作
      如果一个类需要一个析构函数,我们几乎可以肯定它也需要一个拷贝构造函数和一个拷贝赋值运算符

      // 一个典型反例
      class HasPtr {
      public:
          HasPtr(const std::string &s = std::string()):
          ps(new std::string(s)), i(0) { }
          ~HasPtr() {delete ps;}
          // 错误,HasPtr需要一个拷贝构造函数和一个拷贝赋值运算符。
      }
      // 因为合成的拷贝构造和拷贝赋值运算符,这邪恶函数简单的拷贝指针成员,这意味着多个HasPtr对象可能指向相同的内存
      

      比如:

      HasPtr f(HasPtr hp) {
          HasPtr ret = hp;
          return ret;
      }
      // 此后,ret和hp都会销毁,但是指针指向的是同一个,这会使得delete俩次!!!!导致严重错误
      
    • 需要拷贝操作的类也需要赋值操作,反之亦然

    • 使用=default
      可以将拷贝控制成员定义为=default来显示地要求编译器生成合成的版本

      class Sales_data {
      public:
          Sales_data() = default;
          Sales_data(const Sales_data&) = default;
          Sales_data& operator=(const Sales_data &);
          ~Sales_data() = default;
      };
      Sales_data& Sales_data::operator=(const Sales_data &) = default;
      

      当类内用=default修饰成员的声明时,合成的函数将隐式地声明为内联的

  • 阻止拷贝
    对某些类来说,拷贝构造函数和拷贝赋值运算符没有合理的意义。此时,定义类必须采用某种机制阻止拷贝或者赋值!

    • 定义删除的函数
      将拷贝构造函数和拷贝赋值运算符定义为删除的函数deleted function 来阻止拷贝

      struct NoCopy() {
          NoCopy() = default;
          NoCopy(const NoCopy&) = delete;	// 阻止拷贝
          NoCopy &operator=(const NoCopy&) = delete;
          ~NoCopy() = default;
      }
      

      = delete通知编译器,我们不希望定义这些成员
      注意,与=default不同,=delete必须出现在函数第一次声明的时候

    • 析构函数不能是删除的成员

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

    • private拷贝控制
      新标准之前,类是通过其拷贝构造函数和拷贝赋值运算符声明为private来阻止拷贝

13.2 拷贝控制和资源管理

  • 拷贝语义:
    类像什么?

    • 行为像一个值,则类也应该有自己的状态,副本和原对象独立
    • 行为像一个指针,则共享状态,副本和原对象使用相同的底层数据

    例子:HasPtr:里面有一个指针,如何拷贝指针成员,决定了类似的类,行为是类值行为还是类指针行为!

  • 行为像类的值

    • 类值拷贝赋值运算符

      • 注意事项:
        如果将一个对象赋予它自身,赋值运算符必须能正确工作

        大多数赋值运算符组合了析构函数和拷贝析构函数的工作

      • 正确写法:

        HasPtr& HasPtr::operator=(const HasPtr &rhs) { 
            auto newps = new string(*rhs.ps);   // 拷贝指针指向的对象
            delete ps;                          // 销毁原string
            ps = newps;
            i = rhs.i;
            return *this;
        }
        
      • 错误写法:

        HasPtr&
        HasPtr::operator=(const HasPtr &rhs) {
            delete ps;
            ps = new string(*(rhs.ps));
            // 如果rhs和*this是同一个对象,我们就将从以释放的内存中拷贝数据!!!
            i = rhs.i;
            return *this;
        }
        
  • 行为像指针的值
    对于行为像指针的值,我们需要为其定义拷贝构造赋值运算符,来拷贝指针成员本身而不是它指向的string。此时,析构函数不能单方面释放关联的string,只有当最后一个指向string的HasPtr销毁时,它才可以释放string。
    另一个类展现类似指针的行为最好使用shared_ptr来管理类中的资源。
    如果我们希望直接管理资源,=则可以使用引用计数,reference count。

    • 引用计数的工作方式

      • 除初始化对象,每个构造函数(除了拷贝构造函数)还要创建一个引用计数,来记录有多少对象正在创建对象共享状态。当我们创建一个对象时,只有一个对象共享状态,故计数器初始化为1
      • 拷贝构造函数不分配新的计数器,而是拷贝给定对象的数据成员,包括计数器。拷贝构造函数递增共享的计数器,指出给定对象的状态又被一个新用户所共享
      • 析构函数递减其计数器。若计数器为0则析构函数释放状态
      • 拷贝赋值运算符递增右侧运算对象的计数器,递减左侧运算对象的计数器。如果左侧运算对象的计数器为0 ,则必须销毁。
    • 计数器的实现:保存在动态内存中,每次拷贝指向计数器的指针

    • 定义一个使用引用计数的类

      class HasPtr {
      public:
          HasPtr(const std::string &s = std::string()):
           ps(new std::string(s)), i(0), use(new std::size_t(1)) { }
          HasPtr(const HasPtr &p):
           ps(p.ps), i(p.i), use(p.use) { ++*use; }
          HasPtr& operator=(const HasPtr&);
          ~HasPtr();
      private:
          std::string *ps;
          int i;
          std::size_t *use;	// 用来记录有多少个对象共享ps的成员
      }
      
    • 类指针的拷贝成员 篡改 引用计数

      HasPtr::~HasPtr() {
          if(--*use == 0) {
              delete ps;
              delete use;
          }
      }
      
      HasPtr& HasPtr::operator=(const HasPtr &rhs) {
          ++*rhs.use;	// 递增右侧对象的引用计数
          if(--*use == 0) {	// 递减左侧对象的引用计数
              delete ps;
              delete use;
          }
          ps = rhs.ps;
          i = rhs.i;
          use = rhs.use;
          return *this;
      }
      

13.3 交换操作

除了定义拷贝控制成员,管理资源的类通常还定义一个名为swap的函数。对那些与重排元素顺序的算法一起使用的类,定义swap是很重要的,这类算法需要交换两个元素时会调用swap。

  • 编写自己的swap函数(从交换值到交换指针)

    class HasPtr {
        friend void swap(HasPtr&, HasPtr&);
       	//
    }
    inline
    void swap(HasPtr &lhs, HasPtr &rhs) {
        using std::swap;
        swap(lhs.ps, rhs.ps);	// 交换指针
        swap(lhs.i, rhs.i);
    }
    
  • 函数应该调用swap,而不是std::swap

    每个swap调用应该都是未加限定的,即每个调用都应该为swap而不是std::swap。如果存在特定版本swap,则会优先选择

  • 在赋值运算符中使用swap (绝秒!)

    定义swap的类通常用swap来定义它们的赋值运算符。这些运算符使用了一种名为拷贝并交换的技术,这种技术将左侧对象与右侧对象的一个副本进行交换

    HasPtr%  HasPtr::operator=(HasPtr rhs) {
        swap(*this, rhs);
        return *this;	// rhs被销毁,从而delete了rhs中的指针,即释放左侧对象原来的内存
    }
    

13.4 拷贝控制示例

  • Message

  • Folder

13.5 动态内存管理类

某些类需要定义自己的的拷贝控制成员来管理所分配的内存
比如使用allocator来获得原始内存。由于allocator分配的内存是未构造的,我们需要将在需要添加新元素时用allocator的construct成员在原始内存中创建对象。同样,要删除一个元素的时候,我们将使用destroy成员来销毁元素。

  • 举例:StrVec
    有3个指针成员指向其元素使用的内存

    • elements:指向分配的内存中的首元素
    • first_free:指向最后一个实际元素之后的位置
    • cap:指向分配的内存末尾之后的位置
    • alloc:静态成员,类型为allocator

    还有4个工具函数

    • alloc_n_copy:分配内存,并拷贝一个给定范围中的元素
    • free:销毁构造的元素并释放内存
    • chk_n_alloc:保证StrVec至少有容纳一个新元素的空间,若没有,则调用reallocate来分配更多内存
    • reallocate:分配新内存

    在重新分配内存的过程中,是移动而不是拷贝元素。

    • reallocate:

      • 为一个新的,更大的string数组分配内存
      • 在内存空间的前一部分构造对象,保存现有元素
      • 销毁原内存空间中的元素,并释放这块内存

      所以很麻烦,又要销毁原内存又要分配新内存又要把原数据复制过来

    • 移动构造和std::move
      用move可以避免string的拷贝,定义在头文件中
      注意,当reallocate在新内存中构造string时,必须调用move来表示希望使用string的移动构造函数

13.6 对象移动

13.6.1 右值引用

移动!=拷贝

  • 右值引用:rvalue reference
    所谓右值引用就是必须绑定到右值的引用,通过&&来获得右值引用。
    重要性质是:只能绑定到一个将要销毁的对象。故我们可以自由地将一个右值引用的资源移动到另一个对象中。

    一般来说,左值表达式表示是一个对象的身份,右值表达式表示的是对象的值

    • 常规引用:&
      为左值引用
    • 右值引用:&&
      右值不能绑定到一个左值
      返回非引用类型的函数,联通算术,关系,位以及后置递增,递减运算符,都生成右值。
      但我们可以将一个const的左值引用或者一个右值引用绑定到这类表达式中
  • 左值持久,右值短暂
    右值要么是字面常量,要么是表达式求值过程中创建的临时对象
    右值引用只能绑定到临时对象。故我们知道:

    • 所引用的对象将要被销毁
    • 该对象没有其他用户

    即,使用右值引用的代码可以自由地接管所引用的对象的资源

  • 变量是左值

    变量可以看作一个只有运算对象 而没有运算符的表达式,而变量表达式都是左值!
    所以我们不能将一个右值引用绑定到一个右值引用类型的变量上

    int &&rr1 = 42;		// 正确
    int &&rr2 = rr1;	// 错误,表达式rr1是左值!
    
    • 标准库move函数
      虽然不能将一个右值引用直接绑定到一个左值上,但我们可以显式地将一个左值转换位对应的右值引用类型。我们还能通过调用一个名为move的新标准库函数来获得绑定到左值上的右值引用
      int &&rr3 = std::move(rr1);// ok
      调用move就意味着,除了对rr1赋值和销毁,我们不再使用它。

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

  • 移动构造函数 - 移动赋值函数
    对应:拷贝构造函数和拷贝赋值函数

    • 类似拷贝构造函数,移动构造函数的第一个参数是该类类型的一个引用。不同于拷贝构造函数的是,这个引用参数在移动构造函数上是一个右值引用。与拷贝构造函数一样,任何额外的参数都必须有默认实参!

    除了完成资源移动,移动构造函数还必须确保移动后源对象处于一个状态:销毁后是无害的。特别是,一旦资源完成移动,源对象必须不再指向被移动的资源,这些资源所有权已经归属于新创建的对像

    • 例子:
    StrVec::StrVec(StrVec &&s) noexcept	// 移动操作不应抛出任何异常!
        // 成员初始化接管s中的资源
        : elements(s.elements), first_free(s.first_free), cap(s.cap)
    {
            // 令s进入这样的状态,对其运行析构函数是安全的
            s.elements = s.first_free = s.cap = nullptr;
        }
    
  • 移动操作,标准库容器和异常
    由于移动操作窃取资源,通常不分配任何资源。因此,移动操作通常不会抛出任何异常。所以通知标准库,用noexcept

    class StrVec {
    public:
        StrVec(StrVec&&) noexcept;	// 移动构造函数
        // 其他成员的定义,如前
    };
    StrVec::StrVec(StrVec &&s) noexcept : /* 成员初始化器 */
    	
    
  • 移动赋值运算符
    移动赋值运算符执行与析构函数和移动构造函数相同的工作。与移动构造函数一样,如果我们的移动赋值运算符不跑出异常,我们就应该将它标记为noexcept。
    注意!移动赋值运算符也必须正确处理自赋值!

    StrVec &StrVec::operator=(StrVec &&rhs) noexcept
    {
        // 直接检测自赋值
        if (this != &rhs) {
            free();
            elements = rhs.elements;
            first_free = rhs.first_free;
            cap = rhs.cap;
            // 将rhs置于可析构状态
            rhs.elements = rhs.first_free = rhs.cap = nullptr;
        }
        return *this;
    }
    

    进行检查的原因是,此右值可能是move调用的返回结果!与其他任何赋值运算符一样,关键点是我们不能在使用右侧运算对象的资源之前就释放左侧运算对象的资源。

  • 移后源对象必须可解析
    从一个对象移动数据并不会销毁此对象,但有时在移动操作完成之后,源对象会被销毁!故编写移动操作后,必须确保移动后源对象进入了一个可析构的状态!通常将所有的指针成员置为nullptr来实现
    注意:程序不应该依赖于移后源对象中的数据

  • 合成的移动操作
    与拷贝操作不同,编译器不回位某些类合成移动操作。如果一个类定义了自己的拷贝构造函数、拷贝赋值运算符或者析构函数,编译器就不为他们合成移动构造函数和移动赋值运算符了。
    若一个类没有移动操作,通过正常的函数匹配,类会使用对象的拷贝操作来代替移动操作
    只有当一个类没有定义任何自己版本的拷贝控制成员,且类的每个非static数据成员都可以移动时,编译器才会为它合成移动构造函数或者移动赋值运算符。

    • 例子:

      struct X {
          int i;			// 内置类型可以移动
          std::string s;	// string定义了自己的移动操作
      };
      struct hasX {
          X mem;	// X有合成的移动操作
      };
      X x, x2 = std::move(x);			// 使用合成的移动构造函数
      hasX hx, hx2 = std::move(hx);	// 使用合成的移动构造函数
      

      与拷贝操作不同,移动操作永远不会隐式定义为删除的函数!但如果我们显示要求编译器生成=default的移动操作,且编译器不能移动所有成员,则编译器会将移动操作定义为删除的函数。,

      // 假设Y为一个类,定义了自己的拷贝构造函数但未定义自己的移动构造函数
      struct hasY {
          hasY() = default;
          hasY(hasY&&) = default;
          Y mem;	// hasY 将有一个删除的移动构造函数
      };
      hasY hy, hy2 = std::move(hy);	// 错误:移动构造函数是删除的!
      
  • 移动右值,拷贝左值

    若一个类既有移动构造函数,也有拷贝构造函数,编译器用普通的函数匹配规则来确定使用哪个构造函数
    移动构造函数接受一个&&,只能用于实参是(非static)右值的情形

    StrVec v1, v2;
    v1 = v2;					// v2是左值,使用拷贝赋值
    StrVec getVec(istream &);	// getVec返回一个右值
    v2 = getVec(cin);			// getVec(cin)是一个右值,使用移动赋值
    

    如果没有移动构造函数,则右值也会被拷贝!

  • 拷贝并交换赋值运算符和移动操作

    class HasPtr {
    public:
        // 添加新的移动构造函数
        HasPtr(HasPtr &&p) noexcept : ps(p.ps), i(p.i) {p.ps = 0;}
        // 赋值运算符既是移动赋值运算符,也是拷贝赋值运算符
        HasPtr& operator=(HasPtr rhs) {
            swap(*this, rhs); return *this;
        }
    }
    
  • 移动迭代器:
    移动迭代器通过改变给定迭代器的解引用运算符的行为来适配此爹嗲气,移动迭代器与普通的迭代器相反,解引用生成一个右值引用!
    可以和uninitialized_copy来使用,保证construct将会使用移动构造函数来构造元素(从而使得运行更快!)

13.6.3 右值引用和成员函数 (略)*

posted @ 2022-12-21 11:18  M1kanN  阅读(34)  评论(0编辑  收藏  举报