美文网首页
C++ 拷贝控制(一) — 析构函数、拷贝构造函数与拷贝赋值函数

C++ 拷贝控制(一) — 析构函数、拷贝构造函数与拷贝赋值函数

作者: 进击的Lancelot | 来源:发表于2020-07-05 17:13 被阅读0次

什么是 C++ 的拷贝控制 ?

我们知道在 C++ 当中,类类型是一种由用户自定义的数据类型。既然是数据类型,我们很自然地会希望在定义上和其他的内置类型有着相同的操作。回想一下,当我们在定义一个内置类型变量时,我们需要考虑以下几种情况:

// 内置类型
{
    int a = 10; //定义一个 int 变量并初始化
    int b = a;  //使用已定义好的变量 a 初始化变量 b
    int c;      //定义一个变量 c 并初始化为默认值
    c = a;      //将 a 的值赋给 c
}// 离开作用域后将局部变量 a、b、c 销毁

而为了实现这样的功能,C++ 为类类型提供了构造函数、析构函数、拷贝构造函数、拷贝赋值运算符、移动构造函数和移动赋值运算符。其中类的拷贝控制成员包括了析构函数、拷贝构造函数、拷贝赋值运算符、移动构造函数和移动赋值运算符,而后两者则是在 C++ 的新标准中引入的,它们为类提供了 “剪切” 操作。

析构函数

析构函数的定义方式如下:

class MyClass{
public:
    ...
    //析构函数
    ~MyClass(){
        cout << "MyClass Deconstructor" << endl;
    }   
    ...
};

从定义方式可以看到,析构函数没有返回值,没有参数列表,且函数名固定为 “~类名”。由于没有参数列表,因此析构函数不可以重载,一个类中只能有一个析构函数。

当一个对象被销毁时,会自动调用其对应的析构函数。在析构函数中,首先会执行函数体,然后按照成员初始化顺序进行逆序销毁。如果对象成员中有其他类的对象,则也会调用对应析构函数进行销毁操作。这也就意味着析构函数本身并不直接销毁对象成员。成员对象是在析构函数之后隐含的析构阶段销毁的,而析构函数体是作为成员销毁步骤的一个前置操作。
一个对象被销毁的时机主要有以下几种:

  • 局部对象离开其作用域时会被销毁
  • 存放对象的容器被销毁,容器中的对象都会被销毁
  • 对动态分配内存的对象指针执行 delete 操作时,所指向的对象会被销毁
  • 对于临时对象,当创建它的完整表达式结束的时候销毁

当用户没有显式地定义类的析构函数,那么编译器会自动为类生成默认的析构函数。默认的析构函数有两种可能

  • 默认析构函数的函数体为空,什么都不操作
  • 若类的某个成员的析构函数被指明为 = delete 的,则这个类的默认析构函数也是 = delete 的。

拷贝构造函数

拷贝构造函数的函数定义如下

class MyClass{
public:
    ...
    // 拷贝构造函数
    MyClass(const MyClass& obj):var(obj.var){
        cout << "MyClass Copy Constructor" << endl;
    }   
    ...
private:
    int var;
};

从定义上来看,拷贝构造函数的形参是常引用类型,没有返回值。其中需要注意的是,拷贝构造函数的参数必须是引用类型,而不能为值类型。如果形参为值类型,则在调用拷贝构造函数时,需要将实参拷贝给形参,则会引发新一轮拷贝构造函数的调用,导致无限递归。将引用定义为 const 是因为拷贝构造函数不应当修改源对象的值,但这并非强制要求。当用户没有显式定义拷贝构造函数时,而程序中又使用到了对象的拷贝功能,则编译器会自动生成默认的拷贝构造函数。默认的拷贝构造函数只能实现浅拷贝操作。

由于拷贝构造操作常常会被隐式调用,因此拷贝构造函数通常不声明为 explicit。例如:

string A("test");   //直接初始化,调用构造函数
string B(A);        //直接初始化,调用拷贝构造函数
string C = A;       //拷贝初始化,调用拷贝构造函数
string D = "test";  //拷贝初始化,调用拷贝构造函数
string E = string("test");  //拷贝初始化,调用拷贝构造函数

由于编译器优化的原因,像 string D = "test" 通常会被优化为 string D("test"),进而提高执行效率

拷贝赋值运算符

拷贝构造运算符的函数定义如下

class MyClass{
public:
    ...
    // 拷贝构造函数
    MyClass& operator=(const MyClass& obj){
        cout << "MyClass Copy Assignment" << endl;
        if(this != &obj){
            auto newp = new string(*obj.p_str);
            delete p_str;
            p_str = newp;
        }
        return *this;
    }   
    ...
private:
    string *p_str;
};

从定义上看,拷贝赋值运算符是一个返回对象的引用,参数为对象的常引用的运算符函数。在实现的过程中,我们利用 this != obj 来预防自赋值操作。同时为了保证运算符的实现是异常安全的,我们采用先将右值保存到一个临时对象中,随后释放自身的成员对象,并完成拷贝操作。同样的,如果没有显式地定义类的拷贝赋值函数而代码又使用了拷贝赋值功能,那么编译器将会自动生成默认的拷贝赋值运算符。

在定义拷贝赋值运算符的时候,有三个需要注意的地方:

  • 当使用一个对象对自身进行赋值时,赋值运算符依然要保证有正确的行为。
  • 一个定义良好的拷贝赋值运算符应当是异常安全的,即当异常发生时,能够使左侧运算对象处于一种有意义的状态
  • 大多数的拷贝赋值运算符组合了析构函数和拷贝构造函数的工作。

什么情况下,需要程序员手动实现拷贝构造函数和拷贝赋值运算符(三/五法则)

  1. 当类需要实现析构函数时,那么往往也需要实现拷贝构造函数和拷贝赋值运算符。而实现了拷贝构造函数和拷贝赋值运算符的类却不一定要显式定义析构函数
    【注:基类的析构函数是个例外,不遵循该原则】

    这主要是因为当程序需要显式定义析构函数时,往往意味着我们需要手动释放资源。而使用默认拷贝构造函数则会导致“深浅拷贝问题”。

    class A{
    public:
        A(string const s = ""):ps(new string(s)){}
        ~A(){delete ps;}
        void display(void){
            cout << *ps << endl;
        }
    private:
        string *ps;
    };
    int main(void){
        A* a = new A("test");
        A b(*a);
        A c;
        c = *a;
        delete a;
        b.display(); //对象 b 试图访问已被删除的对象 a 中的 ps
        c.display(); //对象 c 试图访问已被删除的对象 a 中的 ps
        return 0;
    }
    

    由于没有显式定义拷贝构造函数和拷贝赋值运算符,因此编译器生成默认拷贝构造函数和拷贝赋值运算符,它们仅仅只拷贝了 a.ps 的值,但并没有拷贝 a.ps 所指向的对象。因此,a.ps, b.ps, c.ps 均指向同一对象,在 a.ps 所指向的对象被释放后,b 和 c 又试图去访问它,这种操作的后果是未定义的。

  2. 如果定义了拷贝构造函数,那么通常也要定义拷贝赋值运算符;反之同理

  3. 如果一个类是可拷贝的,那么它应该是可移动的。但如果一个类是可移动的,它不一定是可拷贝的,例如 unique_ptr 或 IO 类

注:三/五法则并不是指有 3 条或 5 条法则,而是因为在 C++ 的早期标准中只有析构函数、拷贝构造函数和拷贝赋值运算符,这三者应当作为整体考虑,这称之为 “C++ 三法则”。而 C++ 的新标准引入了移动构造函数和移动赋值运算符,将三法则扩充为五法则,后统一称之为 “三/五法则” 。详见《C++ 三法则

如何阻止对象的拷贝功能

对于某些类对象而言,比如 IO 类或者包含 unique_ptr 成员的类对象,我们不能为其提供拷贝操作。即使我们不去实现拷贝构造函数和拷贝赋值运算符,编译器也会自动生成默认的拷贝构造函数和拷贝赋值运算符。我们先来看看 C++ 的新旧标准当中是如何解决这个问题的。

在早期的 C++ 标准中,用户可以通过将拷贝构造函数和拷贝赋值运算符的访问权限设置为 private,而且对这两个函数只声明不定义。由于设置 private,因此当用户代码试图拷贝类对象时,会产生编译错误。若成员函数或友元函数试图拷贝对象,则会因函数未定义而引发链接错误。

在 C++ 的新标准中引入了 = delete 来表明显式地禁止使用某个函数。具体的用法如下:

struct NoCopy{
    NoCopy();
    NoCopy(const NoCopy&) = delete;
    NoCopy& operator=(const NoCopy&) = delete;
    ~NoCopy() = default;
};
NoCopy::NoCopy() = default;
int main(void){
    NoCopy a;
    NoCopy b(a);    //error: use of deleted function 'NoCopy::NoCopy(const NoCopy&)'
    NoCopy c;
    c = a;          //error: use of deleted function 'NoCopy& NoCopy::operator=(const NoCopy&)'
    return 0;
}

显然,引入 = delete 使得为了特定类类型提供禁止拷贝功能变得更加简单。不仅如此,= delete 还可以修饰普通函数,来禁止某些特定的隐式类型转换。例如一个针对 int 类型的 add 函数,我们不希望当用户传递 double 类型的实参时,会因为隐式类型转换而损失精度,我们可以禁止 add 的重载版本来实现这个功能:

int add(int const a, int const b){
    return a + b;
}
int add(double const a, double const b) = delete;
int main(void){
    cout << add(3,4) << endl;
    cout << add(3.0,4.5) << endl;   //error: use of deleted function 'double add(double, double)'|
    return 0;
}

= delete 和 = default 的区别

从前面的描述当中,我们可以看到 = delete 和 = default 在用法上的相似性,接下来我们来看一看它们二者之间的区别

  • 理论上所有函数都可以指定为 = delete,而只有类的特殊成员函数 (构造函数、析构函数、拷贝构造函数和拷贝赋值运算符) 才能指定为 = default

  • = default 可以在类内(inline)声明,也可以在类外(out of line) 声明,而= delete 必须在函数的首次声明时指定,这也就意味着 = delete 只能在类内声明

    struct A{
        A();
        A(const A&) = delete; //= delete 必须在函数首次声明时指定
        A& operator=(const A&) = delete;
    };
    A::A() = default;         //= default 可以在类外声明
    

注意:理论上所有函数都可以指定为 delete 的,但通常情况下不能删除析构函数。如果析构函数被删除,则无法销毁此类型对象。而对于删除了析构函数的类,或者类成员中包含了删除析构函数的类的对象,则编译器会禁止定义该类型的对象或创建该类型的临时对象。

相关文章

网友评论

      本文标题:C++ 拷贝控制(一) — 析构函数、拷贝构造函数与拷贝赋值函数

      本文链接:https://www.haomeiwen.com/subject/foivqktx.html