C++拷贝控制

当定义一个类的时候,我们显示或者隐式的制定这个类的对象在拷贝,移动,赋值或者销毁的时候做什么,一个类通过定义五中特殊的成员函数来控制这些操作,包括 拷贝构造函数, 拷贝赋值运算符,移动构造函数,移动赋值运算符和析构函数

拷贝和移动构造函数定义了当用同类型的另一个对象初始化本对象时做什么。
拷贝和移动赋值运算符定义了将一个对象赋予同类型的另一个对象时做什么。
析构函数定义了此类型对象销毁的时候做什么。

拷贝构造函数
如果一个构造函数的第一个参数子自身类型的引用,并且任何额外类型的参数都有默认值,则此构造函数是拷贝构造参数。

class Foo {
    Foo(); // 默认构造参数
    Foo(Foo& f); // 拷贝构造参数
}

一般而言,合成的拷贝构造函数会将其参数的成员逐个拷贝到正在创建的对象中,编译器从给定对象中依次将每个非 static的成员拷贝到正在创建的对象中。

每个成员的类型决定了它是如何拷贝的:类类型的成员会使用其拷贝构造函数,内置类型则是直接拷贝,虽然我们不能拷贝一个数组,但是合成拷贝函数会逐元素的拷贝一个数组类型的成员,如果数组元素是类类型,则使用元素的拷贝构造函数来拷贝。

拷贝初始化不仅发生在使用 = 定义变量的时候发生,还有以下情况

  • 将对象作为一个实参传递给一个非引用类型的形参
  • 从一个返回类型为非引用类型的函数返回一个对象(方法不能返回一个方法内对象的引用)
  • 用花括号列表初始化一个数组中的元素或者一个聚合类中的成员。

拷贝构造参数被从来初始化非引用类型参数,这一特性解释了为什么拷贝构造函数自己的参数必须是引用类型。如果不是,那么调用拷贝构造函数的时候,其参数有需要拷贝初始化,这样就陷入了死循环

拷贝构造函数必须是存在且可以访问的(例如不能是private)

Point foo(Point p) { // 传递参数调用一次
    Point local = p;  // = 调用一次
    Point* heap = new Point(p); // 调用一次
    *heap = p; // 不会调用拷贝构造函数
    Point pa[4] = {local, *heap}; // 调用两次
    return local;
}

拷贝赋值运算符( = 操作符 )
与类控制其对象如何初始化一样, 类也可以控制其对象如何赋值
与处理拷贝构造函数一样,如果一个类未定义自己的拷贝赋值运算符,编译器会为它生成一个合成拷贝赋值运算符,合成拷贝赋值运算符返回一个指向其左侧运算对象的引用。

析构函数
析构函数执行与构造函数相反的工作,析构函数释放对象使用的资源,并且销毁对象的非static成员数据,析构函数是一个类的成员函数,名字由 ~ 加上类名构成,它没有返回值也没有参数,所以析构函数不能被重载。

在一个析构函数中首先执行函数体然后销毁成员,成员按照初始化的顺序逆序销毁。销毁类类型的成员需要执行成员自己的析构函数,内置类型没有析构函数,因此销毁内置类型成员什么也不需要做,需要注意的是,当隐式的销毁一个内置类型的成员不会delete 它所指向的对象。

与普通指针不同,智能指针是类类型,所有具有析构函数,只能指针成员在析构阶段会被自动销毁。

合成析构函数
当一个类未定义自己的析构函数时,编译器会为它定义一个合成析构函数,类似拷贝构造函数和拷贝赋值运算符,对于某些类,合成析构函数被用来阻止该类型的对象被销毁。

成员是在析构函数之后隐含的析构阶段被销毁的,在整个对象被销毁的过程中,析构函数作为成员销毁步骤之外的另一部分而进行鍀

三/五法则

  • 需要析构函数的类需要拷贝和赋值操作
  • 需要拷贝操作的类也需要赋值操作,反之亦然

阻止拷贝

  • 定义删除的函数,在函数的参数列表后面加上=delete,来指出我们希望将它定义为删除的。=delete通知编译器以及我们代码的读者,我们不希望定义这些成员,因此可以使用 = delete 放在拷贝参数的后面来阻止拷贝。但是需要注意的是析构函数不能使用=delete. 如果析构函数被删除,那么久无法销毁此类型的对象了,对于一个删除了析构函数的类型,编译器将不允许定义该类型的变量或者创建该类型的变量和是临时对象。对于定义了析构函数的类型,虽然我们不能定义这种类型的变量或者成员,但是可以动态的分配这种类型的对象。
    本质上,当含有不可能拷贝,赋值或者销毁的成员时,类的合成拷贝控制函数就被定义成删除的、
  • 在新的标准发布之前,类是通过将其拷贝函数和拷贝赋值运算符定义为private 来阻止拷贝。

希望阻止拷贝的类应该使用=delete 来定义他们自己的拷贝构造函数和拷贝赋值运算符、

拷贝控制和资源管理

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

赋值运算符
当编写赋值运算符的时候有两点需要注意

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

当编写一个赋值运算符的饿时候,一个好的模式是现将右侧运算对象拷贝到一个局部临时变量中,当完成拷贝之后销毁左侧运算对象的现有成员就是安全的了,完成之后就可以将剩下的数据拷贝到左侧对象的成员中了。

定义行为像指针的类

对行为类似指针的类,我们需要为期定义拷贝构造函数和拷贝赋值运算符,来拷贝指针成员本身而不是其指向的对象,自定义的类任然需要自己的析构函数来释放接受构造函数所指向的内存,但是析构函数不能单方面的释放内存,只有当最后一个指针被销毁的时候才能释放所指向的内存。令一个类展现类似指针最好的方法就是使用shared_ptr。但是如果我们希望自己直接来管理,那么使用引用计数会是一个很好的选择。

引用计数
引用计数的工作方式如下:

  • 除了初始化对象,每个构造函数都要创建一个引用计数,用来记录有多少对象与正在创建的对象共享状态,当我们创建一个对象的时候,只有一个对象共享状态,因此计数器的值为1
  • 拷贝构造函数不分配新的计数器,而是与拷贝给定对象的数据成员,包括计数器。拷贝构造函数递增共享的计数器。
  • 析构函数递减计数器,表示共享状态的对象少了一个,当一个计数器为零的时候,析构函数需要释放对象。
  • 拷贝赋值运算符递增右侧运算对象的计算器,递减左侧运算对象的计数器,如果左侧运算对象的计数器为零,那么拷贝赋值运算符需要销毁对象。

C++ swap 函数

在大多数时候,使用默认的swap函数就足够了, 但是对于一些自定义的类型来说就不是了,因为在默认的实现中,交换两个对象需要一次拷贝和两次赋值,但是对于某些类型来说,拷贝的代价太大,所以需要尽可能的避免拷贝,同时,在自定义的类型的成员变量中,存在成员指针,这个时候我们只需要交换指针即可,而不需要拷贝指针所指向的对象。

移动函数
在C++的新特性中,一个很重要的特性就是移动而非拷贝的能力,因为在很多情况下,我们在完成了拷贝之后马上就将拷贝对象原来所占用的内存销毁掉了,如果这个时候使用移动而非拷贝就可以避免一部分开销,有助于性能的提升。

右值引用

在新的C++标准中为了支持移动操作,添加了这一种新的引用类型,右值引用。所谓的右值引用就是必须绑定到右值上, 可以通过&& 来绑定到一个右值上。左值引用这右值引用有明显的区别:左值有持久的状态,而右值要么是常亮要么是求值过程中临时创建的对象。右值引用只能绑定到临时对象,那么右值引用的对象即将被销毁,而且该对象没有其他的用户=。

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

虽然不能直接将一个右值引用绑定到一个左值上,但是可以使用std::move() 函数来显示的将一个左值引用转换为一个右值引用。但是需要注意的一点是:调用move就意味着除了对传入的引用的赋值或者销毁外,将不再使用这个引用。在调用move之后,不能对对象的状态作任何假设。

移动构造函数
移动构造函数的第一个参数是该类类型的一个右值引用,除此之外所有其他的参数都应该有默认值除了完成资源的移动,移动构函数还必须保证对移动源对象的销毁操作是无害的。特别需要注意的是,一旦资源完成移动,源对象就不在只想被移动的资源。

移动操作,标准库容器和异常
由于移动构造函数通常只是移动资源而不分配任何资源,所以移动操作通常不会抛出任何异常。当我们需要定义一个不抛出异常的函数时,我们需要在函数声明的后面手动加上noexcept,如果我们不这样做,那么标准库会认为移动自定义的对象可能会抛出异常,并且为了处理这种可能性而做一些额外的工作。

为什么需要noexcept:

  • 虽然移动操作通常不会抛出异常,但是抛出异常是允许的
  • 标准库容器能对异常发生时自身的行为提供保障

比如有以下情况的存在,自定义类

class A {
    void * data1;
    void * data2;
}

当需要完成移动构造函数时,如果在移动了data1 之后出现了异常,无法完成data2的移动,那么这个时候将无法满足A 自身保持不变的情况(data1已经被改变)。 另一方面,如果使用拷贝构造函数,那么这个时候无论拷贝有没有出现异常,原来的A都不会受到影响。

因此为了避免这种潜在的问题,除非标准库知道元素类型在移动构造函数不会抛异常,否则在重新分配内存的过程中,它就必须使用拷贝构造函数而不是移动构造函数。如果希望标准库容器在重新分配内存的情况使用自定义的移动构造函数而不是拷贝构造函数就必须将移动构造函数声明为noexcept。

移动赋值运算符
移动赋值运算符执行与析构函数和移动构造函数相同的工作,与移动构造函数一样,如果移动赋值运算符不抛出任何异常,应该将其标记为noexcept,类似拷贝赋值运算符,移动赋值运算符必须正确处理自赋值的情况。

下面是使用移动构造函数和移动赋值运算符的一个示例、

#include <string>
#include <iostream>
#include <vector>
#include <utility>

using namespace std;
class MyClass {

private:
    string* name;

public:
    MyClass(string * name_ = new string("MyClass....")) : name(name_) {

    }   

    MyClass(const MyClass & other) {
        this->name = new string(*other.name);
        cout<<"copy construct is invoked"<<endl;
    }

    MyClass(MyClass && right) noexcept {
        if (this != &right) {
            delete name;
            this->name = right.name;
            right.name = nullptr;
        }
        cout<<"move construct is invoked"<<endl;
    }

    ~MyClass() {
        if (name) {
            delete name;
        }
    }

    string getString() const {
        return *name;
    }

    MyClass& operator=(MyClass && right) noexcept {
        if (this != &right) {
            delete name;
            this->name = right.name;
            right.name = nullptr;
        }
        cout<<"move assigment is invoked"<<endl;
        return *this;
    }
};

int main() {
    string name = "xxxxxx";
    MyClass test(&name);
    MyClass test1;
    test1 = std::move(test);
    cout<<test1.getString()<<endl;
}

移动后源对象必须可析够

从一个源对象移动数据并不会销毁这个对象,但是有时候在完成移动操作之后,源对象会被销毁,因此当我们编写一个移动操作时,必须确保一个源对象进入可析够状态。

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

需要注意的是,不建议随意使用移动操作,由于一个移动源对象具有不确定状态,对其调用std::move是危险的,当调用std::move的时候,必须确定移动后源对象没有其他的用户,通过在类代码中小心使用move可以大幅提升性能。

右值和左值引用成员函数
在旧的标准中,不能阻止对对右值进行赋值,为了维持向后兼容性,新的标准库任然允许想右值赋值,但是在自定义的类中,可以阻止这种情况的出现。