默认成员函数
首先要理解什么是默认成员函数:类在什么都不写的时,编译器会生成六个默认成员函数
用户没有显式实现,但编译器会生成的成员函数就是默认成员函数
下面我们对这些函数一一进行介绍
构造函数
在C语言中,无论是实现栈队列链表等各种数据结构,都避免不了要写Init初始化函数,这个函数的功能是给变量一个初始化的值,在C++中,认为C语言的这些问题有些许麻烦,于是进行了一定的优化,构造函数就是要在对象创建的时候,就把信息设置进去
构造函数是一个特殊的成员函数,名字和类名相同,创建类型对象的时候就由编译器自己自动调用,用来保证类中的每一个数据成员都有一个自己的初始值,在整个对象的生命周期中只调用一次
构造函数的特性
构造函数是特殊的成员函数,主要功能是用来初始化对象
它有下面的一些特点
- 函数名和类名相同
- 没有返回值
- 对象实例化的时候会自动调用对应的构造函数
- 构造函数可以重载
- 如果类中没有显式的构造函数,那么会自动生成一个无参的默认构造函数,如果用户定义了构造函数就不再生成
- 默认构造函数是在不写的时候会生成,且内置类型的成员不会进行处理(在C++11中的声明支持给缺省值),自定义类型的成员才会处理,回去调用这个成员的默认构造函数
- 无参的构造函数和全缺省的构造函数都是默认构造函数,这两个构造函数只能存在一个
既然它有这么多的特点,那么就一一举例论证它的特点
1. 函数名和类名相同
2. 没有返回值
3. 对象实例化的时候会自动调用对应的构造函数
4. 构造函数可以重载
在实际写代码时,尽量要写全缺省的构造函数,这样不管如何给参数都有一定的初始化值
class Date { public: Date(int year=1, int month=1, int day=1) { cout << "Date(int year, int month, int day)" << endl; _year = year; _month = month; _day = day; } private: int _year; int _month; int _day; }; int main() { Date d1; Date d2(2004); Date d3(2004, 6); Date d4(2004,6,16); return 0; }
5. 如果类中没有显式定义构造函数,则编译器会自动生成一个无参的默认构造函数,一旦用户有显式定义就不再生成
这里也是我们接触到的第一个默认构造函数,我们用下面的代码证明它的存在性
下面继续验证它的后半段存在性
从中可以看出,当我们没有定义构造函数时,定义一个无参的对象编译器会执行默认构造函数给数据成员初始值,而现在我们定义了构造函数,那么默认构造函数不复存在,此时编译器也就不能执行默认构造函数给无参的d1初始化值,此时会报错—Date不存在默认构造函数,没有合适的构造函数可以使用
6. 默认构造函数是在不写的时候会生成,内置类型的成员不会进行处理(在C++11中的声明支持给缺省值),自定义类型的成员才会处理,回去调用这个成员的默认构造函数
简单来说,一般情况下都需要我们自己写构造函数,决定初始化方式,成员变量都是自定义类型,可以考虑不写构造函数,因为会调用自定义类型的构造函数
7. 无参的构造函数和全缺省的构造函数都是默认构造函数,这两个构造函数只能存在一个
后续还有初始化列表,我们后续进行讲解
析构函数
析构函数就相对简单一点
它存在的意义是什么呢?
我们实现栈顺序表链表等数据结构时,都要在堆上malloc开辟一段空间,但是是不是经常会忘记free呢?这样会造成内存泄漏的危险情况的发生
那么C++在开发的时候就想到了这个问题,因此析构函数就这样产生了,它存在的意义就是完成对象中资源的清理工作,它会在对象被销毁前自动调用
析构函数的特点如下
- 析构函数名是在类名前加上字符 ~。
- 无参数无返回值类型。
- 一个类只能有一个析构函数。若未显式定义,系统会自动生成默认的析构函数。析构函数不能重载
- 对象生命周期结束时,C++编译自动调用析构函数。
- 调用顺序满足栈的顺序
拷贝构造函数
在实际代码运用中,我们经常会拷贝一个对象用来做其他事情,在C语言中,这个过程十分简单,把值直接全部从一个内容中拷贝到另外一个地方即可,但在C++中却不那么容易
拷贝构造函数的引入
首先要清楚堆创建后,除非通过free否则是不会被还原的,因此如果有这样的C语言代码:
void push(Stack ps) { ps._a[0] = 10; ps._top++; } void test2() { Stack s={0,0,0}; StackInit(&s); push(s); printf("%d", s._a[0]); }
这里的StackInit函数只是单纯的初始化,给栈开辟空间,而最后运行结果是10,原因就在于在push函数中,虽然是值传递,但是ps结构体中的成员_a依旧拥有改变堆内存的能力,具体可以用下面的图来表示
那么现在换到C++,引入类的概念后,整个就变得比C语言要复杂一点,原因如下:
首先,定义一个栈的类,并且完成一系列栈的操作
typedef int STDataType; class Stack { public: Stack() { capacity = 4; a = (STDataType*)malloc(sizeof(STDataType) * capacity); if (a == nullptr) { perror("malloc fail"); exit(-1); } top = 0; } ~Stack() { top = capacity = 0; free(a); a = nullptr; } void Push(STDataType x) { if (capacity == top) { capacity *= 2; STDataType* tmp = nullptr; tmp = (STDataType*)realloc(a,sizeof(STDataType) * capacity); if (tmp == nullptr) { perror("realloc fail"); exit(-1); } a = tmp; } a[top] = x; top++; } void Pop() { top--; } STDataType Top() { return a[top]; } private: STDataType* a; int top; int capacity; };
和C语言的实现相同,假如我们直接进行传值拷贝,具体做法如下:
void func1(Stack s) { s.Push(1); } int main() { Stack s1; func1(s1); return 0; }
再画出和上面相仿的图
看似和C语言基本相同,但实际相差很大,C++会执行构造函数和析构函数,那么在进入func1的栈帧后,销毁栈帧的时候就会执行析构函数,_a所指向的空间就被销毁掉了,那么回到main函数的栈帧后,结束程序依旧要进行析构函数,此时_a已经被销毁过一次了,程序就会崩溃,无法正常运行
这其实也就说明,C++中想要直接进行对象拷贝似乎不是一件容易的事,两个对象指向同一片空间就必然会出问题,C++语法就定义了拷贝函数来解决这个问题
拷贝构造函数的特征
拷贝构造函数也是特殊的成员函数,具体表现在:
- 拷贝函数是构造函数的一个重载
- 拷贝函数的参数只有一个并且必须是类类型对象的引用,使用传值方式编译器会报错,因为涉及到了无穷递归调用
- 若未显式定义,编译器会生成默认的拷贝构造函数,默认拷贝构造函数会按对象按内存中的存储字节序完成拷贝,也叫做浅拷贝或值拷贝
- 深拷贝就涉及到上面栈在堆上空间的问题
- 拷贝构造函数的典型应用场景
使用已存在的对象创建新对象
函数参数类型为类类型对象
函数返回值类型为类类型对象
下面根据拷贝构造函数的特征进行一一分析
1. 拷贝构造函数是构造函数的重载
这个很好解释,拷贝构造函数函数名和构造函数相同,只是函数参数不同
2. 拷贝函数的参数只有一个并且必须是类类型对象的引用,使用传值方式编译器会报错,因为涉及到了无穷递归调用
假设我们这里是这样实现拷贝构造函数:
//函数定义 Date(const Date d1) { _day = d1._day; _month = d1._month; _year = d1._year; } //函数调用 Date d1(d2);
那么标准写法是如何写的呢
//函数定义 Date(const Date& d1) { _day = d1._day; _month = d1._month; _year = d1._year; } //函数调用 void func2(Date d2) { d2.Print(); } int main() { Date d1(2002, 10, 12); d1.Print(); func2(d1); return 0; }
3. 若未显式定义,编译器会生成默认的拷贝构造函数,默认拷贝构造函数会按对象按内存中的存储字节序完成拷贝,也叫做浅拷贝或值拷贝
4. 深拷贝就涉及到上面栈在堆上空间的问题
这里需要注意的是:
不写拷贝构造函数时,编译默认生成的拷贝构造,和之前的构造函数特性是不一样的
- 内置类型是值拷贝
- 自定义的类型是调用它的拷贝
简单来说,像Date类型的就不需要我们进行拷贝构造,但是Stack类型的就需要进行深拷贝
下面是深拷贝的拷贝构造函数
Stack(const Stack& s1) { top = s1.top; capacity = s1.capacity; STDataType* tmp = (STDataType*)malloc(sizeof(STDataType) * capacity); if (tmp == nullptr) { perror("malloc fail"); exit(-1); } a = tmp; }
所谓深拷贝,就是重新在堆上开辟一个空间供构造出的栈使用,这样就避免了函数栈帧中的栈在结束时free掉了堆上的空间使得main函数崩溃的情况出现
注意:类中如果没有涉及资源申请时,拷贝构造函数是否写都可以;一旦涉及到资源申请时,则拷贝构造函数是一定要写的,否则就是浅拷贝。
运算符重载
C++在C的基础上的提升在运算符重载上也可以体现出
C++为了增强代码的可读性引入了运算符重载,运算符重载是具有特殊函数名的函数,也具有其返回值类型,函数名字以及参数列表,其返回值类型与参数列表与普通的函数类似。
函数名字为:关键字operator后面接需要重载的运算符符号。
函数原型:返回值类型 operator操作符(参数列表)
假设我们现在要比较日期谁大,那么这个场景就可以应用运算符重载
// 类体内定义运算符重载 bool operator <(const Date& d1) { if (_year > d1._year) { return false; } else if (_year == d1._year && _month > d1._month) { return false; } else if (_year == d1._year && _month == d1._month && _day > d1._day) { return false; } else { return true; } } // 调用main函数 int main() { int i = 10; int j = 20; int tmp1 = i < j; Date d1(2000, 10, 20); Date d2(2001, 8, 10); int tmp2 = d1 < d2; cout << tmp1 << endl; cout << tmp2 << endl; }
我们转到汇编观察
从中也不难发现,运算符重载后调用小于实际上是调用了运算符重载函数
这样写代码的可读性大大提高
赋值运算符重载
关于拷贝构造和赋值运算符重载,你需要知道的…
1. 就运算符重载本身而言,这个函数本身是成员函数
就对上面的代码来说,假设这里执行下面的命令
d1<d2
实际上在施行的时候会转换成这样:
d1.operator<(d2)
再返回对应的值或其他形式
2. 赋值运算符重载和拷贝构造的区别
前面我们讲了拷贝构造要带引用,不带引用会递归
Date d1(const Date d2); //错误写法 Date d1(const Date& d2); //正确写法
那么反观运算符重载,表面上看也是这样:
bool operator <(const Date d1) bool operator <(const Date& d1)
那上面的写法可以吗,其实是可以的,这里就需要对拷贝构造死循环有更深刻的理解
对于拷贝构造来说,它实质上是需要新建对象的,也就是说这里进行拷贝构造函数的时候要先传参再执行函数新建一个对象,而在传参的过程中就会陷入这是否也是拷贝构造的循环中,导致最后陷入死循环导致构造失败
而对于运算符重载来说,这里仅仅只是执行一个类内的成员函数,两个对象都已经创建好了,而这里传参的Date只是把参数传过来而已,如果使用的不是引用,则会创建一个形参,在内存中会有更多的消耗,经此而已,而如果使用引用就避开了创建形参这样的一个过程,相当于是减小了内存的消耗,因此这里并不一定必须传引用,传引用只是提升效率的一种方式
const的含义
将const修饰的“成员函数”称之为const成员函数,const修饰类成员函数,实际修饰该成员函数隐含的this指针,表明在该成员函数中不能对类的任何成员进行修改,后续有具体实际情况再进行分析
取地址及const取地址操作符重载
这是最后一个默认成员函数,但是使用场景极少
这两个运算符一般不需要重载,使用编译器生成的默认取地址的重载即可,只有特殊情况,才需要重载