【第三节】类的构造和析构函数

目录

一、数据成员的初始化

二、构造函数

2.1 什么是构造函数

2.2 构造函数的注意事项

三、析构函数

四、带参数的构造函数

五、缺省构造函数

六、构造函数初始化列表

七、拷贝构造函数和调用规则

八、深拷贝和浅拷贝

九、总结


一、数据成员的初始化

        定义普通变量,在定义时,一般都会给定一个初始值。

#include <iostream>
using namespace std;

int g_Num= 100;  //全局变量
int main() {
    int nNumA = 30;//局部变量
    int *pNum=new int[20];//堆
    memset(pNum, 0, 20*sizeof(int));
    cout<<g_Num << nNumA;
    return 0;
}

        在以上的例子中,我们给局部变量和全局变量,堆内都定义了初始值。但是假如我们使用了更为复杂的类,用类定义对象该怎么办呢?如下所示:

//定义一个办公桌类
class CDesk {

private:
    int m_high;
    int m_width;
    int m_length;
    int m_weight;
};

//全局对象
CDesk g_objDesk; 

int main() {
    // 局部对象
    CDesk objDesk;
    CDesk* pobjDesk = new CDesk;//堆对象
    delete pobjDesk;
    return 0;
}

上面的都是没有初始值的,或许有人可能想到下面的方法。

class CDesk { //定义一个办公桌类
public:
    //自己添加一个函数,能够给各个成员赋值。
    void SetValue() {
        m_high = 0;
        m_width = 0;
        m_length = 0;
        m_weight = 0;
    }

private:
    int m_high;
    int m_width;
    int m_length;
    int m_weight;
};

CDesk g_objDesk; //全局对象

int main() {
    CDesk objDesk;  //局部对象
    objDesk.SetValue();
    objDesk.SetValue();
    CDesk* pobjDesk = new CDesk;//堆对象
    pobjDesk->SetValue();
    delete pobjDesk;
    return 0;
}

这样就显得非常的麻烦。有没有更好的方法呢?有的,就是使用下面学习的构造函数。

二、构造函数

2.1 什么是构造函数

        构造函数是一种独特的成员函数,其名称与类名完全一致,且不具备返回值类型,因此无法为其定义返回类型,亦不可使用void。在常规情况下,构造函数应被声明为公有函数。然而,将其声明为私有函数则有其特殊目的,例如在单例模式中使用。当创建类类型的新对象时,系统会自动调用相应的构造函数。

代码示例:

class CDesk { //定义一个办公桌类
public:
    CDesk() {
        cout << "我是构造函数"<<endl;
    }

private:
    int m_high;
    int m_width;
    int m_length;
    int m_weight;
};

CDesk g_objDesk; //全局对象

int main() {
    CDesk objDesk;  //局部对象
    CDesk* pobjDesk = new CDesk;//堆对象
    delete pobjDesk;
    return 0;
}

运行结果:

        可以看到并没有人主动调用构造函数,但是这个函数自动被调用了。注意:全局对象局部对象,堆中的对象,只有有对象被创建了,都会自动调用构造函数。

2.2 构造函数的注意事项

问题一:构造函数仅用于初始化吗?
答案是否定的。构造函数是C++语言提供的一种机制,它在对象创建时自动被调用。在构造函数内部,可以编写任何代码,甚至可以模拟一个贪吃蛇游戏。然而,构造函数之所以具备这种自动调用的特性,主要是为了便于程序开发者进行对象的初始化工作。

问题二:构造函数能否在类外部定义?
确实可以。在类外部定义构造函数时,需要在函数名前加上“类名::”。构造函数没有返回类型,且不能被声明为虚函数。

示例代码:

class CDesk { //定义一个办公桌类
public:
    CDesk();

private:
    int m_high;
    int m_width;
    int m_length;
    int m_weight;
};

CDesk::CDesk() {//类外构造函数
    m_high = 0;
    m_width = 0;
    m_length = 0;
    m_weight = 0;
}

CDesk g_objDesk; //全局对象

int main() {
    CDesk objDesk;  //局部对象
    CDesk* pobjDesk = new CDesk;//堆对象
    delete pobjDesk;
    return 0;
}

问题三:main函数是第一个被调用的函数吗?
下面有了一个反例,全局对象的构造函数先于 main 函数执行。

class CDesk { //定义一个办公桌类
public:
    CDesk();

private:
    int m_high;
    int m_width;
    int m_length;
    int m_weight;
};

CDesk::CDesk() {//类外构造函数
    cout << "我是构造函数" << endl;
    m_high = 0;
    m_width = 0;
    m_length = 0;
    m_weight = 0;
}

CDesk g_objDesk; //全局对象

int main() {
    cout << "我是 main函数" << endl;
    CDesk objDesk;  //局部对象
    CDesk* pobjDesk = new CDesk;//堆对象
    delete pobjDesk;
    return 0;
}

运行截图:

问题四:如果一个类的成员是另一个类的对象,那么哪个构造函数会先被调用?
在对象创建过程中,如果一个类的数据成员是另一个类的对象,那么在调用构造函数时,首先会自动调用该数据成员对象的构造函数,随后再执行本类对象的构造函数。以下程序中的ClassRoom类(代表班级)包含学生类和老师类对象作为其成员,该程序展示了构造函数的调用顺序。

代码示例:

#include <iostream>
using namespace std;
class CStudent {
public:
    CStudent() {
        cout << "学生类构造函数" << endl;
    }
};

class CTeacher {
public:
    CTeacher() {
        cout<< "老师类构造函数" << endl;
    }
};

class ClassRoom {//假设这个班级中有3名学生一个老师
public:
    ClassRoom() {
        cout << "班级类构造函数" << endl;
    }
private:
    CStudent m_arrStr[3];
    CTeacher m_objTea;
};

int main() {
    ClassRoom objClass;
    return 0;
}

运行结果:

可以看出,对象数据成员的构造函数也是按照定义顺序构建的。

问题五:new操作符与构造函数之间有何关联?
new操作符不仅负责分配内存,还会自动调用相应的构造函数。这一点在上文的示例中已得到体现。值得注意的是,虽然malloc函数同样能够分配堆内存,但它无法识别对象类型,因此不会调用构造函数。

问题六:构造函数能否带有参数?
确实可以。构造函数可以包含参数,并且一个类可以定义多个构造函数,通过参数的不同来实现函数重载。

三、析构函数

        除了构造函数之外,C++还引入了析构函数。当对象生命周期结束时,析构函数会自动被调用。它通常用于回收对象内部申请的资源,例如,如果对象中有一个指针指向堆内存,可以在析构函数中释放这部分内存。

析构函数的特点如下:
A. 没有返回类型;
B. 不接受任何参数;
C. 不应随意调用,尽管在特定情况下可以调用;
D. 不能进行重载(相比之下,构造函数可以有参数并支持重载);
E. 析构函数的功能与构造函数相对应,因此其名称是在构造函数名前加上逻辑非运算符“~”。

        当对象的生命周期结束时,例如在函数体内定义的局部对象,随着函数调用的结束,这些局部对象将被释放,此时会调用析构函数。

以下情况需要使用析构函数:
A. 如果构造函数打开了文件,在使用完毕后,需要通过析构函数关闭文件;
B. 如果从堆中分配了动态内存,在对象销毁前,必须通过析构函数释放这部分内存。

代码示例:

#include <iostream>
using namespace std;

class CTest {
public:
    CTest() {//构造函数
        m_szName = new char[20];
    }
    ~CTest() {//析构函数
        delete[] m_szName;
    }
private:
    char* m_szName;
};

int main() {
    CTest objClass;
    return 0;
}

        该类定义的构造函数在对象之外分配一段堆内存空间,撤销时,由析构函数收回堆内存。注意,析构函数以调用构造函数相反的顺序被调用,请看如下示例:

#include <iostream>
using namespace std;

class CMonitor {
public:
    CMonitor() { cout << "构造 显示器.\n"; }
    ~CMonitor() { cout << "析构 显示器.\n"; }
};

class CKeyboard {
public:
    CKeyboard() { cout<<"构造 键盘.\n"; }
    ~CKeyboard() { cout<<"析构 键盘.\n"; }
};

class CComputer {
public:
    CComputer() { cout << "构造 电脑.\n"; }
    ~CComputer() { cout << "析构 电脑.\n"; }

protected:
    CMonitor m_objMonitor;//数据成员是类对象
    CKeyboard m_objKeyboard;//数据成员是类对象
};

int main() {
    CComputer* com = new CComputer();//显式调用无参构造
    delete com;
    return 0;
}

四、带参数的构造函数

        不带参数的构造函数不能完全满足初始化的要求,因为这样创建的类对象具有相同的初始化值,如果需要对类对象按不同特征初始化不同的值,应采用带参数的构造函数。如下面程序所示:

class CLocation {
public:
    CLocation(int nNumA, int nNumB) {
        m_X = nNumA;
        m_Y = nNumB;
    }
    int getx() {
        return m_X;
    }
    int gety() {
        return m_Y;
    }
private:
    int m_X, m_Y;//数据成员
};

一个类可以拥有多个构造函数构成重载,这样可以多样化的对对象进行初始化,请看如下示例代码:

#include <iostream>
using namespace std;

class CLocation {
public:
    CLocation(int nNumA, int nNumB) {
        cout << "2个参数的构造函数" << endl;
        m_X = nNumA;
        m_Y = nNumB;
    }


    CLocation(int nNumA) {
        cout << "1个参数的构造函数" << endl;
        m_X = nNumA;
        m_Y = nNumA * 2;
    }


    int getx() {
        return m_X;
    }
    int gety() {
        return m_Y;
    }
private:
    int m_X, m_Y;//数据成员
};

int main() {
    CLocation objA(1, 2);
    CLocation objB(3);
    return 0;
}

可以看出构造函数可以构成重载,根据初始化时传递的参数自动选择构造函数调用。

五、缺省构造函数

        C++语言规定,每个类都必须拥有至少一个构造函数。没有构造函数,就无法创建该类的任何对象。如果开发者没有为类定义构造函数,C++会提供一个默认的构造函数。这个默认构造函数是一个不带参数的构造函数,其作用仅限于创建对象,而不执行任何初始化操作。

        一旦类中定义了任何构造函数,C++将不再提供默认构造函数。如果需要无参数的构造函数,开发者必须自行定义。类似于变量的定义,当使用默认构造函数创建对象时,如果创建的是全局对象或静态对象,其成员数据将被初始化为0;而对于局部对象,其成员数据在创建时将是无意义的随机值。

        本节第一个例子中,创建的全局对象、静态对象及局部对象都是由编译器提供的缺省构造函数自动创建的,仅对成员数据分配了内存空间,未做初始化工作。一个类如果什么都没有则被称之为空类,一个空类的大小为1个字节,且编译器会为其隐式产生6个成员,假设有一个空类class Empty,则编译器会为其产生以下几个成员:

Empty();  //默认构造函数
Empty( const Empty&); //默认拷贝构造函数
~Empty(); //默认析构函数
Empty& operator=(constEmpty&); //默认赋值运算符
Empty* operator&(); //取址运算符
const Empty*operator&()const; //取址运算符const

六、构造函数初始化列表

        之前在构造函数中给变量初始值的时候,实际上都算不上初始化,在构造函数初始化列表中初始化才算的上是初始化,那么什么是构造函数初始化列表呢?

示例代码:

#include <iostream>
using namespace std;

class CLocation {
public:
    CLocation(int nNumA, int nNumB) :m_X(nNumA), m_Y(nNumB) {
        cout << "2个参数的构造函数" << endl;
        m_X = nNumA;
        m_Y = nNumB;
    }


    CLocation(int nNumA) : m_X(nNumA), m_Y(nNumA * 2) {
        cout << "1个参数的构造函数" << endl;
        m_X = nNumA;
        m_Y = nNumA * 2;
    }

    int getx() {
        return m_X;
    }
    int gety() {
        return m_Y;
    }
private:
    int m_X, m_Y;//数据成员
};

int main() {
    CLocation objA(1, 2); 
    CLocation objB(3);
}

        如以上代码所示,在构造函数声明的后面,加上一个冒号,冒号后面就是初始化列表在初始化列表中,你可以为成员变量初始化。初始化时可以使用一个表达式。表达式可以有形参,也可以没有形参。

实际上,构造函数的执行过程可以细分为两个阶段:

  1. 初始化阶段:在这一阶段,内存分配的同时会直接填充数据。

  2. 普通计算阶段:这一阶段涉及构造函数体内的程序代码,任何在此阶段的初始化操作实际上是赋值操作。我们的初始化列表位于第一阶段,而在构造函数体内的赋值则属于第二阶段,此时不能称为初始化,而应视为赋值。

区分这两个阶段的重要性在于:

  1. const类型的成员必须在初始化列表中进行初始化,因为它们在定义后不能被修改。

  2. 引用类型的成员同样必须在初始化列表中初始化,因为引用一旦定义就必须立即绑定到一个对象。

  3. 如果类的成员是另一个类的对象,且该对象所属的类没有提供默认构造函数,那么这个对象成员也必须在构造函数的初始化列表中进行初始化。

这些成员必须在定义时立即初始化,因此它们需要在初始化列表中处理。

以下示例代码是const与引用的初始化问题:

#include <iostream>
using namespace std;

class Object {
public:
    Object(int num = 0) :m_num(num), m_cNum(num), m_refNum(m_num) {
        //m_cNum=100;//常量不能在这里初始化
        //m_refNum = m_num;引用也不能再这里初始化
        cout << "Object"<< m_num << "..." <<endl;
    }


    ~Object(){
        cout << "~Object " << m_num << "..." << endl;
    }

    void DisplaycNum(){
        cout << "cNum=" << m_cNum << endl;
    }

private:
    int m_num;
    const int m_cNum;
    int& m_refNum;
};


int main(void) {
    Object obj1(10);
    Object obj2(20);
    obj1.DisplaycNum();
    obj2.DisplaycNum();
    return 0;
}

以下代码是有参构造的对象成员的初始化问题

#include <iostream>
using namespace std;

class Object {
public:
    Object(int num = 0) :m_num(num) {
        cout << "Object"<< m_num << "..." <<endl;
    }


    ~Object(){
        cout << "~Object " << m_num << "..." << endl;
    }


private:
    int m_num;
};

class Container {
public:
    Container(int objl = 0, int obj2 = 0) {
        cout << "Container" << "..." << endl;
    }

    Container() {
        cout << "~Container ..." << endl;
    }

private:
    Object m_objl;
    Object m_obj2;
};


int main(){
    Container c(10, 20);
    return 0;
}

        在Container类的构造函数中,你声明了两个Object类型的成员变量m_objlm_obj2,但是在构造函数的初始化列表中并没有对它们进行初始化。在C++中,如果一个类包含了其他类的成员变量,那么这些成员变量必须在构造函数的初始化列表中进行初始化。

代码改成如下:

#include <iostream>
using namespace std;

class Object {
public:
    Object(int num = 0) : m_num(num) {
        cout << "Object" << m_num << "..." << endl;
    }

    ~Object() {
        cout << "~Object " << m_num << "..." << endl;
    }

private:
    int m_num;
};

class Container {
public:
    Container(int obj1 = 0, int obj2 = 0) : m_obj1(obj1), m_obj2(obj2) {
        cout << "Container" << "..." << endl;
    }

private:
    Object m_obj1;
    Object m_obj2;
};

int main() {
    Container c(10, 20);
    return 0;
}

根据上述实验结果,我们得出以下结论:

  1. 建议将所有初始化操作,包括普通数据成员和对象数据成员的初始化,放置在构造函数的初始化列表中进行。

  2. 对于那些没有默认构造函数的对象成员,其初始化必须且只能在构造函数的初始化列表中完成。一旦进入构造函数体内,再进行初始化则为时已晚。

  3. 这一原则同样适用于const型成员和引用型成员,它们都必须在初始化列表中进行初始化。

  4. 如果一个有参构造函数的所有参数都提供了默认值,那么它也可以被视为具有默认构造函数的功能。

  5. 对象成员的构造顺序是由它们在类中的定义顺序决定的,与初始化列表中的顺序无关。

七、拷贝构造函数和调用规则

        在C++中,提供了用一个对象值创建并初始化另一个对象的方法,完成该功能的是拷贝构造函数(也叫复制构造函数)。
拷贝构造函数的格式如下:
<类名>::<拷贝构造函数名>(<类名>&<引用名>)
{<函数体>}
例如:
CLocation::CLocation(CLocation &obj)
{   }

注意:
其中<拷贝构造函数名>与该类名相同
如果一个类中没有定义拷贝构造函数,则系统自动生成一个缺省拷贝构造函数,其功能是将已知对象的所有数据成员的值拷贝给对应对象的数据成员

拷贝构造函数的特点:
拷贝构造函数名字与类同名,没有返回类型
拷贝构造函数只有一个形参数,该参数是该类的对象的引用

代码示例:

#include <iostream>
using namespace std;

class CLocation{
public:
    //拷贝构造
    CLocation(CLocation& obj):m_X(obj.m_X),m_Y(obj.m_Y) {}
    //普通带参构造
    CLocation(int nX, int nY):m_X(nX), m_Y(nY){ }
private:
    int m_X, m_Y;
    };

int main() {
    CLocation objA(1, 2);
    CLocation objB(objA);//此时调用的是拷贝构造函数
    return 0;
}

拷贝构造函数不仅用于通过已知对象的值创建同类型的新对象,还承担着另外两个关键角色:

A. 当对象作为函数参数传递时,系统会自动调用拷贝构造函数,以实现将对象的值复制给形式参数对象。

B. 当函数的返回值是对象时,系统会自动调用拷贝构造函数,创建一个临时对象来存储返回的对象值,然后将这个临时对象的值赋给接收函数返回值的目标对象。

拷贝构造函数调用示例:

#include <iostream>
using namespace std;

class CLocation{
public:
    //拷贝构造
    CLocation(CLocation& obj):m_X(obj.m_X),m_Y(obj.m_Y) {
        cout << "拷贝构造\n";
    }
    //普通带参构造
    CLocation(int nX, int nY):m_X(nX), m_Y(nY){ 
        cout << "普通构造\n"; 
    }
private:
    int m_X, m_Y;
};

CLocation& fun_A(CLocation obj) { return obj; }
CLocation& fun_B(CLocation& obj) { return obj; }

int main() {
    CLocation objA(3, 4);
    CLocation objB(objA);
    CLocation objC = fun_A(objB);
    CLocation& objD = fun_B(objB);
    return 0;
}

运行结果:

        程序输出结果说明程序中出现了三次调用拷贝构造函数:
1 以objA为蓝本创建objB
CLocation objB(objA);
2 实参objB对象被拷贝到形参
CLocation objC = fun_A(objB);
3函数返回时,调用拷贝构造函数,用对象obj创建一个临时对象保存obj的数据,在主函数中临时对象被释放前,将它的内容赋值到对象 objC中
return obj:
同时,可以发现,返回引用或者参数是引用,可以避免拷贝构造函数的调用。

八、深拷贝和浅拷贝

        如果不实现拷贝构造的话,系统也会帮助我们实现一个,功能就是挨个元素拷贝,这似乎和我们上面实现的功能是一致的。那么我们什么情况下需要自己实现拷贝呢?看下面的例子

#include <iostream>
#include <string.h>
using namespace std;

class String {
public:
    String(const char* str = "");
    ~String();

    void Display(); 

private:
    char* m_str;
};


String::String(const char* str) {
    int len = strlen(str) + 1; 
    m_str = new char[len];
    memset(m_str, 0, len);
    strcpy_s(m_str, len, str);
}

String::~String(){
    delete[] m_str;
}

void String::Display() {
    cout << m_str << endl;
}

int main(){
    String s1("AAA");
    s1.Display();
    //系统提供的默认拷贝构造函数实施的是浅拷贝s2.m_str = s1.m_str;
    String s2(s1);//调用拷贝构造函数
    return 0;
}

        上述程序由于默认提供的拷贝构造函数仅仅是赋值,导致S1和S2的m_str 指向同一个位置,两个对象的字符串,有一个改变另一个也会改变。
另外两个对象的析构函数释放的是同一块位置,这也必然会出错,造成程序崩溃。这种拷贝称之为浅拷贝函数。

接下来我们自己实现一个拷贝构造函数

#include <iostream>
#include <cstring>

using namespace std;

class String {
public:
    String(const char* str = "");
    String(const String& other); //自己实现拷贝构造函数
    ~String();

    void Display();

private:
    char* m_str;
};

String::String(const char* str) {
    int len = strlen(str) + 1;
    m_str = new char[len];
    strcpy_s(m_str, len, str);
}

String::String(const String& other) {
    int len = strlen(other.m_str) + 1;
    m_str = new char[len];
    strcpy_s(m_str, len, other.m_str);
}

String::~String() {
    delete[] m_str;
}

void String::Display() {
    cout << m_str << endl;
}

int main() {
    String s1("AAA");
    s1.Display();
    String s2(s1); // 调用拷贝构造函数
    s2.Display();
    return 0;
}

        在这个例子中,在 s2中又申请出了一块空间,将s1中的信息拷贝了进去。
这样,s1和s2就完全的没有关系了,把这种形式称之为深拷贝。可以看出,当类中有指针指向堆的时候,自己实现一个拷贝构造函数做成深拷贝是十分有必要的。

九、总结

        构造函数是一种特殊的成员函数,专门用于创建类的对象。它在对象创建时被调用,负责为对象分配内存空间,初始化其数据成员,并执行其他必要的资源请求操作。与之相对的是析构函数,它在对象生命周期结束时被调用,用于撤销对象并回收其占用的资源。构造函数和析构函数功能互补,通常成对出现。每个类的对象都必须通过构造函数来诞生,一个类可以定义一个或多个构造函数。编译器根据对象构造函数声明中形参的类型和数量与创建对象时提供的实参进行匹配,以确定应使用哪个构造函数,这一过程类似于普通重载函数的选择机制。当创建包含对象成员的类对象时,需要对这些对象成员进行初始化,此时会调用对象成员的构造函数。

        拷贝构造函数用于通过一个已存在的对象来创建一个新的对象,这种情况在对象作为参数传递或作为返回值返回时频繁发生。

        当类中未显式定义构造函数和析构函数时,编译器会提供默认的构造函数和析构函数。默认构造函数在创建对象时仅分配数据成员的存储空间,但不进行初始化;而默认析构函数在对象撤销时自动调用,负责回收资源。

  • 29
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

攻城狮7号

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值