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

什么是 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 的,但通常情况下不能删除析构函数。如果析构函数被删除,则无法销毁此类型对象。而对于删除了析构函数的类,或者类成员中包含了删除析构函数的类的对象,则编译器会禁止定义该类型的对象或创建该类型的临时对象。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,189评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,577评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,857评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,703评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,705评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,620评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,995评论 3 396
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,656评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,898评论 1 298
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,639评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,720评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,395评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,982评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,953评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,195评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,907评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,472评论 2 342