构造析构与拷贝赋值那些事

时间:2021-10-30 19:30:24

构造函数

关于构造函数,我们耳熟能详,似乎都没有必要成为一个知识点,或者说是重要的知识点拿出来特殊说明,毕竟C++的编译器都能帮我们完成这个工作,只是,事情真的如想象的那么简单么;

可能不是。

本文试图挖掘关于构造函数,可能不是那么简单的一面,当然也不会很全面,权当一起学习了。

构造函数的概念:提供类的对象的初始化的方式,类通过一个或几个特殊的成员函数来控制对象的初始化过程。

有这个概念出发,我们可以知道,所有的构造函数都是在类的对象初始化时由系统调用的,具体调用哪个是按重载函数的调用规则来的。

备注:构造函数不能被声明为const。可以想想为何?

构造函数也不能是虚函数,这个应该好解释。

默认构造函数

这个最简单,在面向对象的世界里,万物皆是对象,因为万物皆需要构造函数,如果我们没有定义一个构造函数,那么就由C++的编译器帮我们完成,在《c++ primer》里叫做合成的默认构造函数。

下面开始我们的编码求学之旅:

首先,定义一个类设计者工具类:

#include <iostream>

using namespace std;
class ClassDesignTool
{
public:
    void printSp(){
    cout << sp_ << "\n";
    }
private:
    string *sp_;
        
};

在这样一个什么没有写构造函数的类里,默认构造函数依然会在编译阶段生成,测试代码如下:

ClassDesignTool tool;
tool.printSp();

在VS2010的编译环境下的结果是CCCCCCCC,看到这个你应该很熟悉,这是Windows环境下对所有未显式赋值变量的默认赋值,这也就能证明,Windows系统在编译后使用默认合成构造函数,将成员变量sp_赋值为CCCCCCCC了。

如果你不放心,可以把默认构造函数加上去,

ClassDesignTool(){};

测试的结果是一样的。

这说明,如果你不准备在类的对象初始化时做点什么,完全可以把这件事交给编译器。反之,我们需要做点别的工作了。

覆盖默认构造函数

可能,你认为默认的合成构造函数什么事也没做,对它心有怨恨,所以你决定出马把它改写(覆盖之)。

ClassDesignTool():sp_(new string("lcksfa")){
    cout << "use override default constructor " << "\n";
}

//打印函数同时修改
void printSp(){
    cout << "sp_ is " << sp_->c_str() << "\n";
}

测试结果:

use override default constructor
sp_ is lcksfa

现在,我们覆盖(override)了默认构造函数,合成的默认构造函数不会被调用,而调用我们自己的构造函数。

构造函数重载

函数重载(overload)的概念,我相信大家都不会陌生,对于构造函数,同样的也能将其重载。和调用普通的重载函数一样,系统会在初始化对象时,根据不同的参数类型去调用不同的重载构造函数:

在上面的代码里添加如下代码:

//overload constructor  
ClassDesignTool(const string& str)
    :sp_(new string(str)){
        std::cout << "use overload constructor " << "\n"; 
    }

以上,我们重载了一个构造函数,其参数为一个const string&类型。

ClassDesignTool tool4(string("4"));
tool4.printSp();

测试结果如下:

use overload constructor
sp_ is 4

这说明,当我们添加了构造函数的重载函数后,使用string("4")参数构造对象时,调用了我们的string参数的构造函数。

拷贝构造函数

上面的东西都很简单,下面,我们说下稍微复杂的。

从函数重载层面,拷贝构造函数也是构造函数的重载,只是其参数为本类的const引用,如下:

//copy constructor
ClassDesignTool(const ClassDesignTool&);
ClassDesignTool::ClassDesignTool(const ClassDesignTool& rhs)
{
    std::cout << "use copy constructor from " << rhs.sp_->c_str() << "\n";
    sp_ = new string(*(rhs.sp_));
}

什么时候调用?

ClassDesignTool tool("lcksfa");
ClassDesignTool tool2(tool);
tool2.printSp();

测试输出:

use overload constructor
use copy constructor from lcksfa
sp_ is lcksfa

以上代码说明,tool是使用的构造函数初始化,其参数为"lcksfa",而tool2是使用拷贝构造函数初始化,其参数为tool。

析构函数

说完构造函数,说下析构函数。我们知道对象在创建时调用了构造函数,而在销毁时则会调用析构函数。

//destructor
~ClassDesignTool(){
    std::cout <<"use destructor "<<sp_->c_str()<<"\n";
    delete sp_;
}

以上是析构函数,事实上,我已经把默认的析构函数给覆盖了,原因在于sp_的内存释放,如果使用合成的默认析构函数,系统将不会释放sp__的内存,从而导致内存泄漏。

和构造函数不同,析构函数没有重载函数。这一点和人生很像啊。

执行方式

每一个构造函数都是 由两部分组成的,一个是初始化部分,另一个才是函数体,成员的初始化是在函数体执行之前完成的,所以你的代码里也需要做这两个部分的区分,不要把成员的初始化和函数体混为一体,因为,可能会影响析构函数的执行(只是,没有你想的那么严重)。因为一个析构函数,其也是由函数体和其析构部分组成的,析构时,先执行函数体,再执行销毁操作,成员按构造的初始化列表的逆序销毁。

由析构函数体引起的

如果你需要覆盖重写析构函数体,那么几乎可以肯定你还需要拷贝构造函数和拷贝赋值运算符。

举例子,我在上面的程序中重写了析构函数,因为我需要显示释放sp_的内存,按上面的程序看,还可能出现什么问题呢?毕竟我没有拷贝赋值运算符函数。在测试函数中添加以下代码:

ClassDesignTool tool ;
{
    ClassDesignTool tool2("not me");

    tool2 = tool;
    // tool.printSp();
    tool2.printSp();
}

测试输出:

use override default constructor
use overload constructor
sp_ is lcksfa
use destructor lcksfa
use destructor
///奔溃了!!!

使用大括号{}将tool2的赋值部分封起来,确保tool2先析构。

程序输出后,到tool析构处就奔溃了!

原因何在?

因为这里的系统默认的赋值运算是直接将sp_ 的值进行赋值,而没有去拷贝sp_ 指向的内存,tool2离开作用域时调用析构将sp_ delete掉了,等到tool离开作用域时,尝试delete的还是同一块内存,于是就出现了double delete的问题!

赋值操作运算符

这种情况的解决方案之一就是我们自己定义一个赋值操作运算符:

ClassDesignTool& 
ClassDesignTool::operator=(const ClassDesignTool& rhs)
{
    std::cout << "use copy-assignment operaotr"<<"\n";

    auto spNew = new string(*(rhs.sp_));
    delete sp_;
    sp_ = spNew;
    return *this;
} 

本函数的写法颇为模式化:

  1. 将待拷贝的对象拷贝到新内存
  2. 释放sp_原来指向的内存
  3. 使用新拷贝的指针值给sp_赋值。
  4. 最后将 * this的引用返回(可以说凡是期望返回ClassDesignTool& ,最后都是返回 * this)

总结起来就是 综合了析构和构造函数的操作。销毁了左值运算对象的资源,而从右值运算对象中拷贝资源。

小结:本文初略的说明了构造函数、析构函数和拷贝赋值运算符的重载,可以作为入门者的参考。