Cpp类和对象(中)(4)
文章目录
- 前言
- 一、类的六个默认成员函数
- 二、构造函数
- 构造函数的概念
- 构造函数的特性
- 构造函数的两种分类
- 编译器默认生成构造函数意义及相关问题
- C++11打的补丁
- 三、析构函数
- 析构函数的概念
- 析构函数的特性
- 验证是否会自动调用析构函数
- 验证析构函数对于内置与自定义类型处理
- 验证先定义后析构,后定义先析构
- 四、拷贝构造函数
- 拷贝构造函数的概念
- 拷贝构造函数的特性
- 拷贝构造函数为什么只有一个参数?
- 为什么传值会引发无穷递归调用呢?
- 什么是默认拷贝构造函数的浅拷贝,那何为深拷贝?
- 拷贝构造函数的使用场景
- 总结
前言
来了来了,事先声明本篇文章量大且深
不可垂头丧气,也不可掉以轻心
冲锋!
一、类的六个默认成员函数
class Date {}; // 空类
如上,一个类中什么成员都没有,我们简称其为空类,可对于空类,并不是真的什么都没有,编译器会自动默认生成以下六个默认成员函数:
其实,这有点像我们之前的缺省,你若不写,编译器会帮你自动生成;反之你若是写了,编译器就不生成了
二、构造函数
构造函数的概念
相信你一定写过以下代码:
Stack st1;
st1.Push(1);
也就是,创建一个栈变量,然后直接压栈一个数,这在Cpp中确实没问题,但我们当初学C语言的时候,假若真这么做,早就出错了,原因就在于st1并未被初始化
至于在Cpp中这么做就没问题,显然肯定是完成了初始化,可既然你没这么做,那肯定就是编译器做的,具体怎么做?靠得就是构造函数
生活没有那么一帆风顺,如果你这么觉得了,肯定是有人为你负重前行
构造函数是特殊的成员函数,其中函数名与类名相同,创建类类型对象时由编译器自动调用,以保证每个数据成员都有一个合适的初始值,并且在对象整个生命周期内只调用一次
这里的翻译有很大问题,其实它并不是用来构造的,叫是这么叫,但是你心里要把它当成“初始化函数”,其目的不是开辟空间创建对象,而是对象初始化
构造函数的特性
构造函数特性:
- 函数名与类名相同
- 无返回值 -> 这里所说的构造函数无返回值是真的无返回值,而不是说返回值为void
- 对象实例化时,编译器自动调用对应的构造函数 -> 当你用类创建一个对象时,编译器会自动调用该类的构造函数对新创建的变量进行初始化
- 构造函数支持函数重载 -> 这意味着你可以有多种初始化对象的方式,编译器会根据你所传递的参数去调用对应的构造函数,也就是说构造函数有好几种分类,我们下边会接着讲
- 无参的构造函数、全缺省的构造函数以及我们不写编译器自动生成的构造函数都称为默认构造函数,并且默认构造函数只能有一个 -> 请注意!不是我们不写,编译器自动生成的构造函数才叫默认构造函数,事实上,更贴切的说法是不传参构造函数,你细品一下不传参的意思
构造函数的两种分类
大体来说,构造函数一共有显式构造函数和默认构造函数,没那么玄乎,一个传参一个不传参而已,就是那么简单,我们来看以下代码:
class Date
{
public:Date(int year = 1900, int month = 1, int day = 1){_year = year;_month = month;_day = day;}void Print(){cout << _year << "-" << _month << "-" << _day;}
private:int _year;int _month;int _day;
};int main()
{Date d1; // 调用默认构造函数Date d2(2024, 9, 21); // 显式调用构造函数Date d3(); // errd1.Print();d2.Print();return 0;
}
我们发现创建d1变量,因为没传参,调用默认构造函数,也就是全缺省,打印出来1900-1-1
而d2是显式传参,调用传参构造函数,打印出来2024-9-21
而d3很容易让人产生误解,实际上这会导致程序错误,这是由于编译器很难区分对象实例化是调用无参构造函数还是函数声明,所以我们要求对象实例化调用无参构造函数,不允许添加括号,d1才是调用无参构造函数的正确方法
同时我们还看到,在这里有参和无参构造函数被放到一个函数里面,即全缺省函数,实际上我们鼓励这种做法,这太方便了,无参、缺省、有参都可以被这个函数所囊括
编译器默认生成构造函数意义及相关问题
你可能会问,假如我们不写构造函数,编译器不是自己会生成一个吗,这是不是意味着我们可以对其放任不管?
先放结论,不能,基本上绝大多数的构造函数都需要我们自己显式呈现
我把上面代码的构造函数注释,创建d4,Print()打印如下:
是随机值! 这就不得不提到编译器默认生成的构造函数对于内置/自定义类型处理方式了:
C/C++把类型分成 内置类型(基本类型) 和 自定义类型 。内置类型就是语言提供的数据类型(int/char/double ),自定义类型就是自己通过关键字定义的类型(struct /class/union),你先有这个概念
对于内置与自定义类型处理:
- 对内置类型不做处理
- 对自定义类型的成员,会去调用他们的默认构造(无参构造函数、全缺省构造函数、我们没有写编译器默认生成的构成函数)
不如我们来个具体例子吧,可以让你对这个类型处理方式有个深刻认识:
class Time
{
public:Time(){cout << "Time()" << endl;_hour = 0;_minute = 0;_second = 0;}
private:int _hour;int _minute;int _second;
};class Date
{
private:// 基本类型(内置类型)int _year;int _month;int _day;// 自定义类型Time _t;
};int main()
{Date d;return 0;
}
打开监视,可以观察到 d 的三个内置类型都是随机值不做处理,而一个自定义类型 _t ,我们调用了它的构造函数Time(),从输出我们打印了 Time() 以及 _t 的三个成员变量都被初始化就可以看出来了
这也说明了为什么绝大多数情况下我们还是要自己写构造函数,因为就算是自定义类型,套娃最后还是内置类型,而C++编译器自己默认生成的构造函数对内置类型是没有规定要不要处理的
但是存在即合理,编译器的默认构造函数也有应用场景,就是没有内置类型,成员变量无需赋值的时候,比如:
class MyQueue
{// ...
private:stack _phst;stack _popst;
}
C++11打的补丁
前面说了,编译器自己生成的默认构造函数对成员变量不做处理,基于这个特性,C++11打了一个补丁,内置类型的成员变量在声明时可以给默认值,举例如下:
三、析构函数
同样的,我们之前用C语言写栈的时候,经常忘记加上Destroy()函数来释放申请的资源,这很不好,会造成内存泄露
报错中止就像人得了急性病一样,这能治,就怕慢性病拖到无法挽回了才麻烦,这就是内存泄露的恐怖之处
就像图中所示,st1和d1都开在main函数的栈帧上,可st1申请了动态资源,这需要释放
析构函数的概念
析构函数与构造函数功能相反,该函数任务并不是完成对象本身销毁(局部对象的销毁时由编译器完成),而是对象在销毁时自动调用析构函数,完成对象中资源的清理工作,作用于对象出了作用域的时候
析构函数的特性
- 析构函数的函数名是在类名前加上字符 ’ ~ ’ -> ~Date() {}
- 析构函数无参数,无返回值 -> 也是真的无返回值,而不是返回值为void
- 对象生命周期结束时,C++编译器会自动调用析构函数 -> 大大降低了C语言中栈空间忘记释放问题的发生,因为当栈对象生命周期结束时,C++编译器会自动调用析构函数对其栈空间进行释放
- 一个类有且只有一个析构函数 -> 若未显示定义系统会自动生成默认的析构函数,机制是编译器自动生成的析构函数对内置类型不做处理(交给操作系统)。对于自定义类型,编译器会再去调用它们自己的默认析构函数
- 先构造的后析构,后构造的先析构 -> 因为对象是定义在函数中的,函数调用会建立栈帧,栈帧中的对象构造和析构也要符合先进后出的原则
我们可以来一一验证:
验证是否会自动调用析构函数
验证析构函数对于内置与自定义类型处理
输出如下:
验证先定义后析构,后定义先析构
- 局部对象(后定义先析构)
- 局部的静态
- 全局对象(后定义先析构)
可以得出,以上就是销毁顺序
若有向系统申请动态资源,那么就要考虑自己写显式析构函数了
四、拷贝构造函数
拷贝构造函数也是构造函数的一种,这进一步说明了函数是可以重载的
拷贝构造函数的概念
拷贝构造函数:只有单个形参,该形参是对本类类型对象的引用(一般常用从const修饰),在用已存在的类类型对象创建新对象时由编译器自动调用。
拷贝构造函数的特性
- 拷贝构造函数本身属于构造函数一种重载,同类型对象进行初始化
- 拷贝构造函数的参数只有一个且必须是类类型对象的引用,使用传值方式编译器直接报错,因为会引发无穷递归调用(编译器可能会强制检查)
- 若未显示定义拷贝构造函数,系统将生成默认的拷贝构造函数 -> 编译器自动生成的拷贝构造函数对内置类型会完成浅拷贝(值拷贝),对于自定义类型,编译器会再去调用它们自己的默认拷贝构造函数
拷贝构造函数为什么只有一个参数?
拷贝构造函数需要拷贝对象参数即可,由于存在this指针,将调用对象地址传进来(编译器会自动处理)
为什么传值会引发无穷递归调用呢?
请看上图,首先我们想要将d1拷贝给d2,可是传值的话,因为不是内置类型,所以d1赋值给d形参其实也要拷贝,即要d(d1),可是,要把d1给d形参,又要传值,又要把d1传给又一个d形参,按图形语言就像这样:
传值过程需要开辟空间去拷贝实参数据,这里就需要调用拷贝函数。
传值需要调用拷贝构造,调用拷贝构造需要传值
哈哈,这套娃!
什么是默认拷贝构造函数的浅拷贝,那何为深拷贝?
其实,若未显示定义,编译器会生成默认的拷贝构造函数。默认的拷贝构造函数对象按照内存存储按字节序完成拷贝,这种拷贝叫做浅拷贝(值拷贝),有点像memset
我们不写拷贝构造函数,采用编译器默认生成的,看看效果:
Date d2 = d1; 与 Date d3(d2); 是等价的
可有些时候,按字节复制也会出问题,比如说要申请动态内存的Stack类,假如创建两个栈st1、st2,那我们就会面临以下问题:
程序报错的原因是重复析构,可是就算这个不报错,从逻辑上也有很大问题
比如说st1压栈一个数,这与st2无关,两者交互了,这是我们所不愿意遇见的,终其根本就是两者动态数组指向了同一内存空间
这才是我们愿意见到的:
这很简单,无非就是自己写一下_array的动态开辟,其他直接浅拷贝过去:
Stack(const Stack& st)
{_array = (DataType*)malloc(st._capacity * sizeof(DataType));if (_array == nullptr){perror("malloc申请空间失败");return;}memcpy(_array, st._array, st._size * sizeof(DataType));//要记得把原来的数据拷贝过去_size = st._size;_capacity = st._capacity;
}
所以说,关于是否显式写拷贝构造函数,答案是类中没有涉及资源申请,拷贝构造是否写都是可以;类中一旦涉及资源申请,拷贝构造一定要写,否则就是浅拷贝
拷贝构造函数的使用场景
- 使用已存在对象创建新对象
- 函数参数类型为类类型对象
- 函数返回值类型为类类型对象
来个实际代码感受一下传值返回吧!
class Date
{
public:Date(int year, int minute, int day){cout << "Date(int,int,int):" << this << endl;}Date(const Date& d){cout << "Date(const Date& d):" << this << endl;}~Date(){cout << "~Date():" << this << endl;
}
private:int _year;int _month;int _day;
};Date Test(Date d)
{Date temp(d);return temp;
}int main()
{Date d1(2022,1,13);Test(d1);return 0;
}
为了提高程序效率,一般对象传参时,尽量使用引用类型,返回时根据实际场景,能用引用尽量使用引用
总结
本节内容好多,其实还没完,再开一篇吧!