1. 类的6个默认成员函数
首先,我们直接给出类中有哪六类默认1. 类的6个默认成员函数
此时,我们可以会想到,为什么要有这些默认成员函数?这些默认成员函数会带来什么作用呢?
要弄清楚这个问题,我们先来引入一个“空类”的概念。
👉空类的定义:如果一个类中什么成员都没有,即一个类中没有成员变量,也没有成员函数,简称为空类。定义形式如下:
class Date { };
通过如上代码发现,空类中什么都没有放,此时请大家认真思考一下,👉空类中难道真的什么都没有吗?
- 答案其实是否定的,对于任何一个类来说,它们都有六个默认成员函数,即使是空类。经过编译器处理之后,类【Date】便不在为空,它会自动的生成六个默认的成员函数,即使这六个成员函数什么也不做。
因此,这就给我们解答了为什么要引入这六个默认成员函数,具体大家可以这样理解:
- 当我们定义一个类时,在初始化之前就调用了打印函数,这样会导致输出的是一个随机值,为了避免这种情况,所以c++给了六种默认成员函数
- 而且当我们定义的类为空类时,都会自动生成六个默认成员函数。
具体还可以像如下这样分,大家可以直观的感受各个函数的区别与功能:
至此,这个六个默认成员函数的作用与由来便给大家将清楚了,接下来我们逐个去认识!!!
2. 构造函数
2.1概念介绍
首先,在正式的给出【构造函数】具体的概念前,我们通过代码的方式来为大家做个前情铺垫,这样大家可以直观的感受,通过之前类和对象(上)的学习,我相信大家都能写出一个如下日期类,如果对其还有疑惑的话,可以参考【类和对象(上)】
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(2023, 3, 15); d1.Print(); Date d2; d2.Init(2023, 2, 14); d2.Print(); return 0; }
对于上述这样一个日期类,当我们构造出来之后一般都会先对其进行“初始化”的操作:
但是有时候我们可能会忘记进行初始化操作,直接对对象进行操作,这时当我们不初始化就直接用可能就会出现问题:
当我们进行调试时也可以直观的看到:
因此,为了解决当我们构造出函数之后,未进行初始化就直接对对象进行操作的情况,【C++】就给出了今天我们将要学习的知识——构造函数。有了构造函数,当我们每创建完一个对象,就不用再去手动的调用【Init】函数,因为在创建对象时编译器会自动去调用构造函数对对象进行初始化。
有了上述的认知之后,在这里我给出构造函数的具体概念:
- 构造函数 ,是一种特殊的方法。主要用来在创建对象时初始化对象, 即为对象成员变量赋初始值,总与【new】运算符一起使用在创建对象的语句中。特别的一个类可以有多个构造函数 ,可根据其参数个数的不同或参数类型的不同来区分它们 即构造函数的重载。
2.2 特性介绍
构造函数是特殊的成员函数,需要注意的是,构造函数虽然名称叫构造,但是构造函数的主要任 务并不是开空间创建对象,而是初始化对象。
其特征如下:
1. 函数名与类名相同。
- 这个意思很简单,当我们定义好一个类后,此时它的构造函数的函数名就确定好了,跟当前类的类名是相同的。
2. 无返回值,不能指定返回类型,即使是void也不行。
3. 对象实例化时编译器自动调用对应的构造函数。虽然在一般情况下,构造函数不被显式调用,而是在创建对象时自动被调用。但是并不是不能被显式调用。
4. 构造函数可以重载。
我们通过代码进行举例说明,此时我们已经创建出了一个【Date】类:
class Date { public: // 1.无参构造函数 Date() {} // 2.带参构造函数 Date(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(2023, 3, 15); d1.Print(); Date d2(2023, 3, 15);// 调用带参的构造函数 //d2.Init(2023, 2, 14); d2.Print(); Date d3(); d3.Print(); return 0; }
解析:
此时当我们运行【Date.d1】和【Date.d2】时,集合上面说到的我们可以发现运行结果如下,传参就调用有参数的构造函数,不传参就调用不传参的构造函数:
而当我们此时运行【Date.d3】时,我们会发现程序出现了报错的情况:
👉此时就需要注意一点,如果通过无参构造函数创建对象时,对象后面不用跟括号,否则就成了函数声明 ,声明了【d3】函数,该函数无参,返回一个日期类型的对象
到这大家思考一下,当我们写出这两个代码时,是否可以进行合并为一个代码呢?
class Date { public: Date() { _year = 1; _month = 2; _day = 3; } Date(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; };
答案当然是可以的,那么我们要怎么做呢?这就需要用到之前学习的缺省参数的知识:
Date(int year=1 , int month=2 , int day=3 ) { _year = year; _month = month; _day = day; }
此时,当我们运行它时,如果开始我们不传参,就用默认的,如果传了,就用我们传的。
这跟之前那个比较就显得很“高级”,不仅如此,这个功能相对比上面那种写法还更多,因此这里支持缺省参数,例如:
因此,这里就给大家说明一个点:一个类从大部分场景来说,当能提供构造函数的情况下尽可能提供全缺省或者至少是半缺省,就会显得十分好用。
紧接着就是一点小细节的问题,大家注意以下这两个函数可以同时存在吗?
我们浅浅的分析一波:
- 首先这里的两个函数构成我们之前讲过的重载吗?不知道大家是否还知道重载的基本知识:【函数名相同,参数不同】,大家从语法上看着可能觉得“确实像那么回事”,但是真的可以吗?我们直接运行代码:
- 那么到底为什么呢?大家可以试着想想,当我们传参数的时候既可以调无参,又可以调有参数的函数,那么这样到底应该调用谁呢?编译器就不知道该调那个了,调用时存在歧义,因此就报错了。
5. 如果类中没有显式定义构造函数,则C++编译器会自动生成一个无参的默认构造函数,一旦用户显式定义编译器将不再生成。
class Date { public: void Print() { cout << _year << "年" << _month << "月" << _day << "日" << endl; } private: int _year; int _month; int _day; }; int main() { Date d1; d1.Print(); return 0; }
在这时,我们将构造函数删除掉了,当我们去进编译时,我们会发现可以编译通过。为什么呢?因为之前说过默认会生成一个构造函数。
那么此时大家是否会有这样的想法,既然编译器自己就有默认的,那么是不是我们就不需要在去构造了呢?事实真的是这样的吗?当我们的代码运行起来时,大家可以看到下图:
为什么这是随机值呢?
- 这个问题,大家可以认为是我们的祖师爷设计的不好的一个地方,可能当时没有“想明白”。C++把类型分成内置类型(基本类型)和自定义类型。内置类型就是语言提供的数据类 型,如:int/char...,自定义类型就是我们使用class/struct/union等自己定义的类型。对编译器自动生成的构造函数不会对内置类型进行处理,然而对于自定义类型,则是去调用该自定义类型对应的默认构造函数。因此,上面代码类中的成员变量都是整形【int】,是内置类型,所以是随值。
接下来我们还是通过下图代码来进行相关的理解:
class Time { public: Time() { cout << "Time()" << endl; _hour = 0; _minute = 0; _second = 0; } private: int _hour; int _minute; int _second; }; class Date { private: // 基本类型(内置类型) int _year; int _month; int _day; // 自定义类型 Time _t; }; int main() { Date d1; return 0; }
👉解析:
- 上述代码我们不难发现,既有内置类型,也有自定义类型的,其中【Date】类中我们并没有对其写相应的构造函数,此时当我们在创建一个对象的时候,根据前面讲的内置类型不做处理,这时自然而然就会去调用编译器自动生成的构造函数。而对于这里的自定义类型【Time _t】,因为为自定义类型,因此编译器会自动去调用它对应的默认构造函数。此时当我们故意在【Time 】类的默认构造函数里面增加打印看是否进行了调用。
我们运行程序,结果如下:
此时,我们就会注意到自定义类型不写构造函数就没法初始化,这不是一个妥妥的【bug】吗?
因此,祖师爷呢在后来也发现了这个问题,并在C++11中针对内置类型不初始化的缺陷打了一个补丁,即:内置类型成员变量在 类中声明时可以给默认值。
什么意思呢?意思就是如果你不写构造函数,那么就默认用这个缺省值;如果你写构造函数了就不会用这个缺省值 。注意,这里不是初始化(千万要分辨清楚,这里没开空间哟!!!)
6. 无参的构造函数和全缺省的构造函数都称为默认构造函数,并且默认构造函数只能有一个。
注意:无参构造函数、全缺省构造函数、我们没写编译器默认生成的构造函数,都可以认为 是默认构造函数。
3. 析构函数
通过前面构造函数的学习,我们知道一个对象是怎么来的,那一个对象又是怎么没呢的?
3.1 概念介绍
这里我们就需要引入有关于虚构函数的知识:
- 析构函数 与构造函数相反,当对象结束其生命周期 ,如对象所在的函数已调用完毕时,系统自动执行析构函数。 析构函数往往用来做“清理善后” 的工作(例如在建立对象时用new开辟了一片内存空间,delete会自动调用析构函数后释放 内存 )。
3.2 特性介绍
析构函数是特殊的成员函数,其特征如下:
1. 析构函数名是在类名前加上字符 ~。
2. 无参数无返回值类型。
3. 一个类只能有一个析构函数。若未显式定义,系统会自动生成默认的析构函数。注意:析构 函数不能重载
4. 对象生命周期结束时,C++编译系统系统自动调用析构函数。
typedef int DataType; class Stack { public: Stack(size_t capacity = 3) { _array = (DataType*)malloc(sizeof(DataType) * capacity); if (NULL == _array) { perror("malloc申请空间失败!!!"); return; } _capacity = capacity; _size = 0; } void Push(DataType data) { // CheckCapacity(); _array[_size] = data; _size++; } // 其他方法... ~Stack() { if (_array) { free(_array); _array = NULL; _capacity = 0; _size = 0; } } private: DataType* _array; int _capacity; int _size; }; void TestStack() { Stack s; s.Push(1); s.Push(2); }
我们通过以上代码来做分析:
- 上述我们定义了一个栈类,并且已经写好了构造函数。我的问题是这里的【s】需要我们亲自动手去进行清理工作吗?很显然是不需要的,因为【s】是定义在栈区上的局部变量,一旦整个程序运行结束,就会随着【main】函数的栈帧自动销毁。
5. 关于编译器自动生成的析构函数,是否会完成一些事情呢?
下面的程序我们会看到,编译器生成的默认析构函数,对自定类型成员调用它的析构函数。
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】类生成的默认析构函数
👉注意:创建哪个类的对象则调用该类的析构函数,销毁那个类的对象则调用该类的析构函数
6. 如果类中没有申请资源时,析构函数可以不写,直接使用编译器生成的默认析构函数,比如 Date类;有资源申请时,一定要写,否则会造成资源泄漏,比如Stack类。
4. 拷贝构造函数
4.1 概念介绍
在现实生活中,可能存在一个与你一样的自己,我们称其为双胞胎。
那在创建对象时,可否创建一个与已存在对象一某一样的新对象呢?因此这就引出了拷贝构造函数的概念:
- 复制构造函数是构造函数的一种,也称拷贝构造函数,它只有一个参数,参数类型是本类的引用。
- 复制构造函数的参数可以是 const 引用,也可以是非 const 引用。 一般使用前者,这样既能以常量对象(初始化后值不能改变的对象)作为参数,也能以非常量对象作为参数去初始化其他对象。一个类中写两个复制构造函数,一个的参数是 const 引用,另一个的参数是非 const 引用,也是可以的。
此时,我们在通过代码来进行直观的理解:
class Date { public: Date(int year = 2023, int month = 3, int day = 15) { _year = year; _month = month; _day = day; } void Print() { cout << _year << "年" << _month << "月" << _day << "日" << endl; } private: int _year; int _month; int _day; }; int main() { Date d1(2008,2,5); Date d2(d1); d2.Print(); return 0; }
解析:
- 这里给出了初始化 【d2】的参数,即 【d1】。只有编译器自动生成的那个默认复制构造函数的参数才能和【d1】匹配,因此,【d2】就是以 【d1】 为参数,调用默认复制构造函数进行初始化的。初始化的结果是 【d2】 成为【d1】 的复制品,即 【d2】 和 【d1】 每个成员变量的值都相等。
4.2 特征介绍
拷贝构造函数也是特殊的成员函数,其特征如下:
1. 拷贝构造函数是构造函数的一个重载形式。
2. 拷贝构造函数的参数只有一个且必须是类类型对象的引用,使用传值方式编译器直接报错, 因为会引发无穷递归调用。
根据上述的基本知识,在这里我们知道可以通过如下去写:
Date(Date d) { _year = d._year; _month = d._month; _day = d._day; }
但是这样呢就会发生报错的情况,具体情况如下,如果语法强制这里编译不会通过,通过了这里它也会发生无穷递归的情况,就是不能使用传值传参,这里需要使用引用:
要理解上述问题我们需要了解这里为什么不能使用传值传参:
我们先来理解下面这几行代码的意思:
// 传值传参 void Func1(Date d) { } // 传引用传参 void Func2(Date& d) { } int main() { Func1(d1); Func2(d1); return 0; }
👉解析:
这里的【func1】是传值传参,是一个拷贝,即理解为新开一片空间,把【d1】拷贝给【d】,传引用传参即【d】是【d1】的别名,形参是实参的拷贝。内置类型,编译器可以直接拷贝;自定义类型的拷贝,需要调用拷贝构造。
解析:
- 对于【Date】这样的类,编译器可以自己去进行拷贝,而对于栈这样的类来说,编译器则是不能自己擅自去进行拷贝的(如果编译器这样的工作都能干的话,程序员可能真的就要失业了)。对于上述【Date】类里面只有简单的年月日等,可以按照类似于【memcpy】的方式一字节一字节的进行拷贝,俗称为浅拷贝。
- 而对于栈这样的类来说,假设【stl1】和【stl2】同时以字节去进行拷贝,把【_a】拷贝到一块新空间,这时就会出现问题。不难看出此时两个对象指向同一块空间,当两个对象此时指向同一块时,假设过一会儿就会调用析构函数,当【stl1】先析构,过了一会儿【stl2】又会继续析构,同一块空间析构了两次,这是不允许的,同时也还会引起其他的问题。因此,基于这样的原因自定义类型需要调用拷贝构造,栈上的内容需要进行相应的深拷贝构造(具体的我们后面会讲,这里先给出这样的概念)
到这里我们在解释上述提到的为什么要是无穷递归:
大家想想,那是一个对象实例化,对象实例化就需要用到构造函数,对应的构造函数又是拷贝构造,调拷贝构造之前需要先传参,传值传参又是一个拷贝构造,拷贝构造又需要传参,这样的不断循环,最终就是无穷递归,因此编译器构造的不能是传值传参!!!
因此这里可以怎么做呢?答案是在这里我们可以使用引用的基本方法去解决这个问题,具体如下:
class Date { public: Date(int year = 2023, int month = 3, int day = 16) { _year = year; _month = month; _day = day; } 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; Date d2(d1); d1.Print(); d2.Print(); return 0; }
加引用之后要怎么理解呢?
- 我们可以这样进行理解。这里的【d】加了引用,因此我们可以看做【d】是【d1】的别名,【d】就是【d1】,指针【this】就是【d2】(我们这里并没有有把【*this】写出来),此时【d1】就传给了【d2】
同时还有一种写法也是拷贝构造,编译器也可以允许像如下这样去写:
int main() { Date d1; Date d2(d1); Date d3 = d1; d1.Print(); d2.Print(); d3.Print(); return 0; }
程序运行结果如下:
👉注意:
这里有个小细节的地方问问大家,上面代码中,可以发现我们加入了【const】,大家知不知道为什么要加上这个【const】的?
对于为什么要加入【const】,我们还是以代码为例进行直观的了解,当我们不小心写反的时候,如果不加其中的【const】会出现什么情况呢?具体如下:
Date( Date & d) { d._year = _year; d._month= _month; d._day= _day; }
当我们去编译这个程序时,却不会出现报错的情况。但是当我们一运行这个程序,结果就会出现报错的情况。
此时我们浅浅的分析一波:
- 我们可以发现代码【d2】本来是拷贝【d1】,结果【d2】非但没能拷贝【d1】,还把【d1】改为了随机值。原因就是因为写反,【d】是【d1】的别名,【*this】的【d2】,本来是【d1】赋值给【d2】,但是现在你变成了【d2】赋值给【d1】的情况,因此就出现上述运行出错。
因此如果要确保实参的值不会改变,又希望避免复制构造函数带来的开销,解决办法就是将形参声明为对象的 const 引用,加上【const】,传递过来的不管是不是加了【const】都可以进行接收,但是如果不加【const】就会引起权限放大的问题,编译器是不允许这种情况出现的。出现任何有可能导致 【d】的值被修改的语句,都会引发编译错误。
3. 若未显式定义,编译器会生成默认的拷贝构造函数。 默认的拷贝构造函数对象按内存存储按 字节序完成拷贝,这种拷贝叫做浅拷贝,或者值拷贝这上面已经说过。
- 注意:在编译器生成的默认拷贝构造函数中,内置类型是按照字节方式直接拷贝的,而自定 义类型是调用其拷贝构造函数完成拷贝的。
4. 编译器生成的默认拷贝构造函数已经可以完成字节序的值拷贝了,还需要自己显式实现吗? 当然像日期类这样的类是没必要的。那么下面的类呢?验证一下试试?
我们通过下列栈类的进行举例:
typedef int DataType; class Stack { public: Stack(size_t capacity = 10) { _array = (DataType*)malloc(capacity * sizeof(DataType)); if (nullptr == _array) { perror("malloc申请空间失败"); return; } _size = 0; _capacity = capacity; } void Push(const DataType& data) { // CheckCapacity(); _array[_size] = data; _size++; } ~Stack() { if (_array) { free(_array); _array = nullptr; _capacity = 0; _size = 0; } } private: DataType* _array; size_t _size; size_t _capacity; }; int main() { Stack s1; s1.Push(1); s1.Push(2); s1.Push(3); s1.Push(4); Stack s2(s1); return 0; }
上述代码当我们去运行时, 这里会发现下面的程序会崩溃掉。什么原因呢?这里就需要我们理解深拷贝去解决。
在上面我们已经浅浅的谈到过这个问题,会发生析构两次的问题,那么到底谁先析构呢?在这里我们仔细分析一下。
👉分析如下:
- 首先我们给出答案,这里是【st2】先析构,我们知道【st1】和【st2】都是在栈上的建立的,而之前我们学习数据结构的时候知道,栈的特点是“先进后出”,因此遵循这样的原则,【st1】比【st2】先进入栈区中,这就会导致【st2】先析构,申请的这块空间就被释放了。但是紧接着当【st2】析构完了,【st1】也会进行它的析构,而此时虽然【st1】还保留了这块空间的地址,但是这块空间刚才已经被释放,所以这就会导致【st1】变成野指针,而编译器对野指针进行释放就会导致我们看到的崩溃现象。
注意:类中如果没有涉及资源申请时,拷贝构造函数是否写都可以;一旦涉及到资源申请 时,则拷贝构造函数是一定要写的,否则就是浅拷贝。
而对于深拷贝我们后面会具体去讲,这里我们先浅浅的谈一下:
对于上述的【Stack】进行浅拷贝就会导致两个栈对象指向了同一块空间了,所以才会出现崩溃的情况,那么深拷贝是怎么做的呢?其实深拷贝解决这个问题的原理就是让这两个对象各自拥有独立的空间。这样做对两个对象之间就不会互相影响了。
用代码浅浅的实现一下:
Stack(const Stack& st) { cout << "Stack(const Stack& st)" << endl; _array = (DataType*)malloc(sizeof(DataType)*st._capacity); if (nullptr == _array) { perror("malloc fail"); exit(-1); } memcpy(_array, st._array, sizeof(DataType)*st._size); _size = st._size; _capacity = st._capacity; }
此时,我们会看到两个地址空间不同,此时问题就解决了:
5. 拷贝构造函数典型调用场景:
- 使用已存在对象创建新对象
- 函数参数类型为类类型对象
- 函数返回值类型为类类型对象
接下来我们一一进行分析,复制构造函数在以下三种情况下会被调用:
1) 当用一个对象去初始化同类的另一个对象时,会引发复制构造函数被调用。例如,下面的两条语句都会引发复制构造函数的调用,用以初始化 【d2】。
Date d2(d1); Date d2 = d1;
👉注意,上面说过这两条语句是等价的。第二条语句是初始化语句,不是赋值语句。赋值语句的等号左边是一个早已有定义的变量,赋值语句不会引发复制构造函数的调用。例如:
Date d1,d2; d1 = d2; d1 = d2;
这条语句不会引发复制构造函数的调用,因为 【d1】早已生成,已经初始化过了。
2) 如果函数 【d1】的参数是类 【Date】的对象,那么当 【d1】被调用时,类 【Date】的复制构造函数将被调用。换句话说,作为形参的对象,是用复制构造函数初始化的,而且调用复制构造函数时的参数,就是调用函数时所给的实参。
class Date { public: Date() {}; Date(const Date& d1) { cout << "Time()" << endl; } }; void Func(Date d1) { } int main() { Date d1; Func(d1); return 0; }
输出结果为:
这是因为 Func 函数的形参 【d1】在初始化时调用了拷贝构造函数。
前面说过,函数的形参的值等于函数调用时对应的实参,现在可以知道这不一定是正确的。如果形参是一个对象,那么形参的值是否等于实参,取决于该对象所属的类的复制构造函数是如何实现的。例如上面的例子,Func 函数的形参 【d1】 的值在进入函数时是随机的,未必等于实参,因为复制构造函数没有做复制的工作。
3) 如果函数的返冋值是类 【Date】的对象,则函数返冋时,类 【Date】的复制构造函数被调用。换言之,作为函数返回值的对象是用复制构造函数初始化 的,而调用复制构造函数时的实参,就是 return 语句所返回的对象。例如下面的程序:
class Date { public: Date(int year = 2023, int month = 3, int day = 16) { _year = year; _month = month; _day = day; } Date(const Date& d) { _year = d._year; _month = d._month; _day = d._day; cout << "Date(const Date& d):" << this << endl; } void Print() { cout << _year << "年" << _month << "月" << _day << "日" << endl; } private: int _year; int _month; int _day; }; Date Test(Date d) { Date temp(d); return temp; } int main() { Date d1(2008, 2, 15); Test(d1); d1.Print(); return 0; }
程序的输出结果是:
调用了 Test函数,其返回值是一个对象,该对象就是用复制构造函数初始化的, 而且调用复制构造函数时,实参就是return 语句所返回的 【temp】。复制构造函数在之前确实完成了复制的工作,所以函数的返回值为赋值的。
所以,为了提高程序效率,一般对象传参时,尽量使用引用类型,返回时根据实际场景,能用引用 尽量使用引用。
5. 赋值运算符重载
5.1概念引出
在讲解这个知识点之前我们先了解一个以及回顾一下之前的:
初始化和赋值的区别:
- 在定义的同时进行赋值叫做初始化(Initialization),定义完成以后再赋值(不管在定义的时候有没有赋值)就叫做赋值(Assignment)。
- 初始化只能有一次,赋值可以有多次。
当以拷贝的方式初始化一个对象时,会调用拷贝构造函数;当给一个对象赋值时,会调用重载过的赋值运算符。
即使我们没有显式的重载赋值运算符,编译器也会以默认地方式重载它。默认重载的赋值运算符功能很简单,就是将原有对象的所有成员变量一一赋值给新对象,这和默认拷贝构造函数的功能类似。
对于简单的类,默认的赋值运算符一般就够用了,我们也没有必要再显式地重载它。但是当类持有其它资源时,例如动态分配的内存、打开的文件、指向其他数据的指针等,默认的赋值运算符就不能处理了,我们必须显式地重载它,这样才能将原有对象的所有数据都赋值给新对象。
基于上述情况,我们这里就引出了关于赋值运算符重载的概念。
5.2 运算符重载
在学习正式学习赋值运算符重载,我们先来学习运算符重载的基本知识,有了对这个理解,当我们讲解赋值运算符重载大家才会轻松上手,因为赋值运算符重载是属于运算符重载的。
为什么引入运算符重载:
- C++为了增强代码的可读性引入了运算符重载,运算符重载是具有特殊函数名的函数,也具有其 返回值类型,函数名字以及参数列表,其返回值类型与参数列表与普通的函数类似
还是以我们写过的日期类来举例子,在平常生活中我们是不是经常比较两个日期啊,想着多少天是几号,多少多少号跟今天相差几天这种情况。那么是否支持进行比较判别呢?答案当然是支持:,此时我们用日期类来实例化出两个对象,具体如下:
int main() { Date d1(2008, 2, 15); Date d2(2023, 3, 16); return 0; }
当我们实例化出两个对象d1,d2后,此时大家就会思考一个问题,现在我们想比较这两个对象是否相等,该怎么办呢?根据我们之前学过的知识,当然是用函数来封装它呀!写了功能函数就可以了。
bool Equal(const Date& x1, const Date& x2) { //...... }
这时大家会有这样的想法,这样的方法可行肯定是可行的,但是有没有更加直观的呢?就像我们下面这样去写:
d1 == d2;
为此,当C++引入了运算符重载之后,再去判断就直接像上面的代码这样去操作。但是我们要知道一点那就是自定义类型是不能直接作为这些操作符的操作数的,它不想内置类型一样可以直接进行操作。具体原因如下:
- 所谓的自定义类型,即为我们按照我们自己的想法或者为实现某个功能自己编写的程序,对于这样的程序,编译器是不知道该怎么做的。
- 其次就是我们自己编写的自定义类型其实并不是所有的运算都是有意义的,这个是由我们自己决定的,因此基于以上两点不难得出结论。
为了解决这个问题,就引入了运算符重载的概念!!!使得可以像【d1=d2】这样去进行操作。
- 函数名字为:关键字operator后面接需要重载的运算符符号。
- 函数原型:返回值类型 operator操作符(参数列表)
对比的逻辑思路也很简单,只需比较各个成员变量是否相等。即如下代码:
bool operator==(const Date& d1, const Date& d2) { return d1._year == d2._year && d1._month == d2._month && d1._day == d2._day; }
但是此时当我们编译时,会出现报错的情况:
咦....什么原因呢这是?我们浅浅的分析一波:
- 👉我们不难发现这是定义在类外面的,然而类中的成员变量却是私有的,基于这个原因当我们去编译时就出出现报错的情况。
那么如何解决呢?在这里我给出几种解决方法:
1.第一种就是我们刚才已经把问题 分析出来了,我们就会想到既然你是私有的我无法访问,那我把你直接变为公有的不就可以了吗?因此第一种方法就是先全部都变为公有的,即屏蔽我们的【private】
class Date { public: Date(int year = 1900, 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; }; bool operator==(const Date& d1, const Date& d2) { return d1._year == d2._year && d1._month == d2._month && d1._day == d2._day; } int main() { Date d1(2008, 2, 15); Date d2(2023, 3, 16); operator ==(d1, d2); d1 == d2; // 转换成去调用这个operator==(d1, d2); return 0; }
此时在当我们去运行代码的时候,就可以正常的运行了
当程序能够正常编译后,我们就会想着去运行,既然这个是函数,那我们可以打印一下这个结果,会有一个返回值,但是当我们运行时又会出现报错:
cout << operator==(d1, d2)<< endl; cout << d1 == d2 << endl;
这又是什么原因呢?答案很简单,是因为【<<
】的优先级比【==
】高,因此为了限制这种情况我们需要加个括号:
cout << operator==(d1, d2) << endl; cout << (d1 == d2) << endl;
此时,当我们再次运行时,结果显示就为正确:
从上可以看出这种办法可以解决这个问题,但是大家是否能够发现这样做存在的问题呢?上述方式,我们把全部都变为了公有,那么问题来了,封装性如何保证?
2.因此,基于以上方法存在的问题,我们给出了第二种方法。我们可以把这个函数重载到类里面,干脆重载成成员函数。
但是当我们放到类里面去后,我们再次运行代码,咦....怎么出错了呢?你不是说放到类里面去可以吗?别急,我们先看报错报的是什么?
👉它说我们的参数太多了,什么意思呢?
- 我们这里重载的是【
==
】运算符,正常情况下只有两个操作数,所以只需要两个参数就够了。但是大家是否还记得我们默认的还有一个【*this】这个隐藏的参数呀!!!因此这里只需给一个参数就可以了。
bool operator==(const Date& d2) { return _year == d2._year && _month == d2._month && _day == d2._day; }
此时,我们就需要这样去打印:
cout << d1.operator==(d2) << endl; cout << (d1 == d2) << endl;
这样我们再去运行程序,此时程序就正常运行的了。
👉注意:
- 不能通过连接其他符号来创建新的操作符:比如operator@
- 重载操作符必须有一个类类型参数
- 用于内置类型的运算符,其含义不能改变,例如:内置的整型+,不 能改变其含义
- 作为类成员函数重载时,其形参看起来比操作数数目少1,因为成员函数的第一个参数为隐 藏的this
- 【.*】 【 ::】 【 sizeof 】【 ?: 】【 .】 注意以上5个运算符不能重载。这个经常在笔试选择题中出 现。(【.*】只需记住即可)
接下来,我们在多写几个来进行练习。
1.第一个先写一个日期类的【<】
bool operator<(const Date& d) { if (_year < d._year) { return true; } else if (_year == d._year && _month < d._month) { return true; } else if (_year == d._year && _month == d._month && _day < d._day) { return true; } else { return false; } }
或者直接这样写:
return _year < d._year || (_year == d._year && _month < d._month) || (_year == d._year && _month == d._month && _day < d._day);
以上这两种都是可以的,那么到底是不是呢?我们直接运行程序,可以看到结果是正确的:
如果我们还想要实现其他的话,是不是看着麻烦呀!这门一大堆的东西。其实根本没必要在像以上这样去写了,上面我们已经写好了【==】和【<】,在写其他的直接复用这个就可以了。
2.例如写个【!=】,我们可以这样去写:
bool operator!=(const Date& d) { return !(*this == d); }
3.对于【<=】
// d1 <= d2 bool operator<=(const Date& d) { return *this < d || *this == d; }
4.对于【>】
// d1 > d2 bool operator>(const Date& d) { return !(*this <= d); }
5.对于【>=】
// d1 >= d2 bool operator>=(const Date& d) { return !(*this < d); }
当我们像这样做是不是就会很大程度上的减少我们的工作量呀!!!
5.3 赋值运算符重载
接下来我们正式的介绍关于赋值运算符重载的知识。
1. 赋值运算符重载格式
- 参数类型:const T&,传递引用可以提高传参效率
- 返回值类型:T&,返回引用可以提高返回的效率,有返回值目的是为了支持连续赋值
- 检测是否自己给自己赋值
- 返回*this :要复合连续赋值的含义
首先我们先来看看【=】即赋值怎么操作的把。通过上述的知识学习,我们不难写出这样的一个代码
//d1 = d2; void operator=(const Date& d) { _year = d._year; _month = d._month; _day = d._day; }
当我们写出这样的代码时,接下来我们就需要去验证这个代码的正确性,紧接着我们直接运行程序,运行结果如下:
从上可以得出代码的运行结果是正确的,我们在调试去看看是否真的这样。
上述我们也可以发现程序有去调对应的函数,同时记住一点,在转化的时候并不是让编译器把它给改了,而是编译的时候编译器识别,它看你有没有实现赋值,有实现赋值就转化为去【call】这个函数
然后根绝我们之前的学习经历,赋值往往会有连续赋值这一说法,就像【i=j=k】这样,不断的去连续赋值,然而上述代码当我们去进行这样的操作的时候,我们会发现是编译不通过的。
遇到困难不要害怕我们浅浅的分析一波:
因此最终这里却是【d2 = d3
】调用了重载函数,而我们上面实现的函数并没有返回值。因此这里就会出现报错的情况,那么怎么解决呢?很简单,我们只需要在这里添加个返回值即可,具体如下:
// 返回值为了支持连续赋值,保持运算符的特性 Date& operator=(const Date& d) { if (this != &d) { _year = d._year; _month = d._month; _day = d._day; } return *this; }
最终的运行结果如下:
2. 赋值运算符只能重载成类的成员函数不能重载成全局函数,这个我们之前已经说过了,不再具体讲解。
3. 用户没有显式实现时,编译器会生成一个默认赋值运算符重载,以值的方式逐字节拷贝。
- 注 意:内置类型成员变量是直接赋值的,而自定义类型成员变量需要调用对应类的赋值运算符 重载完成赋值。
既然如此,我们是不是可以理解为上述的那个日期类的赋值就不需要我们自己去写相应的程序,编译器自动生成的是不是就可以帮我们完成任务。那当我们屏蔽时,会不会正常运行呢?结果如下:
可以是可以,但是这里的问题是不是就跟上面讲到的拷贝构造一样了,一样的,这里就不讲了。
接下来,我们在理解以下代码是什么意思:
Date d5 = d1;// 拷贝构造 Date d6(d1);// 拷贝构造
- 如果我没有给出答案,大家会怎样理解上述代码呢?你认为它是拷贝构造还是赋值重载呀!!那就有人会问,这里不是有个【=】赋值符号,然而它却是拷贝构造呢?道理其实很简答,因为赋值重载是已经定义出来的对象。已经实例化好了,然而这里的【d5】没有实例化出来,只是用一个已经存在的实例化对象去初始化另外一个对象而已。
6. const成员函数
在类中,如果你不希望某些数据被修改,可以使用
const
关键字加以限定。const 可以用来修饰成员变量和成员函数。
const成员变量
- 【const 】成员变量的用法和普通 【const 】变量的用法相似,只需要在声明时加上 【const 】关键字。初始化 【const 】成员变量只有一种方法,就是通过构造函数的初始化列表,这点在前面已经讲到了。
const成员函数(常成员函数)
【const 】成员函数可以使用类中的所有成员变量,但是不能修改它们的值,这种措施主要还是为了保护数据而设置的。【const 】成员函数也称为常成员函数。
还是通过代码来进行直观的举例说明,例如当我们运行下列代码时,程序时可以正常运行的,这个大家学到这了应该不陌生了:
class Date { public: Date(int year, int month, int day) { _year = year; _month = month; _day = day; } void Print() { cout << "Print()" << endl; cout << _year << "年" << _month << "月" << _day << "日" << endl; } private: int _year; // 年 int _month; // 月 int _day; // 日 }; int main() { Date d1(2023, 3, 17); d1.Print(); return 0; }
然而当我们这样写的时候呢?即加入【const】时:
const Date d2(2008, 1, 13); d2.Print();
此时当我们再去编译的时候,我们发现程序就会出现报错的情况:
还是浅浅的分析一下:
- 其实是因为这里存在了一个权限放大的问题,即从可读 到 可读可写,【d2】是被【const】修饰的,说明对象本身不可被修改相当于【const Date*】的指针形式,但类成员函数中的【this】指针又是【Date* const this】,【const】修饰的是该地址指向的内容,即对象【d2】不能被修改。因此,当传给【this】,【this】可以修改其指向的内容即对象【d2】,因此权限放大了,所以发生了报错。 权限放大跟之前讲到的一个情况类似的
问题分析,如何解决呢?可以看到如果不想让权限放大,我们必须在【*】的前面加上【const 】由于【this】是隐形的,所以编译器规定在函数括号后面加【const】来表示此对象不可被修改。即如下表示方法:
void Print()const { cout << "Print()" << endl; cout << _year << "年" << _month << "月" << _day << "日" << endl; }
在当我们去运行代码时,就不会出现报错的情况了。
这里回答几个小问题:
1. const对象可以调用非const成员函数吗?
- 不允许,【const】成员函数调用非【const】成员函数,调用该【const】成员函数的对象已经被设置为【const】类型,只可以访问但是不能进行修改,在用该const成员函数访问其他非【const】成员函数可能会修改,因此【const】成员函数不能调用非const成员函数。
2. 非const对象可以调用const成员函数吗?
- 可以,当一个类只有const成员函数的时候,非const对象也可以调用const成员函数
3. const成员函数内可以调用其它的非const成员函数吗?
- 不可以,若你把一个函数声明为const类型函数,那么就说明这个函数是只读的,不可修改,而非const成员函数是可读可写的。
4. 非const成员函数内可以调用其它的const成员函数吗?
- 可以,外层函数类型【Date* const】:是可读可写的;
- 而内层函数类型是【const Date* const】:只读外层可以修改也可以不修改,到底是否要修改,视情况而定。
最后再来区分一下 const 的位置:
- 函数开头的 const 用来修饰函数的返回值,表示返回值是 const 类型,也就是不能被修改,
- 函数头部的结尾加上 const 表示常成员函数,这种函数只能读取成员变量的值,而不能修改成员变量的值。
总结:
到底要不要使用【const】去修饰成员函数,就看你函数中的变量需不需被修改,如果不希望被修改,则加上即可。
7. 取地址及const取地址操作符重载
取地址成员函数也是''类的六大默认成员函数''之一。其分为两种,普通取地址操作符、【const】取地址操作符。
这两个默认成员函数一般不用重新定义 ,编译器默认会生成,用编译器默认生成的取地址的重载即可
class Date { public: Date* operator&() { return this; } const Date* operator&()const { return this; } private: int _year; int _month; int _day; }; int main() { Date d1; cout << &d1 << endl; return 0; }
小结:
- 这两个运算符一般不需要重载,使用编译器生成的默认取地址的重载即可,只有特殊情况,才需 要重载,比如想让别人获取到指定的内容!
总结:
- 本期主要介绍的是【C++】中默认六大成员函数,大家对前四个一定要认真的学习,后两个只需知道,懂即可。
最后,如果本文对你有帮助的话,记得点赞三连哟!!!