<center>Efforts of today and tomorrow.<center>
今天的努力,明天的实力。开启本文!
引入:
如果一个类中森么都没有,那么这个类就是空类,然而空类中真的是什么都没有吗?
其实不然,介绍一下类的6个默认成员函数。
为什么叫默认成员函数呢?正如上边所说,默认成员函数数用户没有显式显示,编译器会自动生成的成员函数。
总览如下,接下来进行逐个讲解,如果只是某一个迷惑,可以通过目录直达痛点。
构造函数
前边我们向一个类中的成员变量赋值,通常是写一个Init函数,就像这样
但每次创建对象时都要调用该方法设置信息,就显得有点麻烦,能否在创建对象时,就将信息设置进去呢?
这就是构造函数函数存在的原因。
构造函数是一个特殊的成员函数,名字与类名相同,创建类类型对象(int类型变量,类类型对象,起始觉得拗口的话,现在懂了吧)时由编译器自动调用,保证每个成员变量都有一个合适的初始值,在整个生命周期只调用一次。
构造函数的特性
构造函数是特殊的成员函数,需要注意的是,构造函数虽然名称叫构造,但是构造函数的主要任务并不是开空间创建对象,而是初始化对象。
特征如下:
- 函数名与类名相同。
- 无返回值。
- 对象实例化时编译器自动调用对应的构造函数。
- 构造函数可以重载。
默认构造函数有三种,分别是如果我们没有写,编译器默认生成的无参构造函数,自己写的无参构造函数,全缺省的构造函数(要记住)。
编译器自动生成和上图中第一个构造函数相同,可以看出,上边代码编译器自己所构造出的构造函数好像是没有什么作用的,上边还写了一个全缺省的构造函数,因为有两个构造函数,运行后报错就是包含多个默认构造函数。
要注意一旦我们自己写构造函数,那么编译器就不再生成默认的构造函数。
因为我们一旦显式定义任何构造函数,但该构造函数不符合默认构造函数的条件,编译器也不会自动生成默认构造函数,所以才会这么报错。
上边所说,默认构造函数在我们实例化对象时会自动调用,我们自己写的默认构造函数可以随意修改,例如
我们调用了吗?没有啊,但是他为什么自己运行了?这就是编译器自行调用。
但是编译器默认生成的构造函数内部空空如也,那他有什么用呢?
看下图
我们知道,C++类中不仅可以有内置类型(基本类型),当然也可以有自定义类型,内置类型就是语言自带的数据类型,比如int,char,double等,自定义类型就是我们自己使用class,struct,union等自己定义的类型。
如果我们实例化对象的类中有自定义类型,编译器默认生成的构造函数会自行调用自定义成员的默认构造函数,就比如我们用两个栈实现队列时,创造的栈结构体类型的变量在队列的构造函数中可以不用考虑,他会自动去调用栈对应的构造函数(这样就不需要我们操心我们定义的类中如果有其他类的成员,就会自己调用自己的构造函数,不需要我们这个类操心了)。
补充说明
和C语言不同的是,我们实例化内置类型不仅可以用全缺省构造函数,也可以给类中内置类型在声明中就给缺省值。
下图为C语言,明显不可以给缺省值。
C++全缺省构造函数
声明时给默认值
构造函数总结
如果我们没有给默认值,也没有写全缺省构造函数,那么内置类型就都不会处理,自定义类型会调用他自己的构造函数,至于其中的细节看了上边的描述我想你已经拿捏了。
析构函数
通过上边的例子,我们已经知道了一个类是怎么来的了。那么我们如何来销毁创造出来的对象呢?
介绍:
与构造函数功能相反,析构函数不是完成对对象本身的销毁,局部对象的销毁工作时由编译器来完成的,对象在销毁时自动调用析构函数,完成对象中资源的清理工作。
特性:
- 析构函数名是在类名前加上字符 ~。
- 无参数无返回值类型。
- 一个类只能有一个析构函数。若未显式定义,系统会自动生成默认的析构函数。注意:析构函数不能重载(没有参数,也没有重载条件)
- 对象生命周期结束时,C++编译系统系统自动调用析构函数。
上边我们说了类中局部对象的销毁工作是有编译器来完成的,那么析构函数的作用是什么呢?
要注意的是,堆上的空间是有我们自行控制的,当我们malloc或者new一段新空间时,需要分别调用销毁free(或delete)掉他们,C++利用析构函数让我们在销毁空间时更加方便(自动调用,内部细节全部封装好了,不需要我们关心)。当对象要释放时自行调用,像构造函数一样,如果一个类中有自定义类型,也会自动调用该自定义类型的析构函数。
百闻不如一见,析构函数的作用如下。
如上图所示。析构函数就是用来回收内存资源的。
我带大家来调试看一看他们调用的时机。
可以看出,在实例化一个对象时,就回去调用它的构造函数,该对象的生命周期是在TestStack中,所以在这个函数结束时,还回去调用该类的析构函数,然后释放该对象所占据的空间。
析构函数总结:
类中成员变量如果是内置类型成员,最后是由系统回收的,而该类中的自定义成员会调用他自己的析构函数。析构函数主要是用来free或delete申请的内存资源。如果类中没有申请资源,那就可以直接使用编译器默认的析构函数,如果有资源申请的话,一定要自己写析构函数,不然就会造成资源泄漏。
调用顺序
如果一个类中包含着自定义变量,在我们用该类实例化对象时,他们的构造函数和析构函数的调用顺序是怎么样的呢?
看该例
class Stack { public: Stack() { arr = new int[_capacity] ; _size = 0; cout << "Stack的构造函数" << endl; } ~Stack() { delete arr; cout << "Stack的析构函数" << endl; } int* arr; int _capacity=20; int _size; }; class Quene { public: Quene() { cout << "Quene的构造函数" << endl; } ~Quene() { cout << "Quene的析构函数" << endl; } Stack _s1; }; int main() { Quene q1; return 0; }
运行后发现,先调用自定义成员的构造函数,然后再调用自己的构造函数。
可以这样想,如果我们需要一个电脑,他的配件都还没配置好,怎么配置好一台电脑呢?所以我们要先把配件都准备齐全,然后构造电脑。同样的道理,调用析构函数时先把电脑砸了,然后再把配件砸掉,这样理解就会很容易记住。
拷贝构造函数
概念
拷贝构造函数:该函数只有一个形参,该形参类型是对本类类型对象的引用(一般用const修饰防止被误改),用已存的类类型对象创建新对象时,由编译器自动调用。
特征
- 拷贝构造函数是构造函数的一个重载形式
- 拷贝构造函数的参数只有一个且必须是类类型对象的引用,使用传值方式编译器直接报错,因为会引发无穷递归调用。
用一下认识认识
class Date { public: Date(int year = 1900, int month = 1, int day = 1) { _year = year; _month = month; _day = day; } Date(const Date& d) { _year = d._year; _month = d._month; _day = d._day; } private: int _year; int _month; int _day; }; int main() { Date d1; Date d2(d1); return 0; }
在主函数中,我们通过已经构造好的类类型对象实例化出d1对象。看上边拷贝构造函数的用法,此时this指针就指向d2,所以拷贝构造函数是将传过去的同类型对象赋值一份,所以它叫做拷贝构造函数。
现在解决第二个问题,为什么形参只能是同类类型对象的引用?
如果直接传时就会报出不能是Date类型的参数,因为会引发无穷递归。
在以值传参时,因为要产生一个新的形参传递过去,要想产生和该对象一样的形参,就要调用该对象(d1)的拷贝构造函数生成形参d,要想将形参d以值传参返回,那就要调用d的拷贝构造函数生成一个新的形参e,要想将e以值传参的形式,又要调用形参e的拷贝构造函数产生一个新的对象f,一直循环,所以会引发无穷递归。(足够清晰明了不?)
所以在写拷贝构造时一定要记得形参为实参的引用,怕实参被修改可以加一个const修饰。
如果拷贝构造函数没有显式调用,那么编译器就会默认生成一个拷贝构造函数,默认生成的拷贝构造函数拷贝出的对象是按照传参对象的字节序完成拷贝,这种拷贝叫做浅拷贝,或者值拷贝,这种拷贝方式可以完成内置类型的拷贝工作。但是对于需要开空间的成员,例如一个指针指向开一定空间的首元素地址,则只会将该指针的值赋给拷贝后的成员变量。
正如前边所写的栈,会使用new在堆上开辟空间。
这是因为在使用编译器默认提供的拷贝构造函数时进行的是浅拷贝。
还记得吗,我们实例化一个对象后,当该对象使用结束会地洞调用该对象的析构函数,当析构完s1后,再析构s2,此时s2的arr指向的内容已经在析构s1时清空了,再释放该位置时就会出现一块空间多次释放的问题,所以才会造成程序崩溃。
所以要注意:如果类中涉及资源申请,一定要显式的写拷贝函数,如果没有涉及资源调用的话,拷贝构造函数写不写都可以,编译器会给你安排。
上边的例子的拷贝构造函数,呐。
拷贝构造函数的使用场景
- 使用已有对象创建新对象
- 函数参数类型为类类型对象
- 函数返回值类型为类类型对象
使用拷贝构造传参或返回时,需要调用拷贝构造函数生成一个一模一样的形参(具有常性,用完就無),如果这个对象不大还比较好,如果该函数很大的话还好,如果该对象包含很大的结构,在拷贝构造时需要很大的时间开销,所以我们在传参时可以选择引用传参,这样就可以节省很大的时间开销。当然,如果怕在函数中被修改可以用const修饰即可。
本文到这里就结束啦,下一篇文章会讲到赋值运算符重载,普通或const修饰对象取地址操作。有用的话留一个赞再走叭,如果你的慧眼金睛发现了问题,一定要说哦,我会虚心改正的。