👉类的六个默认成员函数👈
如果一个类中什么成员都没有,简称为空类。空类中真的什么都没有吗?并不是,任何类在什么都不写时,编译器会自动生成以下六个默认成员函数。默认成员函数:用户没有显式实现,编译器会生成的成员函数称为默认成员函数。
class Date{};
如果我们不写默认的成员函数,编译器会自己生成一个;如果我们写了,编译器就不会生成。换句话说就是,有些类的默认成员函数需要我们自己写,而另一些类,编译器默认生成的就够用了。那这六个默认成员函数究竟是什么呢?我们现在来学一学。
👉构造函数👈
概念
#include <iostream> using namespace std; class Date { 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() { Date d1; d1.Init(2022, 11, 5); d1.Print(); Date d2; d2.Init(2022, 11, 6); d2.Print(); return 0; }
对于上面的日期类Date,可以通过Init共有函数对对象设置日期,但如果每次创建对象时都要通过该函数设置信息,未免有点麻烦。那能否在创建对象时,就将信息设置进去呢?C++ 之父也想到了这一点,那么大佬设计的构造函数就登场了来做这一件事。
构造函数是一个特殊的成员函数,名字与类名相同,创建类类型对象时由编译器自动调用,以保证每个数据成员都有 一个合适的初始值,并且在对象整个生命周期内只调用一次。
特性
构造函数是特殊的成员函数,需要注意的是,构造函数虽然名称叫构造,但是构造函数的主要任务并不是开空间创建对象,而是初始化对象。
其特性如下:
函数名与类名相同。
无返回值。
对象实例化时编译器自动调用对应的构造函数。
构造函数可以重载。
注:构造函数的无返回值就是函数名前连 void 都不能带。构造函数可以重载的意思就是可以写多个构造函数,提供多种初始化方式。
那我们来看一下日期类的构造函数。
#include <iostream> using namespace std; class Date { public: // 初始化对象 //Date() //{ // _year = 1; // _month = 1; // _day = 1; //} //Date(int year, int month, int day) //{ // _year = year; // _month = month; // _day = day; //} // 以上两个构造函数可以合成为下面的构造函数 // 通常,构造函数会给上缺省值 Date(int year = 1, int month = 1, int day = 1) { _year = year; _month = month; _day = day; } void Print() { cout << _year << "/" << _month << "/" << _day << endl; } private: int _year; int _month; int _day; }; int main() { Date d1(2022, 11, 5); d1.Print(); Date d2(2022, 11, 6); d2.Print(); Date d3; d3.Print(); // 无参的不要想下面这样写,因为分不清是定义对象还是函数声明 //Date d4(); return 0; }
注意:如果通过无参构造函数初始化对象时,对象后面不用跟括号,否则就成了函数声明。
上面的构造函数还是有一点小 BUG 的,就是会将非法日期也看成合法日期。那日期类的构造函数就要检查一下日期的合法性了。
//... // 获取每个月的天数 int GetMonthDay(int year, int month) { // static修饰数组避免频繁创建 static int monthDayArray[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; } else { return monthDayArray[month]; } } Date(int year = 1, int month = 1, int day = 1) { _year = year; _month = month; _day = day; // 检查日期是否合法 if(!(year >= 1 && (month >= 1 && month <= 12) && (day >= 1 && day <= GetMonthDay(year, month)))) { cout << "非法日期" << endl; } } //...
上面的日期类的构造函数也还挺简单的,我们来学习一下栈的构造函数。
class Stack { public: Stack(int capacity = 4) { _a = (int*)malloc(sizeof(int) * capacity); if (_a == nullptr) { perror("malloc fail"); exit(-1); } _top = 0; _capacity = capacity; } void Push(int x) { // 扩容 _a[_top++] = x; } private: int* _a; int _top; int _capacity; }; int main() { Stack st1; st1.Push(1); st1.Push(2); st1.Push(3); return 0; }
有时候,我们会经常忘记自定义类型的初始化。那么有了构造函数,我们再也不用担心对象的初始化了。
在上面讲到了,如果类中没有显式定义构造函数,则C++ 编译器会自动生成一个无参的默认构造函数,一旦
用户显式定义编译器将不再生成。
那如果我们不自己给日期类和栈写构造函数,会发生什么呢?我们来看一下:
我们可以看到,使用编译器生成构造函数给对象初始化,却还是随机值。为什么这样呢?我们来看一下下面的结论。
结论:
关于编译器生成的默认成员函数,很多童鞋会有疑惑:
不实现构造函数的情况下,编译器会 生成默认的构造函数。但是看起来默认构造函数又没什么用?d1 对象调用了编译器生成的默认构造函数,但是 d1 中的数据依旧是随机值。也就说在这里编译器生成的默认构造函数并没有什么用??
解答:C++ 把类型分成内置类型(基本类型)和自定义类型。内置类型就是语言提供的数据类型,如:int、char。自定义类型就是我们使用class、struct、union等自己定义的类型。对于内置类型,生成的默认构造函数不会对数据进行处理;对于自定义类型会调用该自定义类型的默认构造函数。
注意:构造函数和默认构造函数不是同一个概念,千万不要混淆。
无参的构造函数和全缺省的构造函数都称为默认构造函数,并且默认构造函数只能有一个。注意:无参构造函数、全缺省构造函数、编译器默认生成的构造函数,都可以认为是默认构造函数。
我们现在就通过一个例子来验证一下上面的结论。
#include <iostream> using namespace std; class A { public: A() { _a = 0; cout << "A()构造函数" << endl; } private: int _a; }; class Date { public: // 不自己写构造函数,看看编译器自动生成的构造函数 void Print() { cout << _year << "/" << _month << "/" << _day << endl; } private: // 内置类型 int _year; int _month; int _day; // 自定义类型 A _aa; }; int main() { Date d1; d1.Print(); return 0; }
我们在日期类里加上了个自定义类型class A,可以发现:如果没有写构造函数,对于自定义类型,编译器会调用该自定义类型的默认构造函数。为了明显的看到默认构造函数对于自定义类型的好处了,我们看一个用栈实现队列的例子。
类MyQueue没有写构造函数,所以会去调用栈Stack的默认构造函数来完成初始化。所以,有些类的构造函数需要自己写,有些类不需要自己写。
那什么类的构造函数才需要自己写呢?我给出的意见是:关注需求,如果编译器默认生成的构造函数满足需求了,就不需要自己写;如果不满足需求,就需要自己写。比如:日期类Date和栈Stack需要写构造函数,而队列MyQueue不需要写构造函数。
但是编译器生成的默认构造函数对于内置类型,不会进行处理。其实如果对内置类型进行处理的话,内置类型初始化成 0 肯定会更好一点。为了弥补上这个不足,C++ 之父又做了一下这件事。C++11 中针对内置类型成员不初始化的缺陷,又打了补丁,即:内置类型成员变量在类中声明时可以给缺省值。
注意:如果日期类有全缺省的构造函数,就会用该构造函数来给成员变量初始化,就不会用成员变量声明时的缺省值来给成员变量初始化。缺省值也可以是 malloc 函数申请来的空间。
在上面,我们已经提到了默认构造函数。无参的构造函数、全缺省的构造函数和编译器默认生成的构造函数都称为默认构造函数,并且默认构造函数只能有一个。通俗来说,不需要传参数就可以调用的构造函数,就称为默认构造函数。 现在我们来看一下默认构造函数需要注意点什么问题。
默认构造函数只能有一个
无默认构造函数
因为我们写了构造函数,所以编译器就不会生成默认构造函数了。但是我们写的这个构造函数又不是默认构造函数,所以我们写出Date d1;这样的语句就无法通过编译,会提示我们没有合法的默认构造函数可用。
以上大概是构造函数的百分之八十的内容了,还有初始化列表的内容没有讲解。这个知识点将会在类和对象(下)里讲解,现在我们来学习析构函数。
👉析构函数👈
概念
通过前面构造函数的学习,我们知道一个对象是怎么来的,那一个对象又是怎么没呢的?
析构函数:与构造函数功能相反,析构函数不是完成对对象本身的销毁,局部对象销毁工作是由编译器完成的。而对象在销毁时会自动调用析构函数,完成对象中资源的清理工作, 和我们之前学的销毁栈的函数Destroy功能相似。
特性
析构函数是特殊的成员函数,其特征如下:
- 析构函数名是在类名前加上字符
~
。- 无参数无返回值类型。
- 一个类只能有一个析构函数。若未显式定义,系统会自动生成默认的析构函数。注意:析构函数不能重载。
对象生命周期结束时,C++ 编译系统系统自动调用析构函数。
系统生成的默认析构函数只会释放对象本身所占据的内存,对象通过其他方式如动态内存分配(new)和打开文件等方式获得的内存和系统资源是不会被释放的。
类的析构函数调用一般按照构造函数调用的相反顺序进行调用,但是要注意 static 对象的存在。 因为 static 改变了对象的生存作用域,需要等待程序结束时才会析构释放对象
以下为栈的析构函数,有了析构函数,我们就不怕忘记调用Destroy函数来归还申请的空间了。因为析构函数会帮自动帮我们清理对象的资源。
//... ~Stack() { free(_a); _a = nullptr; _top = _capacity = 0; } //...
注意:并不是所有类都需要写析构函数。比如:日期类就不需要写构造函数。因为日期类并没有向堆区申请空间。
如果我们没有写析构函数,那么编译器就会自动生成一个默认析构函数。默认析构函数对于内置类型不做处理,对于自定义类型会调用该自定义类型的析构函数。我们还是通过用栈实现队列的例子来观察。通过下图,我们可以看到MyQueue没有写析构函数,但它会调用Stack的析构函数。
并不是所有的类都需要写析构函数,如果编译器默认生成的析构函数满足我们的需求,我们就不需要写了;如果不满足需求,就需要我们自己写了。
以上就是析构函数的内容,接着我们来学习拷贝构造函数。
👉拷贝构造函数👈
概念
在现实生活中,可能存在一个与你一样的自己,我们称其为双胞胎。
那在创建对象时,可否创建一个与已存在对象一某一样的新对象呢?这是可以的,这就要借助拷贝构造函数了。
拷贝构造函数:只有单个形参,该形参是对本类类型对象的引用(一般常用const
修饰),在用已存在的类对象创建新对象时由编译器自动调用。拷贝构造函数也是构造函数的一种。
特性
拷贝构造函数也是特殊的成员函数,其特性如下:
- 拷贝构造函数是构造函数的一个重载形式。
- 无返回值。
- 拷贝构造函数的参数只有一个且必须是类对象的引用,使用传值方式编译器直接报错, 因为会引发无穷递归调用。
那我们以日期类为例,来学拷贝构造函数。
注意:拷贝构造函数的参数一定是类对象的引用。如果参数是类对象的话,会造成无限递归调用拷贝构造函数。因为形参是实参的拷贝,引用是实参的别名,传值传参要调用拷贝构造函数,那么就会无限调用下去。
这个无限递归调用拷贝构造函数有点难理解,那么我们就写两个函数来帮助大家来理解。
我们可以明显地看到,传值传值的函数会调用拷贝构造函数。所以如果拷贝构造函数的参数为类对象,而不是类对象的引用,就会导致无限递归调用拷贝构造函数的问题。
注:为了提高程序效率,一般对象传参时,尽量使用引用类型,返回时根据实际场景,能用引用尽量使用引用。
日期类的拷贝构造函数
//... Date(const Date& d) { _year = d._year; _month = d._month; _day = d._day; // 形参加const修饰,防止写反了,下面的问题就可以检查出来 //d._day = _day; } //...
注:将拷贝构造函数的参数换成指针也能实现,但是这个函数就不再是拷贝构造函数了,而是构造函数。拷贝构造函数的引用要用const关键字修饰,防止写反了两个参数。
若未显式定义,编译器会生成默认的拷贝构造函数。 默认的拷贝构造函数对象按内存存储按字节序完成拷贝,这种拷贝叫做浅拷贝,或者值拷贝。
注意:在编译器生成的默认拷贝构造函数中,内置类型是按照字节方式直接拷贝的,而自定义类型是调用其拷贝构造函数完成拷贝的。
可以看到,如果我们不写日期类Date的拷贝构造函数,编译器生成的默认拷贝构造函数也够用了。那如果栈Stack不写拷贝构造函数,编译器生成的默认拷贝构造函数是否够用呢?我们来看一下。
如果我们没有写栈Stack的拷贝构造函数,编译器就会生成默认拷贝构造函数完成对象的浅拷贝。那么此时 st1 和 st2 就指向了同一块空间,那么就会导致一个问题:调用析构函数是会对同一块空间析构两次,那么程序就会崩溃掉。
注:对象的析构顺序符合先定义后析构的原则。但是要注意static对象的存在, 因为static改变了对象的生存作用域,需要等待程序结束时才会析构释放对象。
类中如果没有涉及资源申请时,拷贝构造函数是否写都可以;一旦涉及到资源申请时,则拷贝构造函数是一定要写的,且构造拷贝函数为深拷贝;否则为浅拷贝。
那我们来写一下栈Stack
的拷贝构造函数。
//... Stack(const Stack& st) { _a = (int*)malloc(sizeof(int) * st._capacity); if (_a == nullptr) { perror("malloc fail"); exit(-1); } memcpy(_a, st._a, sizeof(int) * st._top); _top = st._top; _capacity = st._capacity; } //...
这时候,调式起来就可以看到 st1 和 st2 不再指向同一块空间了, st1 和 st2 的修改不会互相影响,也不会造成同一块空间释放多次的问题。
我们已经写好了栈Stack的拷贝构造函数,那我想问大家一个问题,什么样的类需要写拷贝构造函数呢?下面我给大家总结了个规律。
规律:
需要写析构函数的类,都需要写深拷贝的拷贝构造函数。
不需要写析构函数的类,编译器默认生成的浅拷贝的拷贝构造函数就够用了。
编译器默认生成的拷贝构造函数对于内置类型会进行浅拷贝,而对于自定义类型会调用该类型的拷贝构造函数。那现在我们以MyQueue为例,来看一看是不是酱紫的。
通过上图可以发现,确实是这样子的。因为MyQueue不需要写析构函数,所以它也就不需要写构造函数了。
👉运算符重载👈
概念
C++ 为了增强代码的可读性引入了运算符重载,运算符重载是具有特殊函数名的函数,也具有其返回值类型,函数名字以及参数列表,其返回值类型与参数列表与普通的函数类似。
函数名字为:关键字operator后面接需要重载的运算符符号。
函数原型:返回值类型 operator操作符(参数列表)
注意:
不能通过连接其他符号来创建新的操作符,比如:operator@。
重载操作符必须有一个类类型参数。
用于内置类型的运算符,其含义不能改变,例如:内置的整型+,不能改变其含义。
作为类成员函数重载时,其形参看起来比操作数数目少1,因为成员函数的第一个参数为隐藏的 this 指针。
.* :: sizeof ?: .注意以上5个运算符不能重载,这个经常在笔试选择题中出现。
运算符重载是非常有意义的。想要比较内置类型的大小,是相当简单的。那么对于自定义类型的话,我们就需要借助函数来完成这个工作,这个函数就是运算符重载。
注:运算符重载和函数重载没有必然的联系,运算符重载知识为了自定义类型对象能够使用运算符,增强代码的可读性。
接下来的运算符重载的讲解,我都以日期类为例。因为日期类相对来说比较简单,也是相当地经典。