【C++】快速学习类和对象,看这一篇就够了
本篇博客是学校大一下C++期末考察的要求,包含了C++中类和对象的大部分内容,适合学习类和对象的你观看。最后还有两个小项目,综合了绝大部分类和对象的知识,很适合练手+深化学习。
为了避免被同校copy,本篇博客只在我的HEXO个人博客上发布
第一章 绪论
1.1 面向对象程序特点与目标
面向对象有下面几个特征:
- 类与对象:把现实世界中的某一类东西,提取出来,用代码表示;
- 封装性:把过程和数据封装到一个包裹里面,对数据的访问只能通过公开权限的函数接口来进行操作;
- 继承性:一种联结类的层次模型,类似树状结构;
- 多态性:允许不同类的对象对同一消息做出响应,用不同的方法来解决一个问题。
其中 封装、继承、多态 被称为面向对象的三大特性
通过这些特征,我们可以看得到,其实面向对象的程序也是在C语言的面向过程的基础上得来的。其目的也是为了更好的服务某一个具体的问题,并通过这个问题衍生出不同的解题方法,并用代码来表示解决一个问题对象的过程。
1.2 面向对象程序知识架构
第二章 面向程序对象关键支撑技术
2.1 类和对象
类和对象与命名空间类似,都是存在一种封装。不同的是,类是对C语言struct结构体类型的拓。除了变量以外,类里面还可以定义成员函数,并设置它们的权限,实现了对一个接口的完整封装。
2.1.1 构造函数
众所周周知,当我们写C语言的顺序表、链表等代码的时候,一般都会写一个Init
函数来初始化内容。
1 | void Init() |
但是这样有一个缺点,就是不够智能,需要我们自己来调用它进行初始化。
于是C++就整出来了一个构造函数来解决这个问题
特性
构造函数:名字和类名相同,创建类对象的时候编译器会自动调用,初始化类中成员变量,使其有一个合适的初始值。构造函数在对象的生命周期中只调用一次
构造函数有下面几个特性:
- 函数名和类名相同
- 无返回值
- 构造函数可以重载
- 对象实例化的时候,编译器会自动调用对应的构造函数
- 如果你自己不写构造函数,编译器会自己创建一个默认的构造函数
2.1.1.1基本使用
下面用一个队列来演示一下构造函数
1 | class Queue{ |
可以看到,在创建对象q1的时候,编译器就自动调用了类中的构造函数,帮我们初始化了这个队列
除了上面这种最基本的无参构造函数以外,一般写构造函数的时候,我们都会带一个有缺省值的参数,这样可以更好地灵活使用这个队列
1 | Queue(int Capacity=4) |
调用这种构造函数也更加灵活,我们可以根据数据类型的长度,来创建不同容量的队列,避免多次realloc
造成的内存碎片
1 | Queue q1;//调用无参的构造函数 |
多种构造函数是可以同时存在的,不过!它们需要满足函数重载的基本要求
当你调用一个无参的函数,和一个全缺省的函数的时候,编译器会懵逼!
1 | Queue(); |
正确的重载应该是下面的情况
1 | Queue(); |
编译器在创建对象的时候,就会智能选择这两个构造函数其中之一进行调用。但是同一个对象只会调用一个构造函数。
除了在构造函数内部初始化参数,我们还可以在初始化列表处进行操作
1 | Queue(int Capacity=4) |
2.1.1.2编译器默认生成的构造函数
上面提到过,如果我们不写构造函数,编译器会自己生成一个。
但测试过以后,你会发现,这个默认生成的构造函数,好像啥事都没有干——或者说,它把_a _b _c
都初始化成了随机值!
实际上,编译器默认生成的构造函数是不会处理内置类型的
- 内置类型:int、char、float、double……
- 外置类型:自定义类型(其他的类)
在处理的时候,编译器忽略内置类型;外置类型会调用它的构造函数
1 | class Date{ |
可以看到,编译器调用了自己的构造函数的同时,还调用了外置类型Queue
的构造函数,搞定了它的初始化
如果我们去掉Date的构造函数,就能看到下面的情况。Queue
成功初始化,但是内置类型的年月日都是随机值
一般情况下一个C++类都需要自己写构造函数,下面这两个情况除外
- 类里面的成员都是自定义类型成员(且有自己的构造函数)
- 如果还有内置类型成员,声明时给了缺省值
注:只有类在声明变量的时候才可以给缺省值
1 | //下面的情况就不需要写 |
2.1.1.3初始化列表
除了上面的方式之外,还有一种构造函数的使用方式为初始化列表
1 | Date(int year=2022,int month=2,int day=30) |
- 每个成员变量只能在初始化列表中出现一次
- 类中包含以下成员必须在初始化列表中进行初始化
- 引用
- const成员
- 自定义类型成员
一般情况下,建议使用初始化列表进行初始化。因为对于自定义类型的成员变量,初始化列表的优先级是高于{ }
里面的内容的。
这里还有非常重要的一点!
成员变量在类中声明的顺序就是初始化列表的顺序,而并非初始化列表自己的顺序!
- 怎么理解呢?看下面这个代码
1 | class Date{ |
即便我们把_day
放在了初始化列表的首位,但由于它是在最后声明的。所以构造函数走初始化列表的时候,会依据声明顺序,依次初始化年、月、日。
- 这会引起什么问题?再来看看一个错误示例
1 | class Date{ |
当我们用上面这个初始化列表的时候,我们本意是想在初始化完_day
以后,将_day
的值赋给_month
。但由于_month
的声明顺序在_day
之前,所以_month(_day)
会先执行,此时的_day
尚为随机值,这就导致月份变成随机值了!
这只是一个示例,实际上肯定不会用天数初始化月数,范围不一样
最好的办法,就是声明顺序和初始化列表的顺序保持一致!
2.1.1.4 explicit关键字
构造函数不仅可以构造与初始化对象,对于单个参数的构造函数,还具有隐式类型转换的作用。
1 | class Date |
当我们调用赋值的时候,实际上编译器会先用2019构造出一个date类型对象,再调用赋值重载(这里还没有写)赋值给d1。这就是一个隐式类型转换
如果我们用explicit
修饰了这个构造函数,那么编译器将不会进行此类隐式类型转换!
2.1.2 拷贝构造函数
2.1.2.1特性和使用
拷贝构造是一个特殊的构造函数,它的参数是另外一个Date类型。在用已有的类类型对象来创建新对象的时候,由编译器自动调用
因为拷贝的时候我们不会修改d的内容,所以传的是const
。另外,我们必须进行传引用调用!
如下面的这个函数,在传参的时候,编译器会去调用Date的拷贝构造
1 | void func(Date d); |
如果你没有写拷贝构造,或者拷贝构造里面不是传引用,编译器会就递归不断创建新的对象进行值拷贝构造,程序就死循环辣
1 | //拷贝构造,如果不写的时候,编译器会默认生成一个 |
和构造、析构不同的是,编译器自己生成的拷贝构造终于有点用了
- 它会对内置类型进行按内存存储的字节序完成拷贝,这种称为值拷贝(又称浅拷贝)
- 对外置类型会调用它的构造函数
2.1.2.2深拷贝
外置类型拷贝问题
但是!如果你使用了外置类型,该类型中包含malloc的时候,编译器默认生成的构造函数就不能用辣!
因为这时候,编译器默认生成的拷贝构造会进行值拷贝,拷贝完了之后,就会出现q1和q2指向同一个空间的情况。修改q2会影响q1,free的时候多次释放同一个空间会报错,不符合我们的拷贝构造的要求
注意注意,malloc不行的原因是,数据是存在堆区里面,拷贝的时候,q2的_a
得到的是一个地址,而不是拷贝了新的数据内容。
- 如果你在类里面定义了一个
int arr[10]
数组,这时候拷贝构造就相当于memcpy
,是可以完成拷贝的工作的 - 但是malloc和new创造的空间是在堆区上的,无法直接拷贝
如何解决这个问题呢?我们需要使用深拷贝
了解new和delete
从C语言转到C++,多了new和delete关键字,它们分别对应malloc和free
1 | int main() |
深拷贝实现
在上面写道过,编译器会自动生成拷贝构造函数,完成值拷贝工作。但是队列的代码里面包含堆区的空间,需要我们正确释放。这时候就需要自己写一个拷贝构造完成深拷贝
1 | //拷贝构造 |
用下面这个队列的代码来测试深拷贝
1 |
|
深拷贝效果
先注释掉Queue
的拷贝构造函数析构函数(不然会报错)
看一看,发现在不写拷贝构造函数的时候,q2和q1的_a
指向了同一个地址
取消析构函数的注释,可以看到两次释放同一片空间,发生了报错
如果我们把写好的深拷贝构造加上,就不会出现这个问题
当你加上给_a
里面初始化一些数据,以及打印_a
数据的函数后,就可以看到,不仅q2的_a
有了自己全新的地址,其内部的值也和q1一样了
这样写出来的拷贝构造,即便把队列中的int* _a
修改为char*
或者其他类型,都能正确完成拷贝工作
2.1.3 析构函数
和构造函数相对应,析构函数是对象在出了生命周期后自动调用的函数,用来爆破对象里的成员(如进行free操作)
生命周期是离这个对象最近的{ }
括号
特性
- 析构函数名是在类名前加
~
- 无参数,无返回值
- 一个类只能有一个析构函数
- 如果你没有自己写,编译器会自动生成一个析构函数
和构造函数一样,编译器自己生成的析构函数不会处理内置类型;会调用外置类型的析构函数
基本使用
析构函数的定义和我们在外部写的Destroy
函数一样,主要执行free(delete)操作
1 |
|
假设我们在main函数里面定义了两个对象,你能说出q1和q2谁先进行析构函数的调用吗?
可以看到,先调用的是q2的析构函数
因为在底层操作中,编译器会给main函数开辟栈帧
栈遵从后进先出的原则,q2是后创建的,所以在析构的时候会先析构
2.2 静态成员
2.2.1 静态数据成员
和普通的成员变量不同,静态成员变量不属于某一个对象,而是属于整一个类
1 | class A{ |
也因为这个特性,静态成员变量是不会被sizeof计入的
什么时候会用到静态成员变量?比如当我们需要计算一个类究竟开辟了多少个对象的时候。如果使用普通成员变量,它的值是属于某一个对象的,无法完成正确的count计数。使用静态成员变量后,该变量的值不会因为定义多个对象而被重置。这时候,我们就可以在构造函数和拷贝构造函数里面,使用count++
,来实现对类开辟对象个数的统计。
1 | class STU{ |
这时候,每次对象创建都会让conunt+1
,我们可以通过下面两种方式访问来得到count
的值
- 通过指定类域来访问,
STU::count
- 通过对象来访问,
STU s1; s1.count;
如果想在类外直接访问静态成员变量,就不能用private
,必须是公有权限
2.2.2 静态成员函数
如果把静态成员变量定义为公有,那么外部的所有函数都可以通过类域或者对象来访问这个静态成员变量,这时候就不利于我们程序的封装。所以我们可以借助静态成员函数来访问私有的静态成员变量
1 |
|
静态成员函数有下面几个特点
- 类静态成员即可用类名
::
静态成员或者对象.静态成员来访问 - 静态成员函数没有隐藏的this指针,不能访问任何非静态成员
- 静态成员和类的普通成员一样,也有public、protected、private三种访问级别
- 静态成员函数可以具有返回值
2.3 常成员
2.3.1 常数据成员
有一部分数据成员,是一个定值。比如我们定义了某一个学科的类class MATH
1 | class MATH{ |
可以看到,作为一个学科,它的考试学分/绩点是固定的。这时候我们不需要在后续修改这个学分的定义,就可以将它设置为const属性,避免被其他成员误修改
1 | class MATH{ |
和普通成员和静态成员变量不同的是
- 常成员变量必须在声明的时候初始化
- 常成员变量不能在类外定义
- 常成员变量只能在构造函数的初始化列表阶段进行定义
1 | class MATH{ |
2.3.2 常成员函数
const修饰的类成员函数称之为const成员函数
,即常成员函数。const修饰类成员函数,实际修饰的是该成员函数隐含的this指针
,表明在该成员函数中不能对类的任何成员进行修改。
基本的修饰方法如下,在函数的括号后加const即可
1 | void Print()const |
实际修饰的是该函数隐含的this指针
this指针本身是Date*const
类型的,修饰后变为const Date* const
类型
1 | void Print(const Date* const this) |
2.3.2.1 实例-权限问题
这么说好像有点迷糊,我们用实例来演示一下为什么需要const修饰成员函数
1 | class Date{ |
假设我们需要在函数中调用Print
函数,在main中是可以正常调用的
1 | int main() |
但当你用一个函数来进行这个操作的时候,事情就不一样了
1 | void TEST(const Date& d) |
这时候我们进行了引用调用,因为在TEST中我们不会修改d1的内容,所以用const
进行了修饰
- 这时候TEST中的
d.Print()
函数调用,传入的是const Date*
指针,指针指向的内容不能被修改 - main中的
d1.Print();
函数调用,传入的是Date*
指针
于是就会发生权限冲突问题:
这时候如果我们在函数后面加了const,就可以避免此种权限放大问题。这样不管是main函数还是TEST函数中对Print()函数
的调用,就都可以正常打印了!
总结一下:
- const对象不可以调用非const成员函数(权限放大)
- 非const对象可以调用const成员函数(权限缩小)
- const成员函数内不可以调用其他非const成员函数(权限放大)
- 非const成员函数可以独调用其他const成员函数(权限缩小)
2.3.2.2 什么时候需要使用?
众所周周知,const修饰指针有下面两种形式
- 在
*
之前修饰,代表该指针指向对象的内容不能被修改(地址里的内容不能改) - 在
*
之后修饰,代表该指针指向的对象不能被修改(指向的地址不能改)
this指针本身就是类型名* const
类型的,它本身不能被修改。加上const之后,this指向的内容,既类里面的成员变量也不能被修改了。
知道了这一点后,我们可以合理的判断出:只要是需要修改类中成员变量的函数,就不需要在()
后面加const修饰
如果一个函数中不需要修改成员变量,就可以加const进行修饰(最好加上,告知调用者该函数中不会修改成员变量)
注意:如果你使用了声明和定义分离的写法来实现一个const成员函数,那么声明和定义的成员函数都需要加上const修饰
2.3.2.3 出错情况
这里有一点需要提醒的是,如果你对某一个函数进行了const修饰,那么这个函数里面包含的其他类里面的函数,都需要进行const修饰。不然就会报错
出现该报错的情况如下
这个情况也提醒我们,不能在const修饰的函数中,调用非const修饰的成员函数
2.4 常对象和常引用
2.4.1 常对象
可以用const来修饰一个对象,称为常对象
1 | const <classname> s1; |
在初始化设置完常对象后,该对象的内容就不能进行修改。我们可以通过这个对象来访问内部被const修饰的函数,且只能调用类的 const 成员(包括 const 成员变量和 const 成员函数)
- 常对象调用非const修饰函数会报错
- 不能修改常对象中成员变量的值
1 | const STU s1("小明", 15, 90.6); |
如果你想在定义const对象后依旧可以修改某一个成员变量的值,可以用mutable来修饰该成员变量,这样依旧可以修改这个值
1 | class STU{ |
如上面的name变量,即便定义了const对象,也可以对它进行修改
2.4.2 常引用
2.4.2.1 引用基本形式
引用的基本方式如下
1 | int a=10; |
此时的b和c都是a的别名,注意是别名!
可以用两个不同的变量名引用同一个变量,而且引用了之后不可以更改对象
- 一个变量可以有多个引用
- 指针可以更改指向的对象,引用不可以
- 引用必须在定义的时候就初始化,不可以
int& b;
比如你叫李华,有人叫你“小李”,还有人叫你“英语作文人”,这两个外号都是你的别名。指针并不是别名,指针是通过地址访问某个变量。而引用是给a变量起另外的两个名字,实际上b和c都可以当作a来使用
编译运行代码,让编译器打印出这三者的地址,可以看到它们的地址是一样的,因为它们本来就是同一个变量的不同名字。
指针变量的地址和指针变量所指向对象的地址是不同的,引用的类型必须和引用实体的类型相同,不能用int&
引用double类型
2.4.2.2 引用的权限问题
const常量
引用可以引用常量,但是必须加const
修饰
基本的思路就是“权限可以缩小,但不可以放大”。
- 在上面的代码中,a是一个可以修改的变量,但是
const int&d=a;
中的d是不能修改,只可读取a的内容。 - e是不可修改的常量,所以我们不能用
int&
来放大权限
int和double相互引用
在1.1
中有提到,我们不能用int&
来引用double
类型的变量,编译器会报错
不过我们可以用const int&
类型来引用double,此时引用就不是简单的一个别名了。
先来了解一下把double复制给int类型,这时候会产生“隐式类型转换”,h保存的是z的整数部分
在这个过程中,编译器会产生一个临时变量存放z的整数部分,然后赋值给h
- 临时变量具有“常性”,可读不可改
而当我们用const int&
类型来引用double时,实际上引用的是编译器产生的临时变量,它是一个常量,所以我们需要用const int&
来引用
1 | const int& i=z;//这里的i是临时变量的别名 |
一个非常直观的验证方法,就是打印一下,瞅瞅它们的地址是否相同。可以看到,i的值和h是相同的,因为它引用的就是那个存放了整数部分的临时变量,这个临时变量的地址和z不同
2.5 this指针及工作原理
2.5.1 特点
当你用同样的图纸建了很多个屋子后,有没有想过应该如何区分它们呢?
C++在设计这部分的时候,添加了一个this指针来解决这个问题:C++编译器给每个“非静态的成员函数“增加了一个隐藏的指针参 数,让该指针指向当前对象(函数运行时调用该函数的对象),在函数体中所有成员变量的操作,都是通过该指针去访问。只不过所有的操作对用户是透明的,即用户不需要来传递,编译器自动完成。
- this指针的类型:
类名* const
- 只能在“成员函数”的内部使用
- this指针本质上其实是一个成员函数的形参,是对象调用成员函数时,将对象地址作为实参传递给this 形参。所以对象中不存储this指针。
- this指针是成员函数第一个隐含的指针形参,一般情况由编译器通过ecx寄存器自动传递,不需要用户传递
2.5.2 显式使用this
就用下面这个函数举例
1 | void Print() |
实际上,在调用它的时候,编译器会做如下处理。因为只有这样,才能完整的区分两个不同的类。
1 | void Print(Student*const this) |
进一步看看下面这个代码,可以帮助你理解this指针
1 | bool operator==(const Date& d){ |
这是一个日期的比较函数,是操作符重载(后面会讲到)。你可以看到,这个函数我们传入了一个Date类型的引用,这是区别于this的另外一个类的对象。
如果没有this,那就很难区分两个变量的_year
,于是编译器会把它优化成下面这样,就不会存在无法区分的问题了
1 | bool operator==(Date*const this,const Date& d){ |
2.5.3 空指针问题
在程序中,访问NULL不会报错,但是解引用Null会报错
1 |
|
2.6 类间关系
2.6.1 友元关系
友元分为友元函数和友元类。友元提供了一种突破封装的方式,有时提供了便利。但是友元会增加耦合度,破坏了封装,所以友元不宜多用。
2.6.1.1 友元函数
友元函数相当于这个类的好朋友,它并不是类的成员函数,但是可以访问这个类的私有成员。友元函数没有this指针,它是定义在类外部的普通函数,不属于任何类,但需要在类的内部明,声明时需要加friend关键字。
1 | class DATE{ |
以<<和>>操作符重载为例,如果我们直接在类里面定义的话,使用这个重载的方式就会变成下面这样
1 | DATE d1; |
因为对于双目操作符,重载的时候,编译器是将第一个参数作为操作符的左值的。在类里面定义时,第一个操作数是隐含的this指针。即必须用对象名作为左操作数来进行使用。这样虽然也能完成既定任务,但这个使用方式未免太过奇葩了。
定义为友元后,没有隐含的this指针,就可以使用cout<<d1
这种正常的方式来调用这个操作符重载了
- 一个函数可以是多个类的友元函数
- 友元函数不能用const修饰
- 友元函数可以在类定义的任何地方声明,不受类访问限定符限制
- 友元函数的调用与普通函数的调用和原理相同
2.6.1.2 友元类
友元类的所有成员函数都可以是另一个类的友元函数,都可以访问另一个类中的非公有成员。
- 友元关系是单向的,不具有交换性:比如有A类和B类,在A类中声明B类为其友元类,那么可以在B类中直接访问A类的私有成员变量,但想在A类中访问B类中私有的成员变量则不行。
- 友元关系不能传递:如果B是A的友元,C是B的友元,则不能说明C时A的友元。
1 | class B;//前置声明 |
2.6.2 整体部分关系
2.6.2.1 内嵌对象
内嵌对象:在一个类中定义另外一个类的对象
在一些应用场景中,我们会需要在一个类里面定义另外一个类的对象。如下面这道OJ题中,我们需要用两个队列的类来实现这里的MyStack
。
1 | class Queue{ |
对于内部对象Queue,需要注意的有以下几点:
- 内部对象属于外置类型,在对象实例化的时候,编译器会去调用Queue的构造函数
- 不能在MyStack类中直接对Queue进行初始化
如果在Queue中定义了有参的构造函数,需要在MyStack构造函数中的初始化列表处进行初始化操作
2.6.2.2 组合聚合问题
当一个类的对象拥有另一个类的对象时,就会发生类聚合:
- 母类A的每一个对象中都会包含一个或多个B类的对象
- 类组合是一种特殊的聚合形式,其中拥有者类控制被拥有者类对象的生命周期。
依旧以上面的MyStack代码为例,当我们在里面定义了Queue对象q1和q2后,它们的生命周期和MyStack类对象的生命周期同步。MyStack类会先构造,然后构造q1和q2;q1和q2分别析构后,才会析构MyStack的类对象。
2.6.2.3 内部类
当定义内部类时,内部类默认为外部类的友元,可以直接访问外部类的非公有成员。但是内部类是一个独立的类,外部类不能访问内部类的非私有成员,也不能通过外部类的对象来访问内部类的成员。
1 | class A{ |
2.6.3 继承派生关系
2.6.3.1 基本用法
继承和派生是父与子的关系,其中子类拥有父类成员的同时,还会拥有自己的成员
- 继承是一个特殊的语法,用于多个类有公共部分的时候
- 父类:基类
- 子类:派生类
1 | //举例:网站的公共部分 |
在上面的情况中,ART和LINK类中都有网站的公共部分,这时候就出现了代码的重复。继承的出现就是用于解决这个问题的
1 | //下面使用继承的方式来写,WEB类是网站的公共部分 |
测试可以发现,ART和LINK作为派生类,在继承了基类WEB的成员的基础上,还拥有了它们独特的单独成员
同一个类可以同时继承多个基类
1 | class C : public A,public B{ |
2.6.3.2 权限问题
继承有3中类型:public、private、protected。这里会显示出类中protected权限和private权限的区别
1 | class A{ |
当我们分别用上面三种方式对类A进行继承的时候,得到的结果是不同的
- 用什么继承方式,派生类中继承的成员就变成什么类型
- 不管用什么继承方式,都无法访问基类中的私有成员
2.6.3.3 同名问题
当基类和派生类中出现同名成员函数或者同名成员变量时,会出现冲突。这时候编译器会做一定的处理:直接访问变量名和函数名的时候,优先访问派生类自己的成员
1 | //继承同名成员的处理 |
如果是静态成员,访问方法就有所变化
1 | //访问同名的静态成员 |
2.6.3.4 虚继承
有的时候,继承会出现下面这种情况:一个子类继承了两个基类,而这两个基类又同时是一个基类的派生类
这时候,D里面就会有两份A的内容,相当于两份公共部分。这是我们不想看到的,因为会造成空间浪费。而且直接访问的时候,编译器会报错“对变量X的访问不明确”
和前面说道的同名问题一样,我们可以指定作用域来访问特定的变量,但是这样是治标不治本的方法,并没有解决空间浪费的问题。
1 | //解决方法1(治表不治本) |
这就需要我们使用虚继承来操作:给B和C对A的继承加上virtural
关键字
1 | class CPU { |
这时候直接访问变量就不会报错了。因为这时候,B和C中的该变量指向了同一个地址,修改操作会同步。
2.7 多态性
- 静态多态:运算符重载
- 动态多态:派生类和虚函数组成的多态
2.7.1 运算符重载
2.7.1.1定义
在讲解赋值运算符重载之前,我们可以来认识一下完整的运算符重载:C++为了增强代码的可读性引入了运算符重载,运算符重载是具有特殊函数名的函数,也具有其返回值类型,函数名字以及参数列表,其返回值类型与参数列表与普通的函数类似。
函数名为:关键字 operator
运算符,如operator=
函数原型:返回值类型 operator操作符(参数列表),如Date operator=();
下面有几点注意:
- 重载操作符必须有一个自定义类型的操作数(即操作符重载对内置类型无效)
- 不能通过其他符号来创建新的操作符
- 对于类类型的操作符重载,形参比操作数少一个传参(因为有一个默认的形参this指针)
- 这5个操作符是不能重载的:
.*
、::
、sizeof
、? :
、.
2.7.1.2基本使用
以下是在全局定义的操作符重载,用于判断日期是否相等
1 | bool operator==(const Date& d1, const Date& d2) |
当我们在main函数中使用d1==d2
的时候,编译器就会自动调用该操作符重载
当然,你也可以自己来传参使用,如if(operator==(d1,d2))
但是这样非常不方便,和调用一个而普通函数没啥区别,压根算不上操作符重载。所以我们一般是在类里面定义操作符重载的
当我们把它放入类Date
中间,就需要修改成下面这样
1 | bool operator==(const Date& d2) |
编译器在调用的时候,会优化成下面这样
1 | bool operator==(Date* this, const Date& d2) |
而在main里面使用的时候,这个重载后的操作符和原本的使用方法完全相同
1 | Date d1(2022,6,1) |
2.7.1.3赋值运算符重载
因为每一个类都有不同的成员,编译器不可能智能的进行赋值操作。这时候就需要我们自己写一个赋值运算符重载来进行赋值操作了
以日期类为例,赋值操作其实就是把内置类型成员一一赋值即可
1 | Date& operator=(const Date& d){ |
编写赋值重载代码的时候,需要注意下面几点:
- 返回值和参数类型(注意要引用传参,不然会调用拷贝构造)
- 检测是否自己给自己赋值(避免浪费时间)
- 因为返回的是
*this
,出了函数后没有销毁,所以可以用传引用返回 - 一个类如果没有显式定义赋值运算符重载,编译器也会自己生成一个,完成对象按字节序的值拷贝。
如果类中有自定义类型,编译器会默认调用它的赋值运算符重载
2.7.1.4拷贝构造和赋值重载的调用问题
当赋值操作符和拷贝构造同时存在的时候,什么时候会调用赋值,什么时候会调用拷贝构造呢?
在这两个函数中添加cout
进行打印提示,可以看到:
- 如果对象在之前已经存在,就会调用赋值重载
- 如果是一个全新的变量在定义的时候初始化,就调用的是拷贝构造
2.7.2 虚函数
2.7.2.1 基本使用以及动态多态
虚函数,并不代表这个函数是虚无的。而表示这个函数在一定情况下会被替换(就好比继承中的虚继承问题)。要实现动态多态,就需要借助虚函数来实现。以下面这个动物说话的代码为例
1 |
|
当基类Animal中的Talk函数没有用virtual修饰时,不管给这个函数传参什么类的对象,它都会调用Animal自己的Talk函数
当我们用虚函数进行修饰后,就会调用派生类CAT和DOG的Talk函数,这就实现了一个简单的动态多态。
对于虚函数,有几点需要注意:
- 当基类的指针或引用指向派生类的对象时,就会触发动态多态,派生类中的同名函数会覆写基类中的虚函数
- 不能定义静态虚函数——因为静态函数是属于整个类的,而不是属于某一个对象
- 不能定义虚构造函数——总不能用派生类的构造来覆写基类的构造吧?
- 析构函数可以是虚函数
2.7.2.2 虚析构函数
有的时候,我们需要析构一个对象时,往往会给基类的析构函数加上virtual修饰,这样只要传派生类的对象给基类的指针/引用,就可以直接调用派生类对应的析构函数,完成不同的析构操作。而不是都呆呆的调用基类的析构函数——那样就会产生内存泄漏
1 |
|
运行之后,可以看到,我们成功通过父类的指针,调用了子类的析构函数。子类的析构函数中本身就会自动调用父类析构函数。这样一来,子类和父类都被成功析构,不会出现内存泄漏
1 | $ ./test |
如果去掉父类析构函数的virtual关键字,则只析构父类,此时就出现了子类中的int* _a1;
没有被析构造成的内存泄漏。
1 | $ ./test |
2.7.3 纯虚函数
在虚函数的基础上,C++定义了纯虚函数:有些时候,在基类里面定义某一个函数是没有意义的,这时候我们可以把它定义为纯虚函数,具体的实现让派生类去同名覆写。
纯虚函数的基本形式如下
1 | //virtual 函数返回类型 函数名()=0; |
派生类中必须重写基类的纯虚函数,否则该类也是抽象类
1 | class A { |
当我们在派生类中覆写了该函数后,即可实例化对象并调用该函数
和虚函数一样,使用基类的引用或指针来接收派生类的对象,即可调用对应的函数
2.7.4 抽象类
包含纯虚函数的类就是抽象类,抽象类有下面几个特点:
- 抽象类无法实例化对象
- 抽象类的派生类必须重写基类的纯虚函数,不然派生类也是抽象类
- 如果在基类中定义的纯虚函数是const修饰的,则派生类中对应的函数也需要用const修饰
第三章 面向对象应用
3.1 矩阵类设计及应用
矩阵类要求:设计一个矩阵类,要求能够根据用户需求构建row行、column列的矩阵,并灵活接受反馈矩阵元素信息(如:某行、某列、某行某列元素)。实现矩阵的相关运算,包括矩阵加(+)、矩阵乘(*)、矩阵输出(<<)、矩阵赋值(=)、获取矩阵指定位置元素值([])
设计该程序的时候,需要注意下面几点:
- 重载矩阵类必须使用二维数组,不能用一维数组+公式判断的方法(因为这样无法重载[]操作符)
- 重载矩阵加减和相乘的运算符时候需要注意矩阵运算的规则(第一行x第一列=第一个)
- 对于+和-的重载不应该修改原本的矩阵,应该创建临时对象tmp后,修改tmp的值并返回
1 | int a1=1,a2=2; |
如果需要修改原本的矩阵,应该重载的操作符是+=和-=
- 对于矩阵输出<<的重载,必须使用友元函数,否则使用会变成对象<<cout,不是正常使用的方法
- 对于获取矩阵指定位置元素值[]的重载,应该返回int*类型。在最开始设计的时候我错误写成了int类型,无法正确地连续使用两个[]
1 | //重载[]操作符 |
矩阵类完整代码实现见附录。
3.2 银行账户管理系统设计及应用
银行账号管理系统要求:管理不同用户在银行的金融资产,每个用户可以拥有多种银行账户(如:定期储蓄、活期储蓄、信息卡、电子账户、贷款账户等)。账户包括账号、余额、利率等基本信息,用户可以进行账户信息查询、存款、取款、结算利息等操作。银行需统计所有账户的总金额、验证银行系统收支平衡,并能够及时预警反馈。设计Account抽象类作为所有银行账户顶层祖先,根据实际应用需求合理设置派生层次及相应子类。结合银行利息结算、用户贷款申请等实际应用需求,适当添加辅助类协同操作。合理定义虚基类、虚函数、纯虚函数、抽象类完成银行账号管理系统的稳定可靠运行。
3.2.1 基本思路
以下是我设计该管理系统的思路:
- 设计了
Account
类的主框架,作为后续类的基类 - 通过多态,实现了活期储蓄、定期储蓄类、信用卡类、贷款账户的功能
- 实现了简易菜单,通过
switch case
语句和do while
语句实现多次调用不同函数接口的操作 - 与日期类结合,实时计算天数差距并结算利息
在设计该菜单的过程中,我发现了很多问题需要注意,这些问题也加深了我对编程知识点的理解。
完整代码实现见附录。
3.2.2 设计系统时遇到的问题
在设计贷款账户的时候,容易出现double浮点数存放精度的问题。用户贷款额度和余额直接若直接判断相等,很难得到正确结果(因为浮点数后面会跟着很多没有打印出来的小数)
这就会出现,即便你根据程序接口中“查看待还金额”来得到自己的待还金额,并执行还款操作后,还是会有一部分小数位的数据并没有完整还完,这一部分的处理是非常困难的。为了避免用户永远都还不完自己的贷款,我设置了double的修约规则,即当用户余额和贷款/透支额度的差值小于0.2时,不再处理后续的小数位
1 | if (_Max - _money < 0.2)//浮点数精度问题 |
同时在余额计算中进行取零修约,保证“剩余待还”打印值和“查看待还金额”函数的打印值同步。用这种方式间接解决了精度问题。
1 | //待还金额为透支额度+利息 |
在设计销户接口时,为了避免用户在销户后仍然能操作该账户,不能只是简单的使用break跳出单层循环,而是需要用return直接终止该程序。
1 | case 8: |
3.2.3 一些缺陷
1.文件操作
该银行管理类还可以添加文件操作,来保存用户的信息。原先想法是在Account类中定义static全局变量进行count++
,以此得出所有派生类构造对象的总和。再利用for循环进行读取文件操作,这样就能在下一次打开程序的时候,通过用户的账户来定位用户的某一个特定账户对象,进行后续的操作。
但是Account类作为抽象类是无法实例化对象的,如果用各个派生类来进行文件管理操作,该程序就会变得很臃肿。且由于本人能力问题,没能设计出循环读取文件内容,并进行定位下一个对象位置的操作,故在最终的设计中没有实现文件操作。
2.时间问题
为了代码测试需要,每一次操作都需要用户手动输入日期。在实际应用中,这项工作应该由银行用户终端自动化承担。可以设计读取预定义宏__TIME__
来获取每一次操作的时间,从而实现和现实中的时间对照,去除每一次都需要手动输入代码的繁琐
第四章 面向对象程序设计学习总结
学习代码需要有一个持之以恒的心,再学习知识点的基础上要同时坚持写代码的练习。我在Gitee码云上创建了自己的学习仓库,坚持每天托管代码,作为自己学习编程打卡的一个记录
和其他科目不同,CPP的学习是不能只停留在书籍和纸笔上的,只有你上手自己敲代码了,才能认识到一些光是听讲和看书学习不到的知识。比如一些程序出现bug之后的VS调试技巧,都是我在练习中学会的。
同时还需要学会利用工具,如在cplusplus网站上查找函数的定义,根据给出的代码示例尝试自己使用这个函数,并做到能在后面的程序设计中活学活用。
个人认为,不管学习的编程语言是什么,只有综合以上几点,才能真正学好编程。
附录
- 矩阵类和银行管理系统完整代码👉【传送门】
- 部分资料参考C语言中文网👉http://c.biancheng.net/