July.cc Blogs

本篇文章

手机用户建议
PC模式 或 横屏
阅读


C++ 2022 年 6 月 20 日

[C++] 类和对象(2): 默认成员函数介绍分析、构造函数、析构函数、拷贝构造...

任何一个类,即使一个成员都不写,其实也会自动生成6个默认成员函数

一、类的默认成员函数

任何一个类,即使一个成员都不写,其实也会自动生成6个默认成员函数:
  1. 构造函数
  2. 析构函数
  3. 拷贝构造函数
  4. 赋值重载函数
  5. 普通对象取地址重载函数
  6. const修饰的对象取地址重载函数
这 6 个默认成员函数,是编译器自动生成的,空类也拥有这 6 个成员函数

二、构造函数

什么是构造函数?
以一个简单的日期类为例:
void SetDate:是给 对象 设置日期内容的成员函数。
但是如果每一个对象的初始化操作 都需要手动设置,会太过于繁琐。然而,构造函数 就可以很好的解决这个问题。
构造函数 是一个特殊的成员函数,函数名与类名相同,没有返回值,在创建对象时编译器会自动调用构造函数,来对对象进行 ”初始化” 操作

2.1 构造函数的特性

构造函数 是特殊的成员函数,它的作用并不是构造、创建一个对象,而是初始化对象
它的特性都非常的重要:
  1. 函数名与类名相同

  2. 没有返回值

    以 日期类 为例,class Date 的构造函数名,就为 Date()

  3. 对象实例化时,由编译器自动调用

    给构造函数添加内容:

    创建对象,并查看对象:

    对象d1已经按照构造函数初始化

    在定义一个对象时,构造函数自动执行,对象d1 内容被初始化

    如果构造函数无内容(无显式构造函数),那么:

    对象d1 将是随机值

    虽然构造函数被调用了,但是并没有处理数据(原因查看第5、6、7条特性)

  4. 构造函数可以重载

    构造函数可以重载就意味着,构造函数其实可以传参使用

    同样以日期类为例:

    对于重载的构造函数,传参使用是这样使用的:

    // 定义对象 不传参
    Date d1;
    d1.Display();
    
    // 定义对象 传参
    Date d2(2022, 06, 18);
    d2.Display();

    既然可以传参使用,那么就涉及另一个运用:缺省参数

    对于构造函数,无论是函数重载、全缺省参数还是半缺省参数,都是可以运用的

  5. C++编译器会自动生成一个无参的默认构造函数

    一个类中,如果没有显式定义构造函数,则C++编译器会自动生成一个无参的默认构造函数;一旦用户显式定义,编译器将不再生成。

    意思就是,如果构造函数被编写出来了,编译器将不自动生成无参默认构造函数

  6. 默认构造函数

    无参的构造函数 和 全缺省的构造函数 都称为默认构造函数,默认构造函数只能有一个

    注意:无参构造函数、全缺省构造函数、编译器默认生成的构造函数,都可以认为是默认构造函数(对象实例化时不传参自动调用的,就被称为默认构造函数)

    一个类中,默认构造函数只能存在一个,是什么意思呢?

    显式定义构造函数时,一般有三种方式:无参数定义,全缺省参数定义,半缺省参数定义、有参数定义

    而无参构造函数 和 全缺省构造函数 是默认构造函数,这两种写法是不能同时存在的

  7. 构造函数的数据处理特性

    C++ 规定:编译器生成默认的构造函数, 对 内置类型数据(int、char、double……等) 不做处理;对自定义类型数据(class、struct、union等自定义的),调用 其类的默认构造函数 进行处理

    以下面的 日期类包含时间类 为例:

    使用以上日期类定义对象,并且输出日期类 对象内容:

    内置类型成员数据没有处理,自定义类型成员数据 调用其类的默认构造函数处理。但是,如果 自定义类型成员没有默认构造函数,则会发生报错:

    结论就是,编译器自动生成的默认构造函数, 对 内置类型数据(int、char、double……等) 不做处理;对自定义类型数据(class、struct、union等自定义的),调用 其类的默认构造函数 进行处理

2.2 构造函数的使用

构造函数是在对象实例化时,编译器自动调用的,一个合适的构造函数可以节省许多资源
所以,构造函数一般都写成 全缺省构造函数 的形式。
因为 全缺省构造函数,可以传参、也可以不传参、同时还是默认构造函数

没有显式构造函数时,编译器自动生成的无参默认构造函数 并不是没有用

例如:利用 栈与队列 互相实现时,编译器自动生成的无参默认构造函数就很有用

三、析构函数

析构函数 的作用恰巧与 构造函数 相反。析构函数是在对象销毁时,清理数据用的。
析构函数不是完成对象的销毁
对象在销毁时自动调用析构函数,对类的一些资源进行清理
以下面 顺序表类为例:
~SeqList 即为此类的析构函数。析构函数到底有什么作用呢?什么是资源清理?
用此类,定义对象并调试一段代码:
int main()
{
	SeqList slt1;

	return 0;
}
从上图可以清晰的看到,析构函数 在程序还未结束,但是对象的生命周期快要结束时,对 对象的数据进行了清理,并且没有销毁对象
所以,析构函数的作用就是 清理对象数据,并不涉及对象的销毁

3.1 析构函数的特性

  1. 函数名为:~类名

  2. 无参数且无返回值

    以顺序表类为例,其析构函数需写为:

  3. 一个类,有且只有一个析构函数

    不同于构造函数,由于析构函数规定无参,所以一个类只能存在一个析构函数

  4. 无显式定义析构函数时,编译器自动生成析构函数

  5. 对象生命周期结束时,编译器自动调用析构函数

    以下动图是创建对象、调用构造函数、调用析构函数、对象生命周期结束的过程

    class
    class

    对象slt1 声明周期即将结束时,指令光标继续移动就会自动调用析构函数,清理对象数据、资源

  6. C++编译器会自动生成一个析构函数

    与 构造函数相似,析构函数没有显式定义时,编译器会自动生成一个析构函数

    并且,编译器**自动生成的析构函数**,处理对象数据时,同样对内置类型不做处理,对自定义类型则调用此自定义类型的析构函数进行处理

    (过程与编译器自动生成的默认构造函数相似)


3.2 不同对象 调用析构函数的顺序

关于 析构函数 还有一点非常的重要:一个程序中,不同的对象 调用析构函数的顺序是什么?
调试一段代码:
SeqList slt1;			// 全局对象

int main()
{
	SeqList slt2;			// 局部顺序表对象
	Date d1;				// 局部日期对象

	static Date d2;		// static修饰的对象

	return 0;
}
一张动图就可以分析出来(注意右方监视对象的变化)
构造与析构过程
构造与析构过程
可以看到,main 函数内部的局部对象(slt2 和 d1)
对象slt2 先被实例化,并调用构造函数;对象d1 后被实例化,并调用构造函数 但是在 两对象生命周期即将结束时 对象d1 先调用析构函数;对象d1 后调用析构函数
全局对象 和 static修饰的对象(slt1 和 d2)
对象slt1 先被实例化,并调用构造函数;对象d2 后被实例化,并调用构造函数 程序 从main函数出来后,光标继续移动时 并没有观察到右边 static修饰的对象d2 的变化,只观察到了全局对象 slt1的变化;

只观察到了全局对象 slt1变化的原因应该是: 因为main函数已经结束了,已经无法查看main函数内的对象; 而 对象d2 虽然用static修饰了,但是 它是在main函数内定义的 所以,VS 右方监视窗口无法观察到变化

但是程序退出main函数时,指令光标 先进入了 Date类(对象d2所属类)的析构函数当中,然后再是 全局对象slt1 调用了析构函数 其实,先进入的 Date类 析构函数这个过程,就是 对象d2 调用其析构函数的过程 也就是说, static修饰的对象d2 先调用析构函数, 全局对象slt1 后调用析构函数
这两个例子其实已经可以说明
相同生命周期,先调用构造函数的对象,后调用析构函数。这个过程 优点类似于函数栈帧的开辟与销毁
所以,其实 对象调用析构函数的顺序,其实是对象调用构造函数顺序的倒序

四、拷贝构造函数

上面介绍了,关于类 对象的初始化和清理的两个函数:构造函数析构函数
而接下来就是关于 对象拷贝的函数:拷贝构造函数
听名字就可以知道,拷贝构造函数 的作用就是拷贝,将已有的对象的内容拷贝至另一个对象,使两对象内容相同
以日期类为例,展示一下功能
调用拷贝构造,使 对象d1 拷贝至 对象d2

4.1 拷贝构造的特性

  1. 拷贝构造函数是构造函数的重载

  2. 拷贝构造函数有且只有一个参数,且参数类型只能为 类的引用

    为什么 参数类型只能是类的引用呢?

    是因为,如果是传值传参,将会引发无限递归导致程序崩溃

    为什么会无限递归?

    因为,函数的传参其实是原数据的临时拷贝,所以类的传值传参需要调用拷贝构造函数,来对 对象进行拷贝

    如果 拷贝构造函数使用了传值传参,那么就会造成: 调用拷贝构造需要传值传参 ---> 传值传参需要调用拷贝构造 ---> 调用拷贝构造需要传值传参 ---> 传值传参需要调用拷贝构造……

    就会发生无限递归

    为什么不用指针传参?

    使用了指针传参,就不是拷贝构造函数了,拷贝构造函数的功能是:对象的内容拷贝到另一个对象;而不是指针指向的内容拷贝到另一个对象

  3. 无显式定义拷贝构造函数时,编译器自动生成拷贝构造函数

    默认拷贝构造函数,按内存存储、按字节序实现拷贝。即,依照内存存储中,一字节一字节的直接拷贝。 这种拷贝方式被称为:浅拷贝值拷贝

    浅拷贝是可以在在一定程度上完成一些拷贝构造的

    但是 浅拷贝有非常大的弊端

  4. 浅拷贝的弊端

    浅拷贝 可以很好地完成一些类成员简单的拷贝构造. 但是对于 成员稍微复杂一点的类 使用浅拷贝就会发生一些问题

    比如,一个简单的 顺序表类

    用这样的 对象实例化时,需要对成员进行malloc申请内存的,使用浅拷贝会引发很严重的问题

    两个对象实例化完成,程序并没有出现问题,但是如果光标继续移动,即将调用 析构函数

    指针浅拷贝
    指针浅拷贝

    对象slt1 调用析构函数时,程序崩溃了. 为什么会崩溃呢?

    原因很简单:当浅拷贝完成时,仔细看会发现 两个对象中的 _data指针成员 指向了同一个地址,同一块空间

    而 对象调用 析构函数 时,是需要 freemalloc出来的空间的,而两个指针指向同一块空间,就意味着要对同一块空间free 两次. 这显然是无法实现的,所以程序崩溃了

    有关这类的问题,浅拷贝 都不能完美的解决,甚至不能解决。所以以后还有 深拷贝

版权声明:自由转载-非商用-非衍生-保持署名(创意共享3.0许可证)

作者: 哈米d1ch 发表日期:2022 年 6 月 20 日