Ⅲ. 析构函数
0x00 引入
通过前面构造函数的学习,我们知道了一个对象是怎么来的了,
❓ 那一个对象又是怎么没的呢?既然构造函数的本质是初始化,那清理的工作交给谁来干呢?
💡 交给专门擦屁股的 —— 析构函数!
以前我们玩数据结构的时候经常忘记调用 destroy 函数,但是现在我们有析构函数了!!!
多么振奋人心啊!话不多说让我们开始讲解!!!
0x01 析构函数的概念
析构函数与构造函数的功能相反。
构造函数是特殊的成员函数,主要任务是初始化,而不是开空间;
析构函数也一样,主要任务是清理,而不是做对象销毁的工作。
(局部对象销毁工作是由编译器完成的)
📚 概念:对象在销毁时会自动调用析构函数,完成对象的一些资源清理工作。
0x02 析构函数的特性
构造函数是特殊的成员函数,主要特征如下:
① 析构函数名是在类名前面加上字符
② 析构函数既没有参数也没有返回值(因为没有参数,所以也不会构成重载问题)
③ 一个类的析构函数有且仅有一个(如果不写系统会默认生成一个析构函数)
④ 析构函数在对象生命周期结束后,会自动调用。
(和构造函数是对应的构造函数是在对象实例化的时候自动调用)
💬 为了演示自动调用,我们来让析构函数被调用时 "吱" 一声:
#include <iostream> using namespace std; class Date { public: Date(int year = 1, int month = 0, int day = 0) { _year = year; _month = month; _day = day; } void Print() { printf("%d-%d-%d\n", _year, _month, _day); } ~Date() { // Date 类没有资源需要清理,所以Date不实现析构函都是可以的 cout << "~Date() 吱~ " << endl; // 测试一下,让他吱一声 } private: int _year; int _month; int _day; }; int main(void) { Date d1; Date d2(2022, 3, 9); return 0; }
🚩 运行结果:
额,之前举得日期类的例子没法很好地展示析构函数的 "魅力" ……
就像本段开头说情景,我们拿 Stack 来举个例子,这就很贴切了。
我们知道,栈是需要 destroy 清理开辟的内存空间的。
这里我们让析构函数来干这个活,简直美滋滋!
💬 析构函数的用法:
#include<iostream> #include<stdlib.h> using namespace std; typedef int StackDataType; class Stack { public: /* 构造函数 - StackInit */ Stack(int capacity = 4) { // 这里只需要一个capacity就够了,默认给4(利用缺省参数) _array = (StackDataType*)malloc(sizeof(StackDateType) * capacity); if (_array == NULL) { cout << "Malloc Failed!" << endl; exit(-1); } _top = 0; _capacity = capacity; } /* 析构函数 - StackDestroy */ ~Stack() { // 这里就用的上析构函数了,我们需要清理开辟的内存空间(防止内存泄漏) free(_array); _array = nullptr; _top = _capacity = 0; } private: int* _array; size_t _top; size_t _capacity; }; int main(void) { Stack s1; Stack s2(20); // s2 栈 初始capacity给的是20(可以理解为"客制化") return 0; }
🔑 解读:我们在设置栈的构造函数时,定义容量 capacity 时利用缺省参数默认给个4的容量,这样用的时候默认就是4,如果不想要4可以自己传。
如此一来,就可以保证了栈被定义出来就一定被初始化,用完后会自动销毁。以后就不会有忘记调用 destroy 而导致内存泄露的惨案了,这里的析构函数就可以充当销毁的作用。
❓ 问一个比较有意思的问题,这里是先析构 s1 还是先析构 s2?
既然都这样问了,应该是先析构 s2 了 ~
析构的顺序在局部的栈中是相反的,栈帧销毁清理资源时 s2 先清理,然后再清理 s1 。
(不信的话可以去监视一下 this 观察下成员变量)
0x03 析构函数的特性的测试
又到了测试环节,上号!
我们知道,如果没写析构函数编译器会自动生成一个。
那生成的析构函数会做什么事情呢?它会帮我们 destroy 嘛?
想屁吃?哪有这种好事。
如果我们不写默认生成的析构函数,结果和构造函数类似,
对于自定义类型的成员变量不作处理,对于自定义类型的成员变量会去调用它的析构函数。
#include<iostream> #include<stdlib.h> using namespace std; typedef int StackDataType; class Stack { public: Stack(int capacity = 4) { _array = (StackDataType*)malloc(sizeof(int*) * capacity); if (_array == NULL) { cout << "Malloc Failed!" << endl; exit(-1); } _top = 0; _capacity = capacity; } // ~Stack() { // free(_array); // _array = nullptr; // _top = _capacity = 0; // } private: int* _array; size_t _top; size_t _capacity; }; int main(void) { Stack s1; Stack s2(20); return 0; }
难道就不能帮我把这些事都干了吗?帮我都销毁掉不就好了?
不不不,举个最简单的例子,迭代器,析构的时候是不释放的,因为不需要他来管,
所以默认不对内置类型处理是正常的,万一误杀了怎么办,对吧。
有人可能又要说了,这么一来默认生成的析构函数不就没有用了吗?
有用!他对内置类型的成员类型不作处理,会在一些情况下非常的有用!
比如说: 两个栈实现一个队列(LeetCode232) ,用C++可以非常的爽。
💬 自定义类型的成员变量调用它的析构函数:
#include <iostream> using namespace std; class String { public: String(const char* str = "jack") { _str = (char*)malloc(strlen(str) + 1); strcpy(_str, str); } ~String() { cout << "~String()" << endl; free(_str); } private: char* _str; }; class Person { private: String _name; int _age; }; int main() { Person p; return 0; }
🚩 运行结果如下:
Ⅳ. 拷贝构造函数
0x00 引入
我们在创建对象的时候,能不能创建一个与某一个对象一模一样的新对象呢?
Date d1(2022, 3, 9); d1.Print(); Date d2(d1); // 照着d1的模子做一个d2 d2.Print();
当然可以,这时我们就可以用拷贝构造函数。
0x01 拷贝构造函数的概念
📚 拷贝构造函数:只有单个形参,该形参是对本类类型对象的引用(一般常用 const 修饰),在用已存在的类类型对象创建新对象时由编译器自动调用。
0x02 拷贝构造函数的特性
它也是一个特殊的成员函数,所以他符合构造函数的一些特性:
① 拷贝构造函数是构造函数的一个重载形式。函数名和类名相同,没有返回值。
② 拷贝构造函数的参数只有一个,并且必须要使用引用传参!
使用传值方式会引发无穷递归调用!
💬 拷贝构造函数的用法:
#include <iostream> class Date { public: Date(int year = 0, int month = 1, int day = 1) { _year = year; _month = month; _day = day; } /* Date d2(d1); */ Date(Date& d) { // 这里要用引用,否则就会无穷递归下去 _year = d._year; _month = d._month; _day = d._day; } void Print() { printf("%d-%d-%d\n", _year, _month, _day); } private: int _year; int _month; int _day; }; int main(void) { Date d1(2022, 3, 9); Date d2(d1); // 拷贝复制 // 看看拷贝成功没 d1.Print(); d2.Print(); return 0; }
🚩 运行结果如下:
❓ 为什么必须使用引用传参呢?
调用拷贝构造,需要先穿参数,传值传参又是一个拷贝构造。
调用拷贝构造,需要先穿参数,传值传参又是一个拷贝构造。
调用拷贝构造,需要先穿参数,传值传参又是一个拷贝构造。
……
一直在传参这里出不去了,所以这个递归是一个无穷无尽的。
💬 我们来验证一下:
error: invalid constructor; you probably meant 'Date (const Date&)'
这里不是加不加 const 的问题,而是没有用引用导致的问题。
不用引用,他就会在传参那无线套娃递归。至于为什么我们继续往下看。
💬 拷贝构造函数加 const :
如果函数内不需要改变,建议把 const 也给它加上
class Date { public: Date(int year = 0, int month = 1, int day = 1) { _year = year; _month = month; _day = day; } /* Date d2(d1); */ Date(const Date& d) { // 如果内部不需要改变,建议加上const _year = d._year; _month = d._month; _day = d._day; } void Print() { printf("%d-%d-%d\n", _year, _month, _day); } private: int _year; int _month; int _day; };
第一个原因:怕出错,万一你一不小心写反了怎么办?
/* Date d2(d1); */ Date(Date& d) { d._year = _year; d._month = _month; d._day = _day; }
这样会产生一个很诡异的问题,这一个可以被编译出来的 BUG ,结果会变为随机值。
所以,这里加一个 const 就安全多了,这些错误就会被检查出来了。
第二个原因:以后再讲,因为涉及一些临时对象的概念。
🔺 反正,不想深究的话就记住:如果函数体内不需要改变,建议把 const 加上就完事了。
0x03 关于默认生成的拷贝构造
这里比较特殊,我们单独领出来讲。
📚 默认生成拷贝构造:
① 内置类型的成员,会完成按字节序的拷贝(把每个字节依次拷贝过去)。
② 自定义类型成员,会再调用它的拷贝构造。
💬 拷贝构造我们不写生成的默认拷贝构造函数,对于内置类型和自定义类型都会拷贝处理。但是处理的细节是不一样的,这个跟构造和析构是不一样的!
#include<iostream> using namespace std; class Date { public: Date(int year = 0, int month = 1, int day = 1) { _year = year; _month = month; _day = day; } // Date(Date& d) { // _year = d._year; // _month = d._month; // _day = d._day; // } void Print() { printf("%d-%d-%d\n", _year, _month, _day); } private: int _year; int _month; int _day; }; int main(void) { Date d1(2002, 4, 8); // 拷贝复制 Date d2(d1); // 没有写拷贝构造,但是也拷贝成功了 d1.Print(); d2.Print(); return 0; }
🚩 运行结果如下:
🔑 他这和之前几个不同了,这个他还真给我解决了。
所以为什么要写拷贝构造?写他有什么意义?没有什么意义。
默认生成的一般就够用了!
当然,这并不意味着我们都不用写了,有些情况还是不可避免要写的
比如实现栈的时候,栈的结构问题,导致这里如果用默认的 拷贝构造,会翻车。
按字节把所有东西都拷过来会产生问题,如果 Stack st1 拷贝出另一个 Stack st2(st1)
会导致他们都指向那块开辟的内存空间,导致他们指向的空间被析构两次,导致程序崩溃
然而问题不止这些……
其实这里的字节序拷贝是浅拷贝,下面几章我会详细讲一下深浅拷贝,这里的深拷贝和浅拷贝先做一个大概的了解。
🔺 总结:对于常见的类,比如日期类,默认生成的拷贝构造能用。但是对于栈这样的类,默认生成的拷贝构造不能用。
Ⅴ. 总结
默认成员函数有六只,本篇只介绍了三只,剩下的我们后面讲。
类和对象部分知识很重要,所以我们来做一个简单的总结 ~
0x00 构造函数
初始化,在对象实例化时候自动调用,保证实例化对象一定被初始化。
构造函数是默认成员函数,我们不写编译器会自己生成一份,我们写了编译器就不会生成。
我们不写内置类型成员变量不处理。
对于内置类型成员变量不处理。
对于自定义类型的成员变量会调用它的默认构造函数。
// 我们需要自己实现构造函数 class Date { int _year; int _month; int _day; }; // 我们不需要自己实现构造函数,默认生成的就可以 class MyQueue { Stack _pushST; Stack _popST; };
0x01 析构函数
完成对象中自愿的清理。如果类对象需要资源清理,才需要自己实现析构函数。
析构函数在对象生命周期到了以后自动调用,如果你正确实现了析构函数,保证了类对象中的资源被清理。
什么时候生命周期到了?如果是局部变量,出了作用域。全局和静态变量,整个程序结束。
我们不写编译器会默认生成析构函数,我们实现了,编译器就不会实现了。
对于内置类型成员变量不处理。
对于自定义类型的成员变量会调用它的析构函数。
// 没有资源需要清理,不徐需要自己实现析构函数 class Date { int _year; int _month; int _day; }; // 需要自己实现析构函数,清理资源。 class Stack { int* _a; int _top; int _capacity; };
0x02 拷贝构造
使用同类型的对象去初始化实例对象。
参数必须是引用!不然会导致无穷递归。
如果我们不实现,编译器会默认生成一份默认的拷贝构造函数。
默认生成的拷贝构造:
① 内置类型完成按子继续的值拷贝。 —— 浅拷贝
② 自定义类型的成员变量,会去调用它的拷贝构造。
// 不需要自己实现,默认生成的拷贝构造,完成浅拷贝就能满足需求 class Date { int _year; int _month; int _day; }; // 需要自己实现,因为默认生成的浅拷贝不能满足需求。 // 我们需要自己实现深拷贝的拷贝构造,深拷贝我们后面会用专门的章节去讲解。 class Stack { int* _a; int _top; int _capacity; }; #include <iostream> using namespace std; class Date { public: Date(int year = 1, int month = 0, int day = 0) { _year = year; _month = month; _day = day; } void Print() { printf("%d-%d-%d\n", _year, _month, _day); } ~Date() { cout << "&Date()" << endl; } private: int _year; int _month; int _day; }; int main(void) { Date d1; d1.Print(); Date d2(2002); d2.Print(); Date d3(2022, 3); d3.Print(); Date d4(2022, 3, 9); d4.Print(); return 0; }
🚩 运行结果如下: