学习总结
- 多态:无论发送消息的对象属于什么类,它们均能发送具有同一形式的消息,对消息的处理方式可能随接收消息的对象而变。C++通过虚函数实现多态,即在基类中用
virtual
声明,并在派生类中定义。虚函数:“虚假”的函数,父类引用子类对象,子类成员函数重写父类方法(函数)。 - 虚函数一旦被定义后,在同一类族的类中,所有与该虚函数具有相同参数和返回值类型的同名函数都将自动成为虚函数(无论是否加上关键字
virtual
)。如基类的函数是虚函数时,则所有子类、子类中的子类的对应函数都是虚拟的。 - 对于抽象类来说,它无法实例化对象,而对于抽象类的子类来说,只有把抽象类中的纯虚函数全部实现之后,那么这个子类才可以实例化对象。
- 备注:必须掌握的有:6.1-6.6;后面6.7(接口类)、6.8(RTTI)、6.9(异常处理)面试中很少考,可以晚点再完成。
文章目录
6.1 多态概述
当发出一条命令时,不同对象接收到相同的命令后,所作出的动作不同。
严谨说:相同对象收到不同消息 or 不同对象收到相同消息时产生不同的动作。
6.2 虚函数机器实现原理
6.2.1 静态多态(早绑定)
多态分为静态多态(又称为早绑定)和动态多态。现在定义的一个矩形类Rect
中,定义2个成员函数(名字相同),但是2个函数的参数不同,他们是互为重载的函数。我们实例化矩形对象rect
后,可通过该对象分别调用这两个函数。
静态绑定:根据传参的个数,计算机在编译时自动地调用相应的函数,即在运行前的编译阶段,函数的程序(调用哪个)已经确定下来了。即很早就将函数编译进去,这种情况叫做早绑定(即静态多态)。
6.2.2 动态多态(晚绑定)
1.分别调用圆形和矩阵的求面积函数
这种情况用到动态多态:下达相同命令给不同对象,却做着不同的操作。如圆形类和矩形类(不同对象)分别由自己的计算机面积的方法,但方法不同。
动态多态的前提:以封装和继承为基础。如在一个形状类Shape
类中,定义了一个成员函数(计算面积):
再定义2个类,Circle
圆类和Rect
矩形类都是以public
方式继承shape
类。
先是圆形类:
再是矩形类:
在main
函数中使用时,可以使用父类的指针shape1
指向其中的一个子类对象Circle
,
并且用另一个父类指针shape2
指向一个矩形的对象Rect
。即这两个子类对象都被它的父类指针所指向,并通过这两个指针分别调用求面积函数calcArea
:
输出的结果是2个calcArea()
,即不是我们想要的结果,这里用到的都是父类的计算面积函数。如果想要分别调用子类的calcArea
,则需要用到虚函数,即virtual
关键字修饰类的成员函数。具体而言就是在想要实现多态的成员函数前加关键字virtual
,使其成为成员虚拟函数:
然后在定义子类时,给计算面积的成员函数前也要加上virtual
(其实是这里非必需加,如果我们没加则系统会自动加上,建议加上),使其成为虚函数:
2.虚函数:
采用virtual
修饰类的成员函数。在父类shape
中定义成员函数时,就把我们想要实现多态的成员函数前加virtual
关键字(使其成为成员虚函数):
虚函数——“虚假”的函数,父类引用子类对象,子类成员函数重写父类方法(函数)。
写法:即将基类的calcArea
函数前加virtual
,和子类Circle和子类Rect的calcArea
函数声明前面加上virtual
。
输出结果不再是2个calcArea()
(父类的求面积函数),而是分别调用了2个子类中重写基类的calcArea()
函数,分别计算出圆和矩形的面积。
虚函数——“虚假”的函数,父类引用子类对象,子类成员函数重写父类方法。
6.3 虚函数代码实践
/* 动态多态、虚函数 */
1. 定义Shape类,成员函数:calcArea()
,构造函数,析构函数
2. 定义Rect类,成员函数:calcArea()
,构造函数,析构函数
数据成员:m_dWidth
,m_dHeight
3. 定义Circle类,成员函数:calcArea()
,构造函数,析构函数
数据成员:m_dR
在主调程序中定义2个Shape类的指针(一个指向子类Rect,一个指向子类Circle)。
用两个指针分别调用计算面积的函数——看调用的父类的还是子类的面积函数;
最后将2个指针对象销毁——看销毁父类指针时,能否销毁子类的对象。
1)头文件
Shape.h
头文件:
Circle.h
头文件:
Rect.h
头文件:
2)cpp文件
Shape.cpp
源程序:形状基类。
Circle.cpp
源程序:继承Shape
类。
Rect.cpp
源程序:继承Shape
类的矩形类。
主调程序demo.cpp
:
分析上面的程序结果:
(1)前四行结果:
实例化一个Rect对象
——会先执行父类的构造函数,再执行本身的构造函数。Circle实例化同理。
(2)中间两行
没有做到我们所想的调用Rect和Circle中的calcArea()函数
——解决方案:在3个头文件中的calcArea()
函数前加上virtual
,
注意子类的calcArea函数前的virtual
不是一定要加上的,不加上系统也会自动加上,但是最好加上。
都加了virtual
后的结果:
(3)最后两行
销毁Shape1和Shape2时,只执行父类的析构函数,并没有执行2个子类的析构函数。
6.4 虚析构函数
(1)内存泄漏问题
动态多态中存在【内存泄漏】的问题,通过下面的栗子我们来讨论如何解决:
定义Shape
和Circle
类,并且Circle
类以public
方式继承Shape
类,但是这个Circle
类和刚才的动态多态的栗子略有不同:
最大的不同:多定义一个数据成员—一个坐标类Coordinate
的指针,代表这个圆的圆心坐标。我们会在它的构造函数当中去实例化一个坐标对象,并且把这个坐标对象作为圆心坐标,并且使用m_pCenter
去指向这个Coordinate
对象。指向之后,我们会在析构函数执行的时候把这个对象再释放掉,这样就能够保证在实例化Circle后去使用它,使用完成之后还能将指针在堆中的内存释放掉,从而保证内存步泄漏。可是在多态的使用当中,我们可以看一下:如果用父类指针去指向子类对象,并且通过父类指针去操作子类对象当中相应的虚函数,这个时候是没有问题的(这个前面已经讲过)。
后面的部分是有问题的,当我们使用delete去销毁对象,并且是借助于父类的指针想去销毁子类对象的时候,这个时候就出现问题了,为什么呢?在前面继承篇中学习到:如果delete
后边跟的是一个父类的指针,那么它只会执行父类的析构函数;如果跟着的是一个子类的指针,那么它既会执行子类的析构函数,也会执行父类的析构函数。可见,如果我们delete
后面跟的是父类的指针(如上面的*shape1
),就只执行了父类的析构函数,又怎么能执行到Circle的析构函数呢?执行不到Circle的析构函数,那岂不是就造成了内存的泄漏了吗?因为我们在实例化Circle
的对象的时候,是从堆中去申请的一段内存,并且把这段内存作为它的圆心坐标。
之前的学习说到这里时没说到内存泄漏,是有原因的:Circle这个类的时候,在Circle这个类当中一是没有指针型的数据成员(*m_pCenter
),二是没有在构造函数当中去申请内存。因为这两个原因,在这种情况下,它的构造函数当中其实什么也不做,既然什么也不做,那么我们执行它和不执行它区别就不大了,所以当时不会产生内存泄漏问题。
为了解决内存泄漏,我们引入【虚析构函数】,即用关键字virtual
去修饰析构函数。
当我们用这种方式修饰了Shape的析构函数之后,那么Shape的子类,在这里就是Circle类中的析构函数前面既可以写上关键字
virtual
,也可以不写关键字virtual
,如果不写,系统在编译时会自动加上。不过这里还是推荐大家写上,这样,将来再有类继承Circler的时候,也就知道Circle的析构函数是带有virtual
的,那么它的子类的析构函数也就应该带有virtual
了。
当我们定义完成了虚析构函数之后呢我们在main
函数当中,就可以使用之前的方式进行相应的操作了。这个时候我们再使用delete
,如果此时在delete
后面跟上父类指针的时候,那么父类指针指向的是哪个对象,那么这个对象的析构函数就会先可以执行,然后再执行它父类的析构函数,于是可以保证内存不被泄漏。
(2)virtual的使用限制
问题:关键字virtual
既可以修饰普通的成员函数,也可以修饰析构函数,那它是不是就没有什么限制呢?
Virtual的使用限制
-
Virtual
不能修饰普通的函数,必须是某个类的成员函数 -
Virtual
不能修饰静态成员函数
如果用virtual
去修饰一个静态成员函数的话,它不属于任何一个对象,它是和类是同生共死的,所以当用virtual
去修饰的时候,也会造成编译错误
-
Virtual
不能修饰内联函数
如果用virtual
去修饰内联函数,那么,对于计算机来说,它会忽略掉inline
关键字,而使它变成一个纯粹的虚函数。
-
Virtual
不能修饰构造函数
6.5 虚函数和虚析构函数实现原理
6.3介绍了虚函数的栗子,现在来讨论虚函数和虚析构函数的实现原理。
(1)函数指针
如果一个指针指向一个对象,则叫对象指针;而指针也可以指向函数,函数的本质即一段卸载内存中的二进制代码,即通过指针指向这段代码的开头。计算机则会从开头一直执行下去,直到函数结尾,并且通过相关指令返回结果。
如果有以下5个函数指针,其函数地址如下:
比如我们可以通过Fun3_Ptr
拿到Fun3()
这个函数的入口,计算机从该函数的二进制代码执行到结束(其余函数一样的),其实和普通指针一样,也是由四个基本的内存单元组成,存储着一个内存的地址,这个内存地址就是这个函数的首地址。
定义了一个Shape
类,在这个Shape
类中,还定义了一个虚函数和一个数据成员。然后,又定义了一个Circle子类(public继承自Shape
)。注意,在这里并没有给Circle定义一个计算面积的虚函数,也就是说,Circle这个子类所使用的也应该是Shape
的虚函数来计算面积。此时的虚函数如何来实现呢?
当我们去实例化一个Shape的对象的时候,在这个Shape
对象当中,除了数据成员m_iEdge
(表示边数)之外,还有另外一个数据成员—–虚函数表指针,它也是一个指针,占有4个基本内存单元。虚函数表指针就指向一个虚函数表。这个虚函数表会与Shape
类的定义同时出现。在计算机中,虚函数表也是占有一定的内存空间的,这里假设虚函数表的起始位置是0xCCFF
,那么这个虚函数表指针的值vftable_ptr
就是0xCCFF
。父类的虚函数表只有一个。
通过父类实例化出来的所有的对象的虚函数表指针的值都是0xCCFF
,以确保它的每一个对象的虚函数表指针都指向自己的虚函数表。在父类Shape
的虚函数表当中,肯定定义了一个这样的函数指针,这个函数指针就是计算面积(calcArea()
)这个函数的入口地址。这里假设计算面积函数的入口地址是0x3355
,那么虚函数表中的函数指针(calcArea_ptr
)的值就是0x3355
。
调用的时候就可以先找到虚函数表指针,再通过虚函数指针找到虚函数表(装有很多虚函数的入口地址),再通过位置的偏移找到相应的虚函数的入口地址,从而最终找到当前定义的这个虚函数—-计算面积(calcArea()
)。整个过程如下所示:
当我们去实例化Circle的时候,又会是怎样的呢?如果我们实例化一个Circle对象,因为Circle当中并没有定义虚函数,但是它却从父类Shape
当中继承了虚函数,所以我们在实例化Circle这个对象的时候,也会产生一个虚函数表。注意,这个虚函数表是Circle自己的虚函数表,它的起始地址是0x6688
。但是在Circle的虚函数表当中,它的计算面积的函数指针(calcArea_ptr
)却是一样的,都是0x3355
,这就能够保证:在Circle当中去访问父类的计算面积的函数,也能够通过虚函数表指针找到自己的虚函数表,在自己的虚函数表中找到的计算面积的函数指针也是指向父类的计算面积的函数入口的。整个过程如下所示:
如果我们在Circle中定义了计算面积的函数(如下所示):
对于Shape
这个类来说,它的情况是不变的,有自己的虚函数表,并且在实例化一个Shape
的对象之后,通过虚函数表指针指向自己的虚函数表,然后虚函数表当中有一个指向计算面积的函数,这样就Ok了。对于Circle来说,则有些变化。如下所示:
Circle的虚函数表与之前的虚函数表示一样的,但是,因为Circle此时自己已经定义了自己的计算面积的函数,所以它的虚函数表中关于计算面积的这个函数指针已经覆盖掉了父类当中的原有的指针的值。换句话说,0x6688
当中的计算面积的函数指针的值变成了0x4B2C
,而Shape当中的0xCCFF
这个虚函数表中的所记录的计算面积的函数指针的值则还是0x3355
,这两者是不一样的。
于是,我们如果用Shape
的指针去指向Circle对象,那么,它就会通过Circle对象当中的虚函数表指针找到Circle的虚函数表,通过Circle的虚函数表(偏移量也是一样的),和父类一样,就能够找到Circle的虚函数的函数入口地址,从而执行子类当中的虚函数,这个就是多态的原理。
(2)函数的覆盖与隐藏
之前还没有学习多态的时候,如果定义了父类和子类。当父类和子类出现了同名函数,那么这时就称之为函数的隐藏。
(1)如果我们没有在子类当中定义同名的虚函数,那么在子类虚函数表当中就会写上父类的相应的那个虚函数的函数入口地址。
(2)如果,在子类当中也定义了同名的虚函数,那么在子类的虚函数表当中就会把原来的父类的虚函数的函数入口地址覆盖一下,覆盖成子类的虚函数的函数地址,那么这种情况就称之为函数的覆盖。
(3)虚析构函数的实现原理
虚析构函数的特点:当我们在父类当中,通过virtual
修饰析构函数之后,我们通过父类的指针再去指向子类的对象,然后通过delete
掉父类指针就可以释放掉子类的对象。
理论前提:执行完子类的析构函数就会执行父类的析构函数。
如果有了父类的指针,通过delete
的方式去释放子类的对象,那么只要能够实现通过父类的指针执行到子类的析构函数就可以实现了。
【栗子】父类指针释放子类对象的底层原理
给Shape
类多加了一个函数:虚析构函数。在Circle类当中,我们也定义了它自己的虚析构函数。如果你不写,计算机会默认给你定义一个虚析构函数的,前提是,必须在父类中必须有关键字virtual
修饰的析构函数。如果我们在main
函数当中,通过父类的指针来指向子类的对象(如下):
然后,通过delete
掉父类的指针来释放子类的对象,那么这个时候虚函数表如何来工作呢:
如果我们在父类当中定义了虚析构函数,那么,在父类当中的虚函数表中就会有一个父类的析构函数的函数指针,而在子类的虚函数表中也会产生一个子类的析构函数的函数指针,指向的是子类的析构函数。
这时,如果使用父类的指针指向子类的对象,或者说,使用Shape
的指针来指向Circle的对象,那么,通过delete
来接shape
这样一个指针的时候,我们就可以同shape
来找到子类的虚函数表指针,然后通过虚函数表指针找到虚函数表,再通过虚函数表找到子类的析构函数,从而使得子类的析构函数得以执行。子类的析构函数执行完毕之后,系统就会自动执行父类的析构函数,这个就是虚析构函数的实现原理。
(4)几个概念解释
1)什么是对象的大小?
指在类实例化的对象当中,它的数据成员所占据的内存大小,而不包括成员函数。
2)什么是对象的地址?
类实例化的对象的首地址。
3)什么是对象成员的地址?
实例化得到的对象中,每个数据成员的地址是这个对象的成员地址。
4)什么是虚函数表指针?
在具有虚函数的情况下,实例化一个对象时,该对象的第一块内存中所存储的是一个指针,即内存大小为4的函数表指针。下面的代码实践就可以通过计算对象的大小来证明虚函数表指针的存在性。
(5)虚函数表的代码实践(证明虚函数表的存在性)
证明虚函数表的存在
要求:
1. 定义Shape类, 成员函数:calcArea()
,构造函数,析构函数
2. 定义Circle类,成员函数:构造函数,析构函数
数据成员:m_iR
概念说明:
1. 对象的大小
2. 对象的地址
3. 对象成员的地址
4. 虚函数表指针
demo.cpp
源程序:
1)对象的大小
对上面的结果分析:
(1)第3行:因为在Circle
类定义中有成员变量int m_iR;
所以第3行是4是符合预期的;
(2)第1行:当Shape
类没有任何数据成员的时候,而这个类也是可以实例化的,它实例化一个对象后,那么,作为一个对象来说,它必须要标明自己的存在。C++如何来完成这样的工作呢?C++对于一个数据成员都没有的情况,用1个内存单元去标定它,也就是说,这个内存单元只标定了这个对象的存在。如果这个对象里面有数据成员,那么这个1也就不存在了(比如Circle类的情况,它不会变成5,也就是说,如果它已经有了数据成员,能够标定它的存在了,那么就不需要额外的内存来标定它的存在了)。
2)对象的地址
首先,在main
函数中定义一个指针,并且通过这个指针来指向这个对象。注意,这个指针比较奇特。这个指针是指向int
类型的指针,而指向的这个对象shape
是一个Shape
类型,这样直接指向是不可以的。所以必须使用强制类型转换,即将Shape
类型的一个地址转换成一个int
类型的地址,即:int p = (int )&shape;
这个是不得已,否则我们没有办法进行后续的操作。我们指向之后,就可以通过cout
语句将这个地址打印出来,则打印出的地址就是Shape
这个对象的地址。那么main
函数如下:
显然这个shape
对象的地址与下面的circle对象的地址不相同(因为是不同的对象):
3)对象成员的地址
作为指针p此时指向的shape
这个对象,那么这个对象此时只有一个标识符来表明这个对象的存在。对于circle来说,指针q指向这个对象之后,那么这个对象的第一个位置就应该放的是circle的数据成员m_iR
,我们来打印一下,验证一下是不是这样的。
第三行的这个100就是在我们实例化circle的时候,传入的参数100,而且这个传入的100赋值给了circle这个对象的数据成员m_iR
。这个m_iR
就处在circle这个对象地址的第一个位置。我们的指针q所指向的就是m_iR
,当然,它也就是circle这个对象的地址。我们把这个地址的值打印出来,那就正好打印出来了m_iR
。
4)虚函数表指针
刚才都是在讲虚函数前面的内容,只是对对象的理解。下面要讲的就是与虚函数相关的了。
首先,修改一下Shape.h
头文件(下面),在Shape
这个类中的calcArea()
函数前面加上关键字virtual
。注意,此时Shape的析构函数还是普通的析构函数,只不过它计算面积calcArea()
这个成员函数变成了虚函数。那么在这种情况下,如果去实例化一个Shape
的对象,就应该具有一个虚函数表指针。也就是说,如果原来它占的是一个内存的大小(这里Shape类没有成员变量
),那么,当我们已经拥有一个虚函数表指针的时候,那么这个Shape
就应该占有4个内存的大小(因为一个指针所占有的内存单元的4)。
然后测试如下的demo.cpp
,结果确实为4,说明我们加了virtual
关键字之后,在实例化Shape
对象的时候,那么它实例化出来的对象当中就含有一个虚函数表指针。
接下来再来打开Shape.h
文件,将计算面积的函数变为普通函数,同时在析构函数前面加上关键字virtual
,使其变成虚析构函数。如下:
只有析构函数前面加了virtual
关键字。那么在这种情况下,如果我们实例化一个Shape
的对象,那么这个Shape
的对象究竟占多少内存单元呢?是不是只有虚析构函数的情况下,作为Shape
对象来说,也有一个虚函数表指针呢?我们就可以通过打印的方式来看一看Shape
这个对象的大小,如果结果是4(确实如此),那么就说明:当我们去定义一个虚析构函数的时候,它同样会在实例化对象的时候,会产生一个虚函数表,并且在对象当中,产生一个虚函数表指针。
小结:虚析构函数同样能够使类在实例化对象的时候产生一个虚函数表,并且在实例化对象当中产生一个虚函数表指针。
那么,既然虚函数表指针会存在于父类对象当中,那么它也一样会存在于子类对象当中。头文件保持之前的不变,主调程序如下:
通过打印,我们可以看到,第一行是4,也就是说shape
的大小为4。第二行是8,这个8是怎么来的呢?其中的4个内存单元是由circle对象的数据成员m_iR
所占据的(因为m_iR
是int
类型),另外4个就是虚函数表指针所占据的。
circle的8个内存单元,是因为有int
类型的数据成员m_iR
占有4个,还有4个是虚函数表指针,因为父类定义了虚析构函数,这个虚析构函数能够传给子类,换句话说子类也有虚析构函数,实例化子类对象时会产生虚函数表的指针,这个指针就在子类对象的前4个内存单元。
为了论证上面的内容,可以如下这么做,分别打印shape
和circle
对象前4个基本单元的值,分别其实就是其虚函数表的指针:
上面的结果也很清晰了,circle
类对象的前4个内存单元值是子类的虚函数表地址,通过指针q
自增一,我们可以看到后4个内存单元的值存着100,即m_iR
的值。
小结:在多态的情况下,虚函数表指针在对象当中所占据的内存位置是每个对象的前4个基本内存单元,后面依次排列的才是这个对象的其他的数据成员。
6.6 纯虚函数和抽象类
这个类当中,我们定义了一个普通的虚函数,并且也定义了一个纯虚函数。纯虚函数就是没有函数体,同时在定义的时候,其函数名后面要加上= 0
。
(1)纯虚函数的实现原理
如果我们定义了Shape这样的类,那么,Shape
类当中,因为有虚函数和纯虚函数,所以,它一定有一个虚函数表,当然,也就一定有一个虚函数表指针。也就是说,在虚函数表当中,如果是纯虚函数,那么虚函数表中的函数指针值,就实实在在的写上0,如果是普通的虚函数,那就肯定是一个有意义的值。
纯虚函数也一定是某个类的成员函数。我们把包含纯虚函数的类称之为抽象类。比如刚刚举的Shape
类当中就含有一个计算周长的纯虚函数,那么,我们就说这个Shape
类是一个抽象类。如果我们使用Shape
这个类去实例化一个对象,那么这个对象实例化之后,如果想要去调用纯虚函数(比如要去调用这个计算周长的纯虚函数),那怎么去调用呢?显然是无法调用的。
结论:在C++中,抽象类无法实例化对象。如果强行写成如下形式去实例化Shape
对象:
比如上面的,从栈中或者堆中去实例化一个对象,此时,如果我们去运行程序的话,计算机就会报错。而且,不仅如此,对于抽象类的子类也可以是抽象类。比如:我们如果定义一个Person
的类如下:
因为人是要工作的,所以定义了一个work()函数,同时还定义了一个打印信息的函数。由于人比较抽象,所以也不知道工作要做啥,所以就定义work()为纯虚函数,同时,也不知道该打印啥信息,所以也定义成了纯虚函数。当我们使用Worker这个类去继承Person
类的时候,我们可以想象一下,对于工人来说,其工种是非常多的,单单一个工人,我们倒是可以一些他的信息(比如:这个工人的名字,工号等等),但是,这个工人是什么工作,具体是做什么的,我们也没有办法清晰明了的描述出来,所以这个时候,我们可以也把它定义成一个纯虚函数,如下所示。此时,这个Worker类作为Person
的子类来说,它也是一个抽象类。
当我们明确了这个工人是什么工种(比如他是一名清洁工),清洁工这个类继承了Worker类(清洁工也是工人的一种),那么work()这个函数就有了一个明确的定义了(比如:他的工作就是扫地,我们可以将其打印出来),如下图所示。那么,此时,我们就可以使用清洁工(Dustman)这个类去实例化
注意:对于抽象类来说,它无法实例化对象,而对于抽象类的子类来说,只有把抽象类中的纯虚函数全部实现之后,那么这个子类才可以实例化对象。
(2)纯虚函数和抽象类的代码实践
纯虚函数和抽象类
1. Person类,成员函数:构造函数,虚析构函数,纯虚函数work(),数据成员:名字 m_strName
2. Worker类,成员函数:构造函数,work(),数据成员:年龄m_iAge
3. Dustman类,成员函数:构造函数,work()
验证:含有纯虚函数的类,即抽象类能否实例化对象。确实会报错:”Person”是一个抽象类,不能实例化。
Person
类(抽象类)的子类Worker
也是一个抽象类,并没在Worker
类定义中对work()
有啥操作, 这样显然也是回报错:”Worker”是一个抽象类,不能实例化。但是如果对Worker
类的work()
函数实现后再试试,是可以编译通过的。
这样说明,Worker这个类虽然继承自抽象类Person类,但此时Worker类中已经没有了纯虚函数,但凡是虚函数,也已经被实现了。如果Worker
类中的work
函数也不进行实现,则Worker
类仍是一个抽象类,这时就依赖Worker
的子类来实现纯虚函数,比如将此重任给劳动工作内容更明确的清洁工Dustman
类。具体看我github中的纯虚函数_Person_Worker_Dustman
文件。
6.7 接口类
(1)接口类
接口类:如果在一个抽象类中,仅含有纯虚函数,而不含有其他的任何东西。即:仅含有纯虚函数的类称为接口类。
在类当中,没有任何的数据成员,只有成员函数,而这仅有的成员函数当中,其又都是纯虚函数,此时,我们就把这样的类称之为接口类。如下面的Shape
类:
在实际的使用过程中,接口类更多的是用来表达一种能力或协议。如下的一个飞类,里面的起飞和降落两个函数都是纯虚函数,继承Flyable
类的子类就需要具体定义这两个函数,如Bird
类
如果在使用是,有flyMatch()
函数:
flyMatch
这个函数所要求传入的指针是“会飞”的,也就是说,任何会飞的对象的指针都可以传入进来。Bird这个类实现了Flyable
,即Bird是一个子类。前面我们讲过,当我们用一个子类去继承父类的时候,就形成了一种is-a
的关系。当形成了这种is-a
的关系之后,我们就可以在flyMatch,也就是飞行比赛当中传入两个指针,这两个指针要求传入的类只要是Flyable
的子类就可以了。那么这个时候,我们知道Bird是Flyable
的子类,那么在flyMatch中就可以传入Bird类的对象指针。传入进来的对象指针就可以调用Flyable
类中所要求必须实现的起飞和降落这两个函数了。这个时候,大家应该隐隐的感觉到,其实Flyable
这个类就相当于是一种协议,你如果想要参加飞行比赛,那么你就一定要会飞;那么如果你会飞,你一定实现了起飞和降落这两个函数;那么你实现了这两个函数,那么我们就可以再flyMatch(飞行比赛)中去调用了。同样的道理,如果我们有如下一个类,这个类叫做CanShot
。
在这个类当中,我们定义了两个纯虚函数:瞄准和装 弹。此时,如果我们再定义一个Plane(飞机类),飞机可以进行多继承,其继承了Flyable
(会飞的)和CanShot
这两个类,如下所示:
想要实例化Plane,那么,它就要必须实现Flyable中的起飞(takeoff)和降落(land)以及CanShot中的瞄准(aim)和装弹(reload)。如果我们把这些都实现了,那么,假设我们有如下一个函数fight
,这里要求,只需要具有能够CanShot这种能力就可以了:
现在类之间的关系如下,Plane
即是Flyalbe
类也是CanShot
类的子类。Fight
函数的参数只要是CanShot
的子类就行,如传入Plane
类的对象指针给Fight
,那么传入进来的对象指针必定是CanShot
这个类的对象指针,就一定实现了并能调用瞄准和装 弹这两个函数。
对于接口类来说,更为复杂的情况如下所示:CanShot
父类是一个接口类,
当我们定义一个Plane(飞机)这样的一个类的时候,对于飞机来说,他一定是能够会飞的,所以我们继承Flyable
这个类,这样飞机就有了好“会飞”的能力。如果想要去实例化飞机,那么此时我们就必须要实现起飞和降落这两个函数。而战斗机是可以继承飞机的,同时战斗机还应该具有射击的能力,这个时候它也是一种多继承(同时有多个基类),如下所示的战斗机FightJet
:
注意:它的第一个父类(Plane)并不是一个接口类,它的第二个父类(CanShot
)则是一个接口类。这种情况下,我们从逻辑上可以理解为:战斗机是继承了飞机的绝大部分属性,同时还具有能够射击这样的功能,那么我们就需要在战斗机中去实现CanShot
这个类当中的瞄准和装*弹这两个函数。实现完成之后,如果我们有一个函数airBattle(空战),而空战的时候就需要传入两个战斗机的对象指针,如下所示:
因为此时我们传入的是战斗机的对象指针,那么战斗机对象当中一定实现了CanShot
中瞄准和装弹这两个函数,同时,也肯定实现了Flyable
中的起飞和降落这两个函数,于是我们就可以放心地在airBattle(空战)这个函数中去调用Flyable
和CanShot
所约定的函数了。
(2)接口类代码实践
1. Flyable类,成员函数:takeoff(起飞)、land(降落)
2. Plane类,成员函数:takeoff、land、printCode,数据成员:m_strCode
3. FighterPlane类,成员函数:构造函数、takeoff、land
4. 全局函数flyMatch(Flyable *f1, Flyable *f2)
头文件Flyable.h
、FigtherPlane.h
、Plane.h
:
Plane.cpp
、FighterPlanec.cpp
源程序:
demo.cpp
源程序:
分析上面的结果:
前两行是print的内容,后4行分别是f1的起飞和降落,f2的起飞和降落,这说明了Plane
可以正确的作为参数传递给flyMatch
,而flyMatch
函数其实限制了传入参数的参数类型为Flyable
类型的指针,并且可以在函数体中放心地调用接口类中所定义的纯虚函数,这就是接口类最常用的用法。
PS:如果函数的参数是基类的对象,则基类的对象和派生类的对象都可以作为实参数传递进来,并能够正常使用。
接下来我们使用FighterPlane这个类来试一试,看看其能不能作为参数传入到flyMatch当中,修改main函数如下:
后四行的结果和之前的不一样,调用了FigtherPlane
类的起飞和降落函数。现在要求在FighterPlane
中不仅要继承Plane
这个类,而且还要继承Flyable
这个类,同时,不让Plane继承Flyable
这个类(注意此时就要去掉之前Plane
实现的纯虚函数takeoff和land)。
分析上面结果:
从运行结果可以看到跟之前一样,但是意义却不同了。此时的FighterPlane
既继承了Plane
这个类,也继承了Flyable
这个类。这就意味着,如果有另外一个函数,要求传入的是Plane
而不是FighterPlane
,如下:
而main.cpp
文件还是实例化FighterPlane
对象传入函数flyMatch
,这样写是合法的,因为flyMatch
要求传入的类是FighterPlane
的父类,所以这样写是合法的:
6.8 RTTI:运行时类型识别
(1)RTTI–运行时类型识别
RTTI:Run-Time Type Identification。这里涉及到typeid
和dynamic_cast
这两个知识点。
(2)RTTI代码实践
RTTI
1. Flyable类,成员函数:takeoff()和land()
2. Plane类,成员函数:takeoff()、land()和carry()
3. Bird类,成员函数:takeoff()、land()和foraging()
4. 全局函数dosomething(Flyable *obj)
6.9 异常处理
(1)异常处理
- 常见异常:
- 数组下标越界
- 除数为0
- 内存不足
(2)异常处理与多态的关系
(3)异常处理代码实践
异常处理
1. 定义一个Exception类,成员函数:printException,析构函数
2. 定义一个IndexException类,成员函数printException
Note: Exception类是异常类,作为父类
IndexException类是下标索引异常类,是Exception类的子类
如果这两个类具有继承关系,我们需将父类的析构函数定义为虚析构函数
父类和子类中的printException()都是虚函数