一、类的六个默认成员函数
如果一个类中什么成员都没有,叫做空类。空类真的什么都没有吗?并不是的,任何的类在什么都不写时,编译器都会生成6个默认的成员函数,默认成员函数:就是用户没有写,编译器会自动生成的成员函数称为默认成员函数。
二、构造函数
2.1 什么是构造函数?
构造函数是一个特殊的成员函数,名字与类名相同,创建类类型对象时由编译器自动调用,以保证每个数据成员都有一个合适的初始值,并且在对象整个生命周期内只调用一次。
2.2 构造函数的特性
构造函数是特殊的成员函数,需要注意的是,构造函数虽然名称叫构造,但是构造函数的主要任务并不是开空间创建对象,而是初始化对象。
有以下特征:
- 函数名与类名相同。
- 无返回值。
- 对象实例化时由编译器自动调用。
- 构造函数支持重载。
- 如果类中没有显式定义构造函数,则C++编译器会自动生成一个无参的默认构造函数,一旦用户显式定义了编译器将不会自动生成。
class Date { public: //1、无参数构造函数 Date() {} //2、带参数构造函数 Date(int year, int month, int day) { _year = year; _month = month; _day = day; } private: int _year; int _month; int _day; }; int main() { //调用无参构造函数 Date d1; //注意:调用无参构造函数不能带括号 //warning C4930: “Date d2(void)”: 未调用原型 //函数(是否是有意用变量定义的?) //编译器会误认为是函数声明,存在二义性 Date d2();//这样写是错误的 //调用带参构造函数 Date d3(2023, 4, 30); return 0; }
我们看一下下面这张图:
- 显然,这里的年月日都是随机值,构造函数的作用就是初始化,我们不写构造函数,编译器会自动生成一个默认的构造函数,但是编译器自动生成的这个构造函数似乎没有对对象的成员变量进行初始化啊,都是随机值,那么这个编译器自动生成的构造函数到底做了什么工作呢?
在C++中,把类型分成了两种,一种是内置类型,一种是自定义类型。内置类型就是语言提供的类型,例如int,double,还有任意类型变量的指针。自定义类型是指我们使用class,struct,union定义的类型。
注意:C++11 中针对内置类型成员不初始化的缺陷打了补丁。即:内置类型成员变量在类中声明时可以给默认值。
- 无参的构造函数和全缺省的构造函数都称为默认构造函数,并且默认构造函数只能有一个。注意:无参构造函数、全缺省构造函数、我们没写编译器默认生成的构造函数,都可以认为是默认构造函数。
三、析构函数
3.1 什么是析构函数?
析构函数:与构造函数功能相反,析构函数不是完成对对象本身的销毁,局部对象销毁工作是由编译器完成的。而对象在销毁时会自动调用析构函数,完成对象中资源的清理工作。
3.2 析构函数的特性
析构函数是特殊的成员函数。
- 析构函数名是在类名前加上字符 ~。
- 无参数无返回值类型。
- 一个类只能有一个析构函数。若未显式定义,编译器会自动生成默认的析构函数。注意:析构函数不能重载。
- 对象生命周期结束时,编译器会自动调用析构函数。
用户没有显式地写析构函数,编译器自己生成的默认析构函数会做哪些工作呢?和构造函数一样,我们不写,编译器自己生成的默认析构函数对内置类型不处理,对于自定义类型会调用它的默认析构函数对自定义类型对象的资源进行析构。
class Time { public: ~Time() { cout << "~Time()" << endl; } private: int _hour; int _minute; int _second; }; class Date { private: //内置类型 int _year = 1970; int _month = 1; int _day = 1; // 自定义类型 Time _t; }; int main() { Date d; return 0; } // 程序运行结束后输出:~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类生成的默认析构函数。 // // 注意:创建哪个类的对象则调用该类的构造函数,销毁那个类的对象则调用该类的析构函数。
- 如果类中没有动态申请资源时,析构函数可以不写,直接使用编译器生成的默认析构函数,比如Date类;有动态申请资源时,一定要写,否则会造成资源泄漏,比如Stack类,需要在析构函数中释放动态malloc的内存。
四、拷贝构造函数
4.1 什么是拷贝构造函数?
拷贝构造函数:只有一个形参,该形参是本类类型对象的引用(一般用const修饰),在用已存在的类类型对象创建新对象时由编译器自动调用。
4.2 拷贝构造函数的特性?
拷贝构造函数也是特殊的成员函数,其特征如下:
- 拷贝构造函数是构造函数的一个重载形式。
- 拷贝构造函数的参数只有一个且必须是类类型对象的引用,使用传值方式编译器直接报错,因为会引发无穷递归调用。
class Date { public: Date(int year = 2023, int month = 4, int day = 30) { _year = year; _month = month; _day = day; } //如果这样写编译器会报错:“Date”: 非法的复制构造函数: 第一个参数不应是“Date” /*Date(const Date d) { _year = d._year; _month = d._month; _day = d._day; }*/ //这样写才是正确的,Date必须要是引用,否则会出现无穷递归的问题 Date(const Date& d) { _year = d._year; _month = d._month; _day = d._day; } void Print() { cout << "_year:" << _year << endl << "_month:" << _month << endl << "_day:" << _day << endl; } private: int _year; int _month; int _day; }; int main() { Date d1; Date d2(d1); d1.Print(); return 0; }
- 若未显式定义,编译器会生成默认的拷贝构造函数。 默认的拷贝构造函数对象按内存存储按字节序完成拷贝,这种拷贝叫做浅拷贝,或者值拷贝。
需要注意的是:在编译器生成的默认拷贝构造函数中,内置类型是按照字节序方式直接拷贝的,而自定义类型是调用其拷贝构造函数完成拷贝的。
- 编译器生成的默认拷贝构造函数已经可以完成字节序的值拷贝了,还需要自己显式实现吗?当然像日期类这样的类是没必要的,但是对于有动态开辟空间的类呢?
原因如下:
需要注意的是:类中如果没有涉及资源申请时,拷贝构造函数是否写都可以;一旦涉及到资源申请时,则拷贝构造函数是一定要写的,否则就是浅拷贝。申请的空间会多次释放,导致程序崩溃。
那对于又动态资源申请的类的拷贝构造函数该怎么写呢?
class Stack { public: //构造函数 Stack() { cout << "Stack()" << endl; _a = (int*)malloc(sizeof(int) * 10); if (_a == nullptr) { perror("malloc fail"); return; } _top = 0; _capacity = 10; } //拷贝构造函数 Stack(const Stack& st) { cout << "Stack(const Stack& st)" << endl; //拷贝构造函数需要重新开辟一样大的空间给正在创建的对象的_a _a = (int*)malloc(sizeof(int)*st._capacity); if (_a == nullptr) { perror("malloc fail"); return; } _top = st._top; _capacity = st._capacity; //把被拷贝对象的内容拷贝到新对象开辟的空间中,才能保证每个对象都有 //自己独立的空间,不会在析构时对同一块空间析构多次 memcpy(_a, st._a, sizeof(int) * _capacity); } //析构函数 ~Stack() { if (_a) { cout << "~Stack()" << endl; free(_a); _a = nullptr; } _top = 0; _capacity = 0; } void Push(const int& x) { if (_top == _capacity) { int* tmp = (int*)realloc(_a, sizeof(int) * _capacity * 2); if (tmp == nullptr) { perror("realloc fail"); return; } _a = tmp; _capacity *= 2; } _a[_top] = x; _top++; } private: int* _a; int _top ; int _capacity ; }; int main() { Stack st1; st1.Push(1); st1.Push(2); st1.Push(3); st1.Push(4); Stack st2(st1); return 0; }
- 拷贝构造函数典型调用场景:
(1)使用已存在对象创建新对象时。
(2)函数参数类型为类类型对象时。
(3)函数返回值类型为类类型对象时。
为了提高程序效率,一般对象传参时,尽量使用引用类型,返回时根据实际场景,能用引用尽量使用引用,因为引用不需要拷贝。
五、赋值运算符重载函数
5.1 运算符重载
C++为了增强代码的可读性引入了运算符重载,运算符重载是具有特殊函数名的函数,也具有其返回值类型,函数名字以及参数列表,其返回值类型与参数列表与普通的函数类似。
函数名字为:关键字operator后面接需要重载的运算符符号。
函数原型:返回值类型 operator操作符(参数列表)
需要注意的以下几点:
- 不能通过连接其他符号来创建新的操作符:比如operator$。
- 重载操作符必须有一个类类型参数。
- 用于内置类型的运算符,其含义不能改变,例如:内置的整型+,不能改变其含义。
- 作为类成员函数重载时,其形参看起来比操作数数目少1,因为成员函数的第一个参数为隐藏的this。
注意以上5个运算符不能重载。这个经常在笔试选择题中会出现。(谨记)
5.2 赋值运算符重载
1、赋值运算符重载格式
参数类型:const T&,T是类名,传递引用可以减少拷贝,提高传参效率。
返回值类型:T&,返回引用可以减少拷贝,提高返回的效率,有返回值目的是为了支持连续赋值。
检测是否自己给自己赋值。
返回*this :要符合连续赋值的含义。
class Date { public: //构造函数 Date(int year = 2023, int month = 4, int day = 30) { _year = year; _month = month; _day = day; } //拷贝构造函数 Date(const Date& d) { _year = d._year; _month = d._month; _day = d._day; } //赋值 Date& operator=(const Date& d) { if (this != &d) { _year = d._year; _month = d._month; _day = d._day; } return *this; } void Print() { cout << "_year:" << _year << endl << "_month:" << _month << endl << "_day:" << _day << endl; } private: int _year; int _month; int _day; }; int main() { Date d1(2022, 10, 20); Date d2(2023, 5, 1); d1.Print(); d1 = d2; d1.Print(); return 0; }
2、赋值运算符只能重载成类的成员函数不能重载成全局函数。
原因:赋值运算符重载函数如果不在类里面显式实现,编译器会生成一个默认的。此时用户再在类外自己实现一个全局的赋值运算符重载函数,就会和编译器在类中生成的默认赋值运算符重载函数发生冲突,故赋值运算符重载只能是类的成员函数。
3.、如果用户没有显式实现,编译器会生成一个默认赋值运算符重载函数,以值的方式逐字节拷贝。注意:内置类型成员变量是直接赋值的,而自定义类型成员变量需要调用对应类的赋值运算符重载函数完成赋值。
4、既然我们不写编译器自动生成的默认赋值重载函数就能实现按字节序拷贝,那么我们还有必要自己写吗?当然,像日期类这样的就不需要自己显式地实现的,让编译器自动生成的就能够完成值拷贝,但是像Stack这种有动态开辟空间的呢?
原因如下:
那涉及动态资源申请的类的赋值重载函数该如何写呢?
class Stack { public: //构造函数 Stack() { cout << "Stack()" << endl; _a = (int*)malloc(sizeof(int) * 10); if (_a == nullptr) { perror("malloc fail"); return; } _top = 0; _capacity = 10; } //赋值重载函数 Stack& operator=(const Stack& st) { cout << "Stack operator=(const Stack& st)" << endl; if (this != &st) { //赋值重载函数需要重新开辟和st一样大的空间给_a int* tmp = (int*)malloc(sizeof(int) * st._capacity); if (tmp == nullptr) { return *this; } _top = st._top; _capacity = st._capacity; //把被赋值对象的内容赋值到赋值对象开辟的空间中,才能保证每个对象都有 //自己独立的空间,不会在析构时对同一块空间析构多次 memcpy(tmp, st._a, sizeof(int) * _capacity); free(_a); _a = tmp; } return *this; } //析构函数 ~Stack() { if (_a) { cout << "~Stack()" << endl; free(_a); _a = nullptr; } _top = 0; _capacity = 0; } void Push(const int& x) { if (_top == _capacity) { int* tmp = (int*)realloc(_a, sizeof(int) * _capacity * 2); if (tmp == nullptr) { perror("realloc fail"); return; } _a = tmp; _capacity *= 2; } _a[_top] = x; _top++; } private: int* _a; int _top; int _capacity; }; int main() { Stack st1; st1.Push(1); st1.Push(2); st1.Push(3); st1.Push(4); Stack st2; st2.Push(6); st2.Push(7); st2.Push(8); st2 = st1; return 0; }
5.3 前置++和后置++重载
class Date { public: // 获取某年某月的天数 int GetMonthDay(int year, int month) { static int DayArr[13] = { 0,31,28,31,30,31,30,31,31,30,31,30,31 }; if ((month == 2) && (((year % 4 == 0) && (year % 100 != 0)) || (year % 400 == 0))) { return 29; } return DayArr[month]; } Date(int year = 2023, int month = 12, int day = 31) { _year = year; _month = month; _day = day; } void Print() { cout << _year << "/" << _month << "/" << _day << endl; } Date& operator+=(int day) { _day += day; while (_day > GetMonthDay(_year, _month)) { _day -= GetMonthDay(_year, _month); _month++; if (_month == 13) { _year++; _month = 1; } } return *this; } // 日期+天数 Date operator+(int day) { Date tmp(*this); tmp += day; return tmp; } // 前置++和后置++都是一元运算符,为了让前置++与后置++能正确重载 // C++规定:后置++重载时多增加一个int类型的参数,但调用函数时该 // 参数不用传递,编译器自动传递 //前置++,返回的值是++之后的值,返回的this指针指向的对象 // 出了该函数不销毁,所以为了提高效率,用引用返回 Date& operator++() { *this += 1; return *this; } //后置++,由于与前置++同名,需要加一个参数作为占位符,以示区分 //返回值是++之前的值,所以需要创建一个临时对象tmp保存++之前的值, // 返回的tmp是临时对象,出了该函数就会随着栈帧的销毁而销毁,所以 //只能用传值返回,绝对不能用引用返回(切记) Date operator++(int) { Date tmp(*this); *this += 1; return tmp; } private: int _year; int _month; int _day; }; int main() { Date d1(2023, 5, 1); Date d; d = d1++; //d: 2023/5/1 d1: 2023/5/2 d.Print(); d1.Print(); d = ++d1; //d: 2023/5/3 d1:2023/5/3 d.Print(); d1.Print(); return 0; }
六、const成员
C++中用const修饰的“成员函数”称之为const成员函数,const修饰类成员函数,实际修饰该成员函数隐含的this指针,表明在该成员函数中不能对类的任何成员进行修改。
- const对象可以调用非const成员函数吗?
答:不可以,const对象本身是不支持修改对象的成员的,调用非const成员函数会导致权限放大,如果可以,那const对象在成员函数里可以修改对象中的变量,这显然是不对的。
- 非const对象可以调用const成员函数吗?
答:可以,非const对象本身是支持修改对象的成员的,调用const成员函数会使对象不能修改成员变量,权限缩小是允许的。
- const成员函数内可以调用其它的非const成员函数吗?
答: 不可以,同1,权限放大。
- 非const成员函数内可以调用其它的const成员函数吗?
答: 可以,同2,权限缩小。
七、取地址及const取地址操作符重载
这两个默认成员函数一般不用重新定义 ,编译器默认会生成。
class Date { public: Date* operator&() { return this; } const Date* operator&() const { return this; } private: int _year; int _month; int _day; };
这两个运算符一般是不需要重载的,使用编译器生成的默认取地址的重载就可以了,只有在特殊的情况,才需要重载,比如想让别人获取到指定的内容!