1.类的6个默认成员函数
如果一个类中什么成员都没有,简称为空类。可是空类中真的什么都没有吗?
其实并不是的,任何类在什么都不写时,编译器会自动生成以下6个默认成员函数。 默认成员函数:用户没有显式实现,编译器会生成的成员函数称为默认成员函数。
类的6个默认成员函数编译器都会自己生成,如果编译器生成的默认成员函数能够满足我们的需求,我们就无需再自己实现;
相反,如果编译器生成的默认成员函数不能满足我们的需求,我们就必须要自己实现了。
本篇博客正是介绍类的这6个默认成员函数都有哪些特性,讲述什么情况下只需使用默认成员函数,什么情况下需要自己实现以及要怎样实现的问题!
2. 构造函数
2.1 构造函数的概念
如下代码,我们定义一个日期类并且调用成员函数:
#include <iostream> using namespace std; class Data { public: void Init(int year, int month, int day) { _year = year; _month = month; _day = day; } void Print() { cout << _year << "-" << _month << "-" << _day << endl; } private: int _year; int _month; int _day; }; int main() { Data d1; d1.Init(2023, 5, 23); d1.Print(); Data d2; d2.Init(2022, 5, 23); d2.Print(); return 0; }
按照我们之前学过的,按部就班地先调用初始化成员函数,再调用打印成员函数,运行结果也中规中矩地跑出来了。
可是有一天,我需要很多个Data变量,写代码又太急躁,在创建某个Data变量时忘记调用Init成员函数了,如下所示:
#include <iostream> using namespace std; class Data { public: void Init(int year, int month, int day) { _year = year; _month = month; _day = day; } void Print() { cout << _year << "-" << _month << "-" << _day << endl; } private: int _year; int _month; int _day; }; int main() { Data d1; d1.Init(2023, 5, 23); d1.Print(); Data d2; d2.Print(); return 0; }
结果d2出现了随机值:
通过以上赘述:对于Data类,可以通过Init公有方法给对象设置日期,如果忘记一次初始化就会导致bug的产生,那就不得不每次创建对象时都调用该方法设置信息,可是这样是不是有些太麻烦了呢?有没有方法在对象创建时,就将对象设置进去呢?
答案当然是有的,这就引出了C++的1个默认成员函数——构造函数:
构造函数是一个特殊的成员函数,名字与类名相同,创建类类型对象时由编译器自动调用,以保证每个数据成员都有一个合适的初始值,并且在对象整个生命周期内只调用一次。
2.2 构造函数的特性
构造函数是特殊的成员函数,需要注意的是,构造函数没有用我们经常熟悉的Init来命名,虽然名称叫构造,但是构造函数的主要任 务并不是开空间创建对象,而是初始化对象。
其特征如下:
1. 函数名与类名相同;
2. 无返回值;
3. 对象实例化时编译器自动调用对应的构造函数;
4. 构造函数可以重载;
验证如下:
#include <iostream> using namespace std; class Data { public: //退出历史舞台: /*void Init(int year, int month, int day) { _year = year; _month = month; _day = day; }*/ //1. 函数名与类名相同;2. 无返回值; Data(int year, int month, int day) { _year = year; _month = month; _day = day; } //4. 构造函数可以重载; Data() { _year = 8; _month = 8; _day = 8; } void Print() { cout << _year << "-" << _month << "-" << _day << endl; } private: int _year; int _month; int _day; }; int main() { //调用含参构造函数: Data d1(2023, 5, 23); //3. 对象实例化时编译器自动调用对应的构造函数; d1.Print(); //调用无参构造函数: Data d2; //注意这里不能用诸如:Data d2();不能加(),因为会与函数声明产生歧义; d2.Print(); return 0; }
当然这里也完全可以用到缺省参数:
#include <iostream> using namespace std; class Data { public: Data(int year = 8, int month = 8, int day = 8) { _year = year; _month = month; _day = day; } void Print() { cout << _year << "-" << _month << "-" << _day << endl; } private: int _year; int _month; int _day; }; int main() { Data d1(2023, 5, 23); d1.Print(); Data d2(2023, 5); d2.Print(); Data d3; d3.Print(); return 0; }
5. 如果类中没有显式定义构造函数,则C++编译器会自动生成一个无参的默认构造函数,一旦用户显式定义编译器将不再生成。
代码验证如下:
#include <iostream> using namespace std; class Data { public: 如果显示定义,编译器将不再生成 //Data(int year, int month, int day) //{ // _year = year; // _month = month; // _day = day; //} void Print() { cout << _year << "-" << _month << "-" << _day << endl; } private: int _year ; int _month ; int _day ; }; int main() { Data d1; d1.Print(); return 0; }
将Date类中构造函数屏蔽后,代码可以通过编译,因为编译器生成了一个无参的默认构造函数:
将Date类中构造函数放开,代码编译失败,因为一旦显式定义任何构造函数,编译器将不再生成无参构造函数,放开后报错:error C2512: “Date”: 没有合适的默认构造函数可用:
这时你可能要问了:
在不显示定义构造函数的情况下,编译器会生成默认的构造函数。但是看起来默认构造函数似乎并没有什么用处呀!?
上面d1对象调用了编译器生成的默认构造函数,但是d1的对象_year/_month/_day,结果显示依旧是随机值,上面的运行结果就是铁铮铮的事实呀!这不是恰恰证明了这里编译器生成的默认构造函数并没有什么卵用吗?
这就涉及到了构造函数的第6个特性:
6.C++把类型分成内置类型(基本类型)和自定义类型。内置类型就是语言提供的数据类型,如:int/char...,自定义类型就是我们使用class/struct/union等自己定义的类型,编译器生成默认的构造函数会对自定义类型成员调用的它的默认成员函数,而内置类型则不做处理。
代码验证如下:
#include <iostream> using namespace std; class Time { public: Time() { cout << "Time()" << endl; _hour = 0; _minute = 0; _second = 0; } private: int _hour; int _minute; int _second; }; class Date { public: void Print() { cout << _year << "-" << _month << "-" << _day << endl; } private: // 基本类型(内置类型) int _year; int _month; int _day; // 自定义类型 Time _t; }; int main() { Date d; d.Print(); return 0; }
说到这里,我又有些不解,同样都是变量,为什么还要分自定义类型调用它的默认成员函数,内置类型却不做处理呢?这难道不是一件画蛇添足的事情吗?
这次不否定了,说的确实有道理,所以在C++11 中针对内置类型成员不初始化的缺陷,又打了补丁,即:内置类型成员变量在类中声明时可以给默认值。
代码验证如下:
#include <iostream> using namespace std; class Time { public: Time() { cout << "Time()" << endl; _hour = 0; _minute = 0; _second = 0; } private: int _hour; int _minute; int _second; }; class Date { public: void Print() { cout << _year << "-" << _month << "-" << _day << endl; } private: // 基本类型(内置类型) // C++11支持,声明时给缺省值 int _year = 2023; int _month = 5; int _day = 23; // 自定义类型 Time _t; }; int main() { Date d; d.Print(); return 0; }
思考如下代码能否正常运行:
#include <iostream> using namespace std; class Date { public: Date() { _year = 2023; _month = 5; _day = 23; } Date(int year = 2023, int month = 5, int day = 23) { _year = year; _month = month; _day = day; } private: int _year; int _month; int _day; }; // 以下测试函数能正常运行吗? void Test() { Date d1; }
答案是否定的:
针对以上现象,可以引出构造函数的第7个特性:
7.无参的构造函数和全缺省的构造函数都称为默认构造函数,并且默认构造函数只能有一个。 注意:无参构造函数、全缺省构造函数、我们没写编译器默认生成的构造函数,都可以认为是默认构造函数。
3. 析构函数
3.1 析构函数的概念
通过上面构造函数的学习,我们知道一个对象是怎么来的,那一个对象又是怎么没的呢?这就需要我们学习析构函数了:
析构函数:与构造函数功能相反,析构函数不是完成对对象本身的销毁,局部对象销毁工作是由编译器完成的。而对象在销毁时会自动调用析构函数,完成对象中资源的清理工作。
3.2 析构函数的特性
析构函数也是特殊的成员函数,其特征如下:
1. 析构函数名是在类名前加上字符 ~;
2. 无参数无返回值类型;
3. 一个类只能有一个析构函数。若未显式定义,系统会自动生成默认的析构函数。注意:与构造函数不同的是,析构函数不能重载;
4. 对象生命周期结束时,C++编译系统自动调用析构函数;
#include <iostream> using namespace std; class Data { public: Data(int year = 2023, int month = 5, int day = 23) { _year = year; _month = month; _day = day; } void Ptint() { cout << _year << "-" << _month << "-" << _day << endl; } //1. 析构函数名是在类名前加上字符 ~;2. 无参数无返回值类型; ~Data() { cout << "~Data" << endl; } private: int _year; int _month; int _day; }; int main() { Data d; d.Ptint(); //这里为调用~Data,4. 对象生命周期结束时,C++编译系统自动调用析构函数; return 0; }
当然,Data类并不需要析构函数,这里只是为了证明C++自动调用了析构函数。
我们将析构函数用到顺序表中,可能会对析构函数有更深刻的理解:
#include <iostream> using namespace std; typedef int DataType; class SeqList { public: SeqList() { cout << "已经调用了SeqList()构造函数;" << endl; _a = (DataType*)malloc(sizeof(DataType) * 4); if (_a == nullptr) { perror("malloc failed");//如果扩容失败,说明原因 exit(-1); } _size = 0;//当size≥capacity时就动态开辟空间 _capacity = 4;//初始化数组容量为4 } ~SeqList() { cout << "已经调用了~SeqList()析构函数;" << endl; free(_a); _a = nullptr; _size = _capacity = 0; } private: int* _a; int _size; int _capacity; }; int main() { SeqList sl; return 0; }
对于第3条特性,系统自动生成默认的析构函数,会不会完成一些事情呢?
5. 答案与构造函数相似,编译器生成的默认析构函数,对自定义类型成员调用它的析构函数,而内置类型则不做处理。
代码验证如下:
#include <iostream> using namespace std; class Time { public: ~Time() { cout << "已经调用了~Time()析构函数" << endl; } private: int _hour; int _minute; int _second; }; class Date { private: // 基本类型(内置类型) int _year = 2023; int _month = 5; int _day = 23; // 自定义类型 Time _t; }; int main() { Date d; return 0; }
运行结果为:
对以上结果和第3、第5条特性的详细解释:
程序运行结束后输出:“已经调用了~Time()析构函数”,在main中根本没有直接创建Time类的对象,为什么最后会调用Time类的析构函数?
因为:main中创建了Date对象d,而d中包含4个成员变量,其中_year, _month, _day三个是内置类型成员,销毁时不需要资源清理,最后系统直接将其内存回收即可;
而_t是Time类对象,所以在d销毁时,要将其内部包含的Time类的_t对象销毁,所以要调用Time类的析构函数;
但是:main函数中不能直接调用Time类的析构函数,实际要释放的是Date类对象,所以编译器会调用Date类的析构函数,而Date没有显式提供,则编译器会给Date类生成一个默认的析构函数,目的是在其内部调用Time类的析构函数;
即:当Date对象销毁时,要保证其内部每个自定义对象都可以正确销毁,main函数中并没有直接调用Time类析构函数,而是显式调用编译器为Date类生成的默认析构函数;
注意:创建哪个类的对象则调用该类的构造函数,销毁哪个类的对象则调用该类的析构函数
6. 如果类中没有申请资源时,析构函数可以不写,直接使用编译器生成的默认析构函数,比如 Date类;有资源申请时,一定要写,否则会造成资源泄漏,比如SeqList类。
4.拷贝构造函数
4.1 拷贝构造函数的概念
电视剧中以及现实中,双胞胎的例子不在少数,我们甚至可以说简直他们就是一个模子里刻出来的!那么,在创建对象时,可否创建一个与已存在对象一某一样的新对象呢?答案的肯定的。
拷贝构造函数:只有单个形参,该形参是对本类 类型对象的引用(一般常用const修饰),在用已存在的类 类型对象创建新对象时由编译器自动调用。
4.2 拷贝构造函数的特性
1. 拷贝构造函数是构造函数的一个重载形式。即:拷贝构造函数是一个特殊的构造函数;
2. 拷贝构造函数的参数只有一个且必须是类 类型对象的引用,使用传值方式编译器直接报错,因为会引发无穷递归调用;
代码验证如下:
不考虑特性2,我们偏偏就要直接传值调用:
#include <iostream> using namespace std; class Date { public: Date(int year = 2023, int month = 7, int day = 7) { _year = year; _month = month; _day = day; } Date( Date d) { _year = d._year; _month = d._month; _day = d._day; } void Print() { cout << _year << "-" << _month << "-" << _day << endl; } private: int _year; int _month; int _day; }; int main() { Date d1; d1.Print(); Date d2(d1); d2.Print(); return 0; }
会发现程序报错:
这是为什么呢?答案就像特征2中所说的,在此过程中引发了无穷递归调用:
当我们直接传值调用时,会发生先传值再调用拷贝构造函数的情况,即:
所以正确应该如特性2所说的那样:
#include <iostream> using namespace std; class Date { public: Date(int year = 2023, int month = 7, int day = 7) { _year = year; _month = month; _day = day; } // Date( Date d) // 错误写法:编译报错,会引发无穷递归 Date( Date& d) // 正确写法 { _year = d._year; _month = d._month; _day = d._day; } void Print() { cout << _year << "-" << _month << "-" << _day << endl; } private: int _year; int _month; int _day; }; int main() { Date d1; d1.Print(); Date d2(d1); d2.Print(); return 0; }
那么在概念中又提到:(一般常用const修饰),这是为什么呢?
这是为了防止我们在定义拷贝构造函数时写反了:
Date( Date& d) { d._year = _year; d._month = _month; d._day = _day; }
那么运行结果不但不会正确,反而会偷鸡不成蚀把米:
所以加上const,即使出现了这样的低级错误,编译器就会报错,我们也能及时发现:
Date( const Date& d) { d._year = _year; d._month = _month; d._day = _day; }
正确代码:
#include <iostream> using namespace std; class Date { public: Date(int year = 2023, int month = 7, int day = 7) { _year = year; _month = month; _day = day; } // Date( Date d) // 错误写法:编译报错,会引发无穷递归 Date( const Date& d) // 正确写法 { _year = d._year; _month = d._month; _day = d._day; } void Print() { cout << _year << "-" << _month << "-" << _day << endl; } private: int _year; int _month; int _day; }; int main() { Date d1; d1.Print(); Date d2(d1); d2.Print(); return 0; }
3.与构造函数和析构函数相似,若未显式定义,编译器会生成默认的拷贝构造函数。 默认的拷贝构造函数对象按内存存储按字节序完成拷贝,这种拷贝叫做浅拷贝,或者值拷贝。
在编译器生成的默认拷贝构造函数中,内置类型是按照字节方式直接拷贝的,而自定义类型是调用其拷贝构造函数完成拷贝的。
代码验证如下:
#include <iostream> using namespace std; class Time { public: Time() { _hour = 8; _minute = 8; _second = 8; } Time(const Time& t) { _hour = t._hour; _minute = t._minute; _second = t._second; cout << "已经调用了!Time::Time(const Time&)" << endl; } private: int _hour; int _minute; int _second; }; class Date { public: void Print() { cout << _year << "-" << _month << "-" << _day << endl; } private: // 基本类型(内置类型) int _year = 2023; int _month = 7; int _day = 7; // 自定义类型 Time _t; }; int main() { Date d1; // 用已经存在的d1拷贝构造d2,此处会调用Date类的拷贝构造函数 // 但Date类并没有显式定义拷贝构造函数,则编译器会给Date类生成一个默认的拷贝构造函数 Date d2(d1); d2.Print(); return 0; }
与前面的构造函数和析构函数相似的问题:编译器生成的默认拷贝构造函数已经可以完成字节序的值拷贝了,还需要自己显式实现吗?
当然像上面的Data类这样的类是没必要的。那么像顺序表之类的类呢?验证如下:
#include <iostream> using namespace std; typedef int DataType; class SeqList { public: SeqList() { cout << "已经调用了SeqList()构造函数;" << endl; _a = (DataType*)malloc(sizeof(DataType) * 4); if (_a == nullptr) { perror("malloc failed");//如果扩容失败,说明原因 exit(-1); } _size = 0;//当size≥capacity时就动态开辟空间 _capacity = 4;//初始化数组容量为4 } //打印 void Print() { for (int i = 0; i < _size; i++) { cout << _a[i] << endl; } } //尾插 void PushBack(const DataType& x) { _a[_size] = x; _size++; } ~SeqList() { cout << "已经调用了~SeqList()析构函数;" << endl; free(_a); _a = nullptr; _size = _capacity = 0; } private: int* _a; int _size; int _capacity; }; int main() { SeqList sl1; sl1.PushBack(1); sl1.PushBack(2); sl1.PushBack(3); sl1.PushBack(4); sl1.Print(); SeqList sl2(sl1); sl2.Print(); return 0; }
运行结果如图:
我们可以看到,程序崩溃了!这是为什么呢?
打开监视窗口看一下sl1和sl2的内存地址:
发现二者的地址相同,所以我们就知道了:
1.sl1对象调用构造函数创建,在构造函数中,申请了(_capacity)4个元素的空间,然后里面存储了4个元素:1 2 3 4;
2. sl2对象使用sl1对象拷贝构造,而SeqList类没有显示定义拷贝构造函数,则编译器会给SeqList类生成一份默认的拷贝构造函数,默认拷贝构造函数是按照值拷贝的,即将sl1中的内容原封不动地拷贝到sl2中。因此sl1与sl2指向了同一块内存空间;
3. 当程序退出时,sl2和sl1要销毁。sl2先销毁,sl2销毁时调用析构函数,已经将0x00b59580的空间释放了,但是sl1并不知道,到sl1销毁时,会将0x00b59580的空间再释放一次(正如3.2的第5条特性说的那样),一块内存空间多次释放,必然会导致bug的产生。
现在我已经知道原因了,那么正确的代码应该怎么写呢?这就需要用到深拷贝去解决(关于深拷贝后面会有详解):
//自定义拷贝构造函数,不用编译器默认生成的(深拷贝) SeqList( const SeqList& sl) { _a = (DataType*)malloc(sizeof(DataType) * 4);//我也开辟一个空间 if (_a == nullptr) { perror("malloc failed");//如果扩容失败,说明原因 exit(-1); } memcpy(_a, sl._a, sizeof(int) * sl._capacity); _size = sl._size; _capacity = sl._capacity; }
所以,我们应该要明白:
4.类中如果没有涉及资源申请时,拷贝构造函数是否写都可以;一旦涉及到资源申请 时,则拷贝构造函数是一定要写的,否则就是浅拷贝。