《C++ Primer》笔记 第13章 拷贝控制

《C++ Primer》笔记 第13章 拷贝控制

拷贝控制概念

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

拷贝、赋值与销毁

拷贝构造函数

  • 如果一个构造函数的第一个参数是自身类类型的引用,且任何额外参数都有默认值,则此构造函数是拷贝构造函数
  • 拷贝构造函数的第一个参数必须是一个引用类型。虽然我们可以定义一个接受非const引用的拷贝构造函数,但此参数几乎总是一个const的引用。
  • 拷贝构造函数在几种情况下都会被隐式地使用。因此,拷贝构造函数通常不应该是explicit的。

合成拷贝构造函数

  • 如果我们没有为一个类定义拷贝构造函数,编译器会为我们定义一个。与合成默认构造函数不同,即使我们定义了其他构造函数,编译器也会为我们合成一个拷贝构造函数。
  • 一般情况,合成的拷贝构造函数会将其参数的成员逐个拷贝到正在创建的对象中。编译器从给定对象中依次将每个非static成员拷贝到正在创建的对象中。
  • 每个成员的类型决定了它如何拷贝:对类类型的成员,会使用其拷贝构造函数来拷贝;内置类型的成员则直接拷贝。虽然我们不能直接拷贝一个数组,但合成拷贝构造函数会逐元素地拷贝一个数组类型的成员。如果数组元素是类类型,则使用元素的拷贝构造函数来进行拷贝。

拷贝初始化

  • 当使用直接初始化时,我们实际上是要求编译器使用普通的函数匹配来选择与我们提供的参数最匹配的构造函数。当我们使用拷贝初始化时,我们要求编译器将右侧运算对象拷贝到正在创建的对象中,如果需要的话还要进行类型转换。

  • 拷贝初始化通常使用拷贝构造函数来完成。但是,如果一个类有一个移动构造函数,则拷贝初始化有时会使用移动构造函数而非拷贝构造函数来完成。

  • 拷贝构造函数在以下几种情况下会被使用(发生拷贝初始化):

    • 拷贝初始化(用=定义变量)
    • 将一个对象作为实参传递给一个非引用类型的形参
    • 从一个返回类型为非引用类型的函数返回一个对象
    • 用花括号列表初始化一个数组中的元素或一个聚合类中的成员
    • 某些类类型还会对它们所分配的对象使用拷贝初始化。(初始化标准库容器或调用其insert/push操作时,容器会对其元素进行拷贝初始化)

参数和返回值

  • 拷贝构造函数被用来初始化非引用类类型参数,这一特性解释了为什么拷贝构造函数自己的参数必须是引用类型。

拷贝初始化的限制

  • 如果我们希望使用一个explicit构造函数,就必须显式地使用。

    vector<int> v1(10); // 正确:直接初始化
    vector<int> v2 = 10; // 错误:接受大小参数的构造函数是explicit的
    void f(vector<int>); // f的参数进行拷贝初始化
    f(10); // 错误:不能用一个explicit的构造函数拷贝一个实参
    f(vector<int>(10)); // 正确:从一个int直接构造一个临时vector
    

编译器可以绕过拷贝构造函数

  • 在拷贝初始化过程中,编译器可以(但不是必须)跳过拷贝/移动构造函数,直接创建对象。即,编译器被允许将下面的代码改写:

    string null_book = "9-999-9999-9"; // 拷贝初始化
    string null_book("9-999-99999-9"); // 编译器略过了拷贝构造函数
    

    但是,即使编译器略过了拷贝/移动构造函数,但在这个程序点上,拷贝/移动构造函数必须是存在且可访问的(例如,不能是private的)。

拷贝赋值运算符

重载赋值运算符

  • 重载运算符的参数表示运算符的运算对象。某些运算符,包括赋值运算符,必须定义为成员函数。如果一个运算符是一个成员函数,其左侧运算对象就绑定到隐式的this参数。对于一个二元运算符,例如赋值运算符,其右侧运算对象作为显式参数传递。

  • 拷贝赋值运算符接受一个与其所在类相同类型的参数:

    class Foo
    {
    public:
    	Foo& operator=(const Foo&); // 赋值运算符
    	// ...
    };
    
  • 赋值运算符通常应该返回一个指向其左侧运算对象的引用。

合成拷贝赋值运算符

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

  • 类似拷贝构造函数,对于某些类,合成拷贝赋值运算符用来禁止该类型对象的赋值。如果拷贝赋值运算符并非出于此目的,它会将右侧运算对象的每个非static成员赋予左侧运算对象的对应成员,这一工作是通过成员类型的拷贝赋值运算符来完成的。对于数组类型的成员,逐个赋值数组元素。合成拷贝赋值运算符返回一个指向其左侧运算对象的引用。

    // 等价于合成拷贝赋值运算符
    Sales_data& Sales_data::operator=(const Sales_data &rhs)
    {
    	bookNo = rhs.bookNo; // 调用string::operator=
    	units_sold = rhs.units_sold; // 使用内置的int赋值
    	revenue = rhs.revenue; // 使用内置的double赋值
    	return *this; // 返回一个此对象的引用
    }
    

析构函数

  • 析构函数释放对象使用的资源,并销毁对象的非static数据成员。析构函数是类的一个成员函数,名字由波浪号接类名构成。它没有返回值,也不接受参数:

    class Foo
    {
    public:
    	~Foo(); // 析构函数
    	// ...
    };
    
  • 由于析构函数不接受参数,因此它不能被重载。对一个给定类,只会有唯一一个析构函数。

析构函数完成什么工作

  • 析构函数有一个函数体和一个析构部分。在一个析构函数中,首先执行函数体,然后销毁成员。成员按初始化顺序的逆序销毁。
  • 在一个析构函数中,析构部分是隐式的。成员销毁时发生什么完全依赖于成员的类型。销毁类类型的成员需要执行成员自己的析构函数。内置类型没有析构函数,因此销毁内置类型成员什么也不需要做。
  • 隐式销毁一个内置指针类型的成员不会delete它所指向的对象。与普通指针不同,智能指针是类类型,所以具有析构函数。因此,与普通指针不同,智能指针成员在析构阶段会被自动销毁。

什么时候会调用析构函数

  • 无论何时一个对象被销毁,就会自动调用其析构函数:

    • 变量在离开其作用域时被销毁
    • 当一个对象被销毁时,其成员被销毁
    • 容器(无论是标准库容器还是数组)被销毁时,其元素被销毁
    • 对于动态分配的对象,当对指向它的指针应用delete运算符时被销毁
    • 对于临时对象,当创建它的完整表达式结束时被销毁
  • 仍需要强调的是,当指向一个对象的引用或指针离开作用域时,析构函数不会执行。

合成析构函数

  • 类似拷贝构造函数和拷贝赋值运算符,对于某些类,合成析构函数被用来阻止该类型的对象被销毁。如果不是这种情况,合成析构函数的函数体就为空。

    // 下面的代码片段等价于Sales_data的合成析构函数
    class Sales_data
    {
    public:
    	// 成员会被自动销毁,除此之外不需要做其他事情
    	~Sales_data() { }
    	// 其他成员的定义,如前
    };
    
  • 析构函数体自身并不直接销毁成员。成员是在析构函数体之后隐含的析构阶段中被销毁的。在整个对象销毁过程中,析构函数体是作为成员销毁步骤之外的另一部分而进行的。

三五法则

需要析构函数的类也需要拷贝和赋值操作

  • 当我们决定一个类是否要定义它自己版本的拷贝控制成员时,一个基本原则是首先确定这个类是否需要一个析构函数。通常,对析构函数的需求要比对拷贝构造函数或赋值运算符的需求更为明显。如果一个类需要自定义析构函数,几乎可以肯定它也需要自定义拷贝赋值运算符和拷贝构造函数

需要拷贝操作的类也需要赋值操作,反之亦然

  • 决定一个类是否要定义它自己版本的拷贝控制成员时,第二个基本原则:如果一个类需要一个拷贝构造函数,几乎可以肯定它也需要一个拷贝赋值运算符。反之亦然——如果一个类需要一个拷贝赋值运算符,几乎可以肯定它也需要一个拷贝构造函数。然而,无论是需要拷贝构造函数还是需要拷贝赋值运算符都不必然意味着也需要析构函数。

使用=default

  • 我们可以通过将拷贝控制成员定义为=default来显式地要求编译器生成合成的版本。

  • 当我们在类内用=default修饰成员的声明时,合成的函数将隐式地声明为内联的(就像任何其他类内声明的成员函数一样)。如果我们不希望合成的成员是内联函数,应该只对成员的类外定义使用=default

    class Sales_data
    {
    public:
    	// 拷贝控制成员;使用default
    	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(即,默认构造函数或拷贝控制成员)。

阻止拷贝

定义删除的函数

  • 我们可以通过将拷贝构造函数和拷贝赋值运算符定义为删除的函数来阻止拷贝:我们虽然声明了它们,但不能以任何方式使用它们。在函数的参数列表后面加上=delete来指出我们希望将它定义为删除的:

    struct NoCopy
    {
    	NoCopy() = default; // 使用合成的默认构造函数
    	NoCopy(const NoCopy&) = delete; // 阻止拷贝
    	NoCopy &operator=(const NoCopy&) = delete; // 阻止赋值
    	~NoCopy() = default; // 使用合成的析构函数
    	// 其他成员
    };
    
  • =default不同,=delete必须出现在函数第一次声明的时候,这个差异与这些声明的含义在逻辑上是吻合的。一个默认的成员只影响为这个成员而生成的代码,因此=default直到编译器生成代码时才需要。而另一方面,编译器需要知道一个函数是删除的,以便禁止试图使用它的操作。

  • =default的另一个不同之处是,我们可以对任何函数指定=delete(我们只能对编译器可以合成的默认构造函数或拷贝控制成员使用=default)。虽然删除函数的主要用途是禁止拷贝控制成员,但当我们希望引导函数匹配过程时,删除函数有时也是有用的。

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

  • 对于一个删除了析构函数的类型,编译器将不允许定义该类型的变量或创建该类的临时对象。而且,如果一个类有某个成员的类型删除了析构函数,我们也不能定义该类的变量或临时对象。

  • 对于删除了析构函数的类型,虽然我们不能定义这种类型的变量或成员,但可以动态分配这种类型的对象。但是,不能释放这些对象:

    struct NoDtor
    {
    	NoDtor() = default; // 使用合成默认构造函数
    	~NoDtor() = delete; // 我们不能销毁NoDtor类型的对象
    };
    NoDtor nd; // 错误:NoDtor的析构函数是删除的
    NoDtor *p = new NoDtor(); // 正确:但我们不能delete p
    delete p; // 错误:NoDtor的析构函数是删除的
    
  • 综上,对于析构函数已删除的类型,不能定义该类型的变量释放指向该类型动态分配对象的指针

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

  • 对某些类来说,编译器将这些合成的成员定义为删除的函数:
    • 如果类的某个成员的析构函数是删除的或不可访问的(例如,是private的),则类的合成析构函数被定义为删除的。
    • 如果类的某个成员的拷贝构造函数是删除的或不可访问的,则类的合成拷贝构造函数被定义为删除的。如果类的某个成员的析构函数是删除的或不可访问的,则类合成的拷贝构造函数也被定义为删除的。
    • 如果类的某个成员的拷贝赋值运算符是删除的或不可访问的,或是类有一个const的或引用成员,则类的合成拷贝赋值运算符被定义为删除的。
    • 如果类的某个成员的析构函数是删除的或不可访问的,或是类有一个引用成员,它没有类内初始化器,或是类有一个const成员,它没有类内初始化器且其类型未显式定义默认构造函数,则该类的默认构造函数被定义为删除的。
  • 本质上,这些规则的含义是:如果一个类有数据成员不能默认构造、拷贝、赋值或销毁,则对应的成员函数将被定义为删除的(本质上,当不可能拷贝、赋值或销毁类的成员时,类的合成拷贝控制成员就被定义为删除的。)

private拷贝控制

  • 因为试图访问一个未定义的成员会导致一个链接时错误,通过声明但不定义private的拷贝构造函数,我们可以预先阻止任何拷贝该类型对象的企图:试图拷贝对象的用户代码将在编译阶段被标记为错误;成员函数或友元函数中的拷贝操作将会导致链接时错误。

    // 在新标准发布之前,类是通过将其拷贝构造函数和拷贝赋值运算符声明为private的来阻止拷贝
    
    class PrivateCopy{
    	// 无访问说明符;接下来的成员默认为private的
    	// 拷贝控制成员是private的,因此普通用户代码无法访问
    	PrivateCopy(const PrivateCopy &);
    	PrivateCopy &operator=(const PrivateCopy &);
    	// 其他成员
    public:
    	PrivateCopy() = default; // 使用合成的默认构造函数
    	~PrivateCopy(); // 用户可以定义此类型的对象,但无法拷贝他们
    };
    
  • 希望阻止拷贝的类应该使用=delete来定义它们自己的拷贝构造函数和拷贝赋值运算符,而不应该将它们声明为private的。

拷贝控制和资源管理

  • 通常,管理类外资源的类必须定义拷贝控制成员。为了定义这些成员,我们首先必须确定此类型对象的拷贝语义。一般来说,有两种选择:可以定义拷贝操作,使类的行为看起来像一个值或者像一个指针。

    • 类的行为像一个值,意味着它应该也有自己的状态。当我们拷贝一个像值的对象时,副本和原对象是完全独立的。改变副本不会对原对象有任何影响,反之亦然。
    • 行为像指针的类则共享状态。当我们拷贝一个这种类的对象时,副本和原对象使用相同的底层数据。改变副本也会改变原对象,反之亦然。

行为像值的类

  • 关键概念:赋值运算符

    当你编写赋值运算符时,有两点需要记住:

    • 如果将一个对象赋予它自身,赋值运算符必须能正确工作。
    • 大多数赋值运算符组合了析构函数和拷贝构造函数的工作。

    当你编写一个赋值运算符时,一个好的模式是先将右侧运算对象拷贝到一个局部临时对象中。当拷贝完成后,销毁左侧运算对象的现有成员就是安全的了。一旦左侧运算对象的资源被销毁,就只剩下将数据从临时对象拷贝到左侧运算对象的成员中了。

    // 这样编写赋值运算符是错误的
    HasPtr& HasPtr::operator=(const HasPtr &rhs)
    {
    	delete ps; // 释放对象指向的string
    	// 如果rhs和*this是同一个对象,我们就将从已释放的内存中拷贝数据!
    	ps = new string(*(rhs.ps));
    	i = rhs.i;
    	return *this;
    }
    
    // 正确处理自赋值的情况
    HasPtr& HasPtr::operator=(const HasPtr &rhs)
    {
    	auto newp = new string(*rhs.ps); // 拷贝底层string
    	delete ps; // 释放旧内存
    	ps = newp; // 从右侧运算对象拷贝数据到本对象
    	i = rhs.i;
    	return *this; // 返回本对象
    }
    

行为像指针的类

  • 令一个类展现类似指针的行为的最好方法是使用shared_ptr来管理类中的资源。但是,有时我们希望直接管理资源。在这种情况下,使用引用计数就很有用了。引用计数的工作方式如下:

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

    class HasPtr
    {
    public:
        // 构造函数分配新的string和新的计数器,将计数器置为1
        HasPtr(const string &s = string()) : ps(new string(s)), i(0), use(new size_t(1)) {}
        // 拷贝构造函数拷贝所有三个数据成员,并递增计数器
        HasPtr(const HasPtr &p) : ps(p.ps), i(p.i), use(p.use) { ++*use; }
        HasPtr &operator=(const HasPtr &);
        ~HasPtr();
    
    private:
        string *ps;
        int i;
        size_t *use; // 用来记录有多少个对象共享*ps的成员
    };
    HasPtr &HasPtr::operator=(const HasPtr &rhs)
    {
        ++*rhs.use;      // 递增右侧运算对象的引用计数
        if (--*use == 0) // 然后递减本对象的引用计数
        {
            delete ps;  // 如果没有其他用户
            delete use; // 释放本对象分配的成员
        }
        ps = rhs.ps; // 将数据从rhs拷贝到本对象
        i = rhs.i;
        use = rhs.use;
        return *this; // 返回本对象
    }
    // 拷贝赋值操作实际上是拷贝构造操作与析构操作的复合,从上述代码中可以得到印证
    
    HasPtr::~HasPtr()
    {
        if (--*use == 0) // 如果引用计数变为0
        {
            delete ps;  // 释放string内存
            delete use; // 释放计数器内存
        }
    }
    

交换操作

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

  • 如果一个类定义了自己的swap,那么算法将使用类自定义版本。否则,算法将使用标准库定义的swap。

    class HasPtr
    {
    	friend void swap(HasPtr&, HasPtr&);
    	// 其他成员定义
    };
    
    // 由于swap的存在就是为了优化代码,我们将其声明为inline函数。
    inline void swap(HasPtr &lhs, HasPtr &rhs) 
    {
    	using std::swap;
    	swap(lhs.ps, rhs.ps); // 交换指针,而不是string数据
    	swap(lhs.i, rhs.i); // 交换int成员
    }
    
  • 与拷贝控制成员不同,swap并不是必要的。但是,对于分配了资源的类,定义swap可能是一种很重要的优化手段。

调用swap,而不是std::swap

  • 内置类型是没有特定(自定义)版本的swap的,对swap的调用会调用标准库std::swap。但是,如果一个类的成员有自己类型特定(自定义)的swap函数,调用std::swap就是错误的了。

    // 错误使用标准库的swap的示例:
    void swap(Foo &lhs, Foo &rhs)
    {
        // 错误:这个函数使用了标准库版本的swap,而不是HasPtr版本
        std::swap(lhs.h, rhs.h);
        // 交换类型Foo的其他成员
    };
    
    // 正确示例:
    void swap(Foo &lhs, Foo &rhs)
    {
        using std::swap;
        swap(lhs.h, rhs.h); // 使用HasPtr版本的swap 
        // HasPtr的特定版本swap优于std::swap,匹配HasPtr版本的swap
        
        // 交换类型Foo的其他成员
        // ...
    }
    
  • 如果存在类型特定的swap版本,swap调用会与之匹配。如果不存在类型特定的版本,则会使用std中的版本(假定作用域中有using声明)。

拷贝并交换

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

    // 注意rhs是按值传递的,意味着HasPtr的拷贝构造函数...
    // ...将右侧运算对象中的string拷贝到rhs
    HasPtr& HasPtr::operator=(HasPtr rhs)
    {
    	// 交换左侧运算对象和局部变量rhs的内容
    	swap(*this, rhs); // rhs现在指向本对象曾经使用的内存
    	return *this; // rhs被销毁,从而delete了rhs中的指针
    }
    
  • 使用拷贝并交换的赋值运算符自动就是异常安全的,且能正确处理自赋值。

拷贝控制示例

  • 虽然通常来说分配资源的类更需要拷贝控制,但资源管理并不是一个类需要定义自己的拷贝控制成员的唯一原因。一些类也需要拷贝控制成员的帮助来进行簿记工作或其他操作。
  • 拷贝赋值运算符通常执行拷贝构造函数和析构函数中也要做的工作。这种情况下,公共的工作应该放在private的工具函数中完成。

对象移动

  • 在某些情况下,对象拷贝后就立即被销毁了。在这些情况下,移动而非拷贝对象会大幅度提升性能。
  • 标准库容器、stringshared_ptr类既支持移动也支持拷贝。IO类和unique_ptr类可以移动但不能拷贝。

右值引用

  • 右值引用——即必须绑定到右值的引用。我们通过&&而不是&来获得右值引用。右值引用只能绑定到一个将要销毁的对象。因此,我们可以自由地将一个右值引用的资源“移动”到另一个对象中。

  • 对于常规引用(我们可以称之为左值引用),我们不能将其绑定到要求转换的表达式、字面常量或是返回右值的表达式。右值引用可以绑定到这类表达式上,但不能将一个右值引用直接绑定到一个左值上

    int i = 42;
    int &r = i; // 正确:r引用i
    int &&rr = i; // 错误:不能将一个右值引用绑定到一个左值上
    int &r2 = i * 42; // 错误:i*42是一个右值
    const int &r3 = i * 42; // 正确:我们可以将一个const的引用绑定到一个右值上
    int &&rr2 = i * 42; // 正确:将rr2绑定到乘法结果上
    
  • 返回左值引用的函数,连同赋值、下标、解引用和前置递增/递减运算符,都是返回左值的表达式的例子,可以将一个左值引用绑定到这类表达式的结果上。返回非引用类型的函数,连同算术、关系、位以及后置递增/递减运算符,都生成右值,可以将一个const的左值引用或者一个右值引用绑定到这类表达式上。

  • 左值持久,右值短暂:左值有持久的状态,而右值要么是字面常量,要么是在表达式求值过程中创建的临时对象。

  • 由于右值引用只能绑定到临时对象,我们得知:

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

    这两个特性意味着:使用右值引用的代码可以自由地接管所引用的对象的资源。

  • 右值引用指向将要被销毁的对象。因此,我们可以从绑定到右值引用的对象“窃取”状态。

  • 变量可以看作只有一个运算对象而没有运算符的表达式。变量表达式都是左值。我们不能将一个右值引用直接绑定到一个变量上,即使这个变量是右值引用类型也不行。

    int &&rr1 = 42; // 正确:字面常量是右值
    int &&rr2 = rr1; // 错误:表达式rr1是左值!
    
  • 通过调用一个名为move的标准库函数来获得绑定到左值上的右值引用,此函数定义在头文件utility中。

    int &&rr3 = std::move(rr1); // ok
    
  • 我们可以销毁一个移后源对象,也可以赋予它新值,但不能使用一个移后源对象的值。

  • 使用move的代码应该使用std::move而不是move。这样做可以避免潜在的名字冲突。

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

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

    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;
    }
    
  • 与拷贝构造函数不同,移动构造函数不分配任何新内存;它接管给定的对象中的内存。在接管内存之后,它将给定对象中的指针都置为nullptr。这样就完成了从给定对象的移动操作,此对象将继续存在。最终,移后源对象会被销毁,意味着将在其上运行析构函数。

移动操作、标准库容器和异常

  • 由于移动操作“窃取”资源,它通常不分配任何资源。因此,移动操作通常不会抛出任何异常。当编写一个不抛出异常的移动操作时,我们应该将此事通知标准库。除非标准库知道我们的移动构造函数不会抛出异常,否则它会认为移动我们的类对象时可能会抛出异常,并且为了处理这种可能性而做一些额外的工作。

  • 一种通知标准库的方法是在我们的构造函数中指明noexcept。在一个构造函数中,noexcept出现在参数列表和初始化列表开始的冒号之间:

    class StrVec
    {
    public:
    	StrVec(StrVec&&) noexcept; // 移动构造函数
    	// 其他成员的定义,如前
    };
    StrVec::StrVec(StrVec &&s) noexcept : /* 成员初始化器 */
    { 
        /* 构造函数体 */ 
    }
    

    我们必须在类头文件的声明中和定义中(如果定义在类外的话)都指定noexcept

  • 不抛出异常的移动构造函数和移动赋值运算符必须标记为noexcept

  • 标记noexcept的原因:首先,虽然移动操作通常不抛出异常,但抛出异常也是允许的;其次,标准库容器能对异常发生时其自身的行为提供保障。例如,vector保证,如果我们调用push_back时发生异常,vector自身不会发生改变。除非vector知道元素类型的移动构造函数不会抛出异常,否则在重新分配内存的过程中,它就必须使用拷贝构造函数如果拷贝发生异常,vector可以释放新分配的内存并返回,vector原有的元素仍然存在)而不是移动构造函数如果移动发生异常,旧空间中的移动源元素已经被改变了,而新空间中未构造的元素可能尚不存在)。如果希望在vector重新分配内存这类情况下对我们自定义类型的对象进行移动而不是拷贝,就必须显式地告诉标准库我们的移动构造函数可以安全使用。我们通过将移动构造函数(及移动赋值运算符)标记为noexcept来做到这一点:

    StrVec &StrVec::operator=(StrVec &&rhs) noexcept
    {
    	// 直接检测自赋值
    	if (this != &rhs)
    	{
    		free(); // 释放已有元素
    		elements = rhs.elements; // 从rhs接管资源
    		first_free = rhs.first_free;
    		cap = rhs.cap;
    		// 将rhs置于可析构状态
    		rhs.elements = rhs.first_free = rhs.cap = nullptr;
    	}
    	return *this;
    }
    

移后源对象必须可析构

  • 在移动操作之后,移后源对象必须保持有效(一般来说,对象有效就是指可以安全地为其赋予新值或者可以安全地使用而不依赖其当前值)的、可析构的状态,但是用户不能对其值进行任何假设。

合成的移动操作

  • 如果一个类定义了自己的拷贝构造函数、拷贝赋值运算符或者析构函数,编译器就不会为它合成移动构造函数和移动赋值运算符了。如果一个类没有移动操作,通过正常的函数匹配,类会使用对应的拷贝操作来代替移动操作。

  • 只有当一个类没有定义任何自己版本的拷贝控制成员,且它的所有(非static)数据成员都能移动构造或移动赋值时,编译器才会为它合成移动构造函数或移动赋值运算符。

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

  • 除了一个重要例外,什么时候将合成的移动操作定义为删除的函数遵循与定义删除的合成拷贝操作类似的原则:

    • 与拷贝构造函数不同,移动构造函数被定义为删除的函数的条件是:有类成员定义了自己的拷贝构造函数且未定义移动构造函数,或者是有类成员未定义自己的拷贝构造函数且编译器不能为其合成移动构造函数。移动赋值运算符的情况类似。
    • 如果有类成员的移动构造函数或移动赋值运算符被定义为删除的或是不可访问的,则类的移动构造函数或移动赋值运算符被定义为删除的。
    • 类似拷贝构造函数,如果类的析构函数被定义为删除的或不可访问的,则类的移动构造函数被定义为删除的。
    • 类似拷贝赋值运算符,如果有类成员是const的或是引用,则类的移动赋值运算符被定义为删除的。
  • 如果类定义了一个移动构造函数和/或一个移动赋值运算符,则该类的合成拷贝构造函数和拷贝赋值运算符会被定义为删除的。因此,定义了一个移动构造函数或移动赋值运算符的类必须也定义自己的拷贝操作。否则,这些成员默认地被定义为删除的。

移动右值,拷贝左值

  • 如果一个类既有移动构造函数,也有拷贝构造函数,编译器使用普通的函数匹配规则来确定使用哪个构造函数。赋值操作的情况类似。

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

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

  • 如果一个类没有移动构造函数,函数匹配规则保证该类型的对象会被拷贝,即使我们试图通过调用move来移动它们时也是如此:

    class Foo
    {
    public:
        Foo() = default;
        Foo(const Foo&); // 拷贝构造函数
        // 其他成员定义,但Foo未定义移动构造函数
    };
    Foo x;
    Foo y(x); // 拷贝构造函数;x是一个左值
    Foo z(std::move(x)); // 拷贝构造函数,因为未定义移动构造函数
    

    总结为:移动右值,拷贝左值。但如果没有移动构造函数,右值也被拷贝。拷贝赋值运算符和移动赋值运算符的情况类似

拷贝并交换和移动操作

  • 拷贝并交换赋值运算符和移动操作:依赖于实参的类型,rhs的拷贝初始化要么使用拷贝构造函数,要么使用移动构造函数——左值被拷贝,右值被移动。因此,单一的赋值运算符就实现了拷贝赋值运算符移动赋值运算符两种功能:

    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; }
    	// 其他成员的定义
    };
    

更新三五法则

  • 所有五个拷贝控制成员应该看做一个整体。一般来说,如果一个类定义了任何一个拷贝操作,它就应该定义所有五个操作。如前所述,某些类必须定义拷贝构造函数、拷贝赋值运算符和析构函数才能正确工作。这些类通常拥有一个资源,而拷贝成员必须拷贝此资源。一般来说,拷贝一个资源会导致一些额外开销。在这种拷贝并非必要的情况下,定义了移动构造函数和移动赋值运算符的类就可以避免此问题。

移动成员的自赋值问题

  • 移动构造函数:从无到有,不需要判断自赋值;移动赋值运算符:从有到有,需要检查自赋值。

移动迭代器

  • 移动迭代器适配器:一般来说,一个迭代器的解引用运算符返回一个指向元素的左值。而移动迭代器的解引用运算符生成一个右值引用。

  • make_move_iterator函数将一个普通迭代器转换为一个移动迭代器。此函数接受一个迭代器参数,返回一个移动迭代器。原迭代器的所有其他操作在移动迭代器中都照常工作。由于移动迭代器支持正常的迭代器操作,我们可以将一对移动迭代器传递给算法。特别是,可以将移动迭代器传递给uninitialized_copy

    void StrVec::reallocate()
    {
    	// 分配大小两倍于当前规模的内存空间
    	auto newcapacity = size() ? 2 * size() : 1;
    	auto first = alloc.allocate(newcapacity);
    	// 移动元素
    	auto last = uninitialized_copy(make_move_iterator(begin()), make_move_iterator(end()), first);
    	free(); // 释放旧空间
    	elements = first; // 更新指针
    	first_free = last;
    	cap = elements + newcapacity;
    }
    

    uninitialized_copy对输入序列中的每个元素调用construct来将元素“拷贝”到目的位置。此算法使用迭代器的解引用运算符从输入序列中提取元素。由于我们传递给它的是移动迭代器,因此解引用运算符生成的是一个右值引用,这意味着construct将使用移动构造函数来构造元素。

  • 由于移动一个对象可能销毁掉原对象,因此你只有在确信算法在为一个元素赋值或将其传递给一个用户定义的函数后不再访问它时,才能将移动迭代器传递给算法。

不要随意使用移动操作

  • 由于一个移后源对象具有不确定的状态,对其调用std::move是危险的。当我们调用move时,必须绝对确认移后源对象没有其他用户。
  • 通过在类代码中小心地使用move,可以大幅度提升性能。而如果随意在普通用户代码(与类实现代码相对)中使用移动操作,很可能导致莫名其妙的、难以查找的错误,而难以提升应用程序性能。
  • 在移动构造函数和移动赋值运算符这些类实现代码之外的地方,只有当你确信需要进行移动操作且移动操作是安全的,才可以使用std::move

右值引用和成员函数

参数是右值的成员函数

  • 允许移动的成员函数通常使用与拷贝/移动构造函数和赋值运算符相同的参数模式——一个版本接受一个指向const的左值引用,第二个版本接受一个指向非const的右值引用。

    // 假定X是元素类型
    void push_back(const X&); // 拷贝:绑定到任意类型的X
    void push_back(X&&); // 移动:只能绑定到类型X的可修改的右值
    
  • 一般来说,我们不需要为函数操作定义接受一个const X&&或是一个(普通的)X&参数的版本。当我们希望从实参“窃取”数据时(要修改源对象),通常传递一个右值引用。为了达到这一目的,实参不能是const的。类似的,从一个对象进行拷贝的操作不应该改变该对象。因此,通常不需要定义一个接受一个(普通的)X&参数的版本。

  • 简之,区分移动和拷贝的重载函数通常有一个版本接受一个const T&,而另一个版本接受一个T&&

返回值是右值的成员函数

  • 通常,我们在一个对象上调用成员函数,而不管该对象是一个左值还是一个右值。

    string s1 = "a value", s2 = "another";
    auto n = (s1 + s2).find("a");
    s1 + s2 = "wow!";
    
  • 在参数列表后放置一个引用限定符(&或&&)来指出this的左值/右值属性。类似const限定符,引用限定符只能用于(非static)成员函数(修饰this指针),且必须同时出现在函数的声明和定义中(this指针是非static成员函数的参数之一,故声明和定义中参数this指针类型要求一致)。

    class Foo
    {
    public:
        Foo &operator=(const Foo&) &; // 只能向可修改的左值赋值
        // Foo的其他参数
    };
    Foo &Foo::operator=(const Foo &rhs) &
    {
    	// 执行将rhs赋予本对象所需的工作
        return *this;
    }
    
  • 对于&限定的函数,我们只能将它用于左值;对于&&限定的函数,只能用于右值。

    Foo &retFoo(); // 返回一个引用;retFoo调用是一个左值
    Foo retVal(); // 返回一个值;retVal调用是一个右值
    Foo i, j; // i和j是左值
    i = j; // 正确:i是左值
    retFoo() = j; // 正确:retFoo()返回一个左值
    retVal() = j; // 错误:retVal()返回一个右值
    i = retVal(); // 正确:我们可以将一个右值作为赋值操作的右侧运算对象
    
  • 一个函数可以同时用const和引用限定。在此情况下,引用限定符必须跟随在const限定符之后:

    class Foo
    {
    public:
    	Foo someMem() & const; // 错误:const限定符必须在前
    	Foo anotherMem() const &; // 正确:const限定符在前
    };
    

重载和引用函数

  • 引用限定符也可以区分重载版本。而且,我们可以综合引用限定符和const来区分一个成员函数的重载版本。

    class Foo
    {
    public:
        Foo sorted() &&; // 可用于可改变的右值
        Foo sorted() const &; // 可用于任何类型的Foo
        // Foo的其他成员的定义
    private:
        vector<int> data;
    };
    // 本对象为右值,因此可以原址排序
    Foo Foo::sorted() &&
    {
    	sort(data.begin(), data.end());
        return *this;
    }
    // 本对象是const或是一个左值,哪种情况我们都不能对其进行原址排序
    Foo Foo::sorted() const &
    {
        Foo ret(*this); // 拷贝一个副本
        sort(ret.data.begin(), ret.data.end()); // 排序副本
        return ret; // 返回副本
    }
    

    编译器会根据调用sorted的对象的左值/右值属性来确定使用哪个sorted版本:

    retVal().sorted(); // retVal()是一个右值,调用Foo::sorted() &&
    retFoo().sorted(); // retFoo()是一个左值,调用Foo::sorted() const &
    
  • 如果一个成员函数有引用限定符,则具有相同参数列表的所有版本都必须有引用限定符(标注&还是&&)。

    class Foo
    {
    public:
    	Foo sorted() &&;
    	Foo sorted() const; // 错误:必须加上引用限定符
    	// Comp是函数类型的类型别名
    	// 此函数类型可以用来比较int值
    	using Comp = bool(const int&, const int&);
    	Foo sorted(Comp*); // 正确:不同的参数列表
    	Foo sorted(Comp*) const; // 正确:两个版本都没有引用限定符
    };
    
hmoban主题是根据ripro二开的主题,极致后台体验,无插件,集成会员系统
自学咖网 » 《C++ Primer》笔记 第13章 拷贝控制