一. 多态的分类
多态就是调用一个函数时,展现出多种形态。比如买火车票这件事,普通人是全价,学生是半价,这就是一种多态。
多态分为静态的多态和动态的多态:
1. 静态的多态
静态的意思是编译时由编译器决定具体调用哪个函数,函数重载和模板就是一种静态的多态。
2. 动态的多态
动态指的是运行时才确定到底调用哪个函数。
条件:要同时满足两个条件。
- 子类继承基类,完成虚函数的重写。
- 基类的指针或引用去调用这个重写的虚函数。
效果:调用函数跟对象有关,指向那个对象就调用谁的虚函数。
- 基类的指针或引用指向基类对象,调用基类的虚函数。
- 基类的指针或引用指向子类对象,调用子类的虚函数。
PS:下文我们讨论的是多态是动态的多态。
二. 多态的相关概念介绍及其实现
我们假设一个场景:Student继承了Person类。Person对象买票全价,Student对象买票半价。
1. 虚函数
虚函数:被virtual修饰的类的成员函数称为虚函数,直接在函数的返回值类型前加上关键字virtual即可。
2. 虚函数的重写(覆盖)
派生类中有一个跟基类完全相同的虚函数(即两个函数的返回值类型、函数名、参数列表完全相同),称派生类的虚函数重写了基类的虚函数。
【Note1】 子类中重写的虚函数可以不加virtual关键字
在重写基类虚函数时,派生类的虚函数不加virtual关键字,这样也可以构成重写,因为已经在基类中已经确定了该函数是虚函数,自然它的虚函数属性也被继承了下来。但是该种写法不规范,不建议这样使用。
【Note2】 协变(基类与派生类虚函数返回值类型可以不同)
有个特殊的例外:派生类重写基类虚函数时,与基类虚函数返回值类型可以不同。即基类虚函数返回自己或其他基类对象的指针或者引用,派生类虚函数返回自己或其他派生类对象的指针或者引用依然可以构成多态,这种情况称为协变。
【Note3】 建议析构函数也定义成虚函数
当一个基类的指针指向new出来的子类对象时,为了保证delete基类指针能够去调用子类析构函数(也就是实现多态),最好把析构函数也定义为虚函数,以避免内存泄漏。
另外虽然基类与派生类析构函数名字不同,这看起来违背了重写的规则,其实不然,最终编译时编译器会对析构函数的名称做特殊处理:把所以对象的析构函数的名称统一处理成destructor。
3. 多态的构成条件
在继承中要构成多态还有两个条件:
- 派生类重写基类的虚函数。
- 通过基类的指针或者引用去调用虚函数。
4. C++11的 override 和 final
从上面可以看出,C++对函数重写的要求比较严格,但是有些情况下由于疏忽,比如函数名字母次序写反而无法构成重写,这种错误在编译期间是不会报错的,只有在程序运行时没有得到预期结果后debug找出,因此:C++11提供了override和final两个关键字,可以帮助用户检测重写的情况。
override关键字
这个关键字用来修饰派生类中需要重写的虚函数,强制要求子类去重写该虚函数。
final关键字
这个关键字用来修饰基类中不想要被子类重写的虚函数,用来限制不让子类重写该函数。
5. 重载、覆盖(重写)、隐藏(重定义)的对比
三. 抽象类
1. 什么是抽象类?
在认识抽象类之前必须先了解什么是纯虚函数。
纯虚函数:在虚函数声明的后面加上 =0 ,这个函数就叫做纯虚函数。
包含纯虚函数的类叫做抽象类(也叫接口类)。
抽象类:包含纯虚函数的类叫做抽象类(也叫接口类)
2. 为什么要有抽象类?
抽象类特点:不能实例化出对象。
- 可以更好的去表示现实世界中那些没有实例对象的抽象类型,比如:植物、人、动物、车等等。
- 体现了接口继承。强制子类去重写基类的虚函数(不重写的话,子类也是抽象类,不能实例化出对象)
2. 实现继承和接口继承
四. 多态的实现原理
1. 虚函数表
算一算32为平台下sizeof(Base)等于多少?
通过计算得到是8字节,除了成员变量_a的4个字节外,还有一个虚函数指针(_vfptr)也是四个字节。
说说这个虚函数指针,它指向的是一个函数指针数组,这个函数指针数组我们叫虚函数表(简称虚表),它的元素是虚函数的地址。
那派生类也有虚表吗?有的话和基类虚表是一样的吗?我们看看下面两个对象的数据模型:
通过上面的对象模型图,我们发现了以下几点问题:
1.派生类对象d中也有一个虚表指针,d对象由两部分构成,一部分是基类继承下来的成员(包括基类的续表),另一部分是派生类自己的成员。
2.虽然说是继承了父类的虚表,但不是照搬这么简单。总结一下派生类的虚表生成过程:
a、先将基类中的虚表内容拷贝一份到派生类虚表中 。
b、如果派生类重写了基类中某个虚函数,用派生类自己的虚函数地址覆盖虚表中基类的虚函数 地址。
c、派生类自己定义的虚函数增加到派生类虚表的最后位置(上图中没有看到是vs编译器隐藏了他们)。
3.既然派生类的虚表内容按照基类虚表深拷贝过来的,那么基类和派生类的虚表指针(_vfptr)也就不一样了,它们是两个不同的指针变量,指向两张虚表。
4.Fun3()是派生类自定义的普通函数,对于普通函数不论是派生自定义的还是基类继承下来的,他都不会被放入虚表。
2. 关于虚表的几点补充
补充1:虚表的元素是一个个虚函数地址,最后放空指针作为结束标志
补充2:一个类只要有虚函数就一定会有虚表,它的派生类也会继承父类的虚表
补充3:类型相同的对象共用同一张虚表
问题1:对象中虚表指针在什么阶段初始化?虚函数又在什么阶段生成的呢?
答:虚表指针在初始化列表中初始化,虚函数在编译时生成。
问题2:虚函数放到虚表里面的,这句话对吗?
答:这句话不准确,虚表里面放的是虚函数地址,虚函数和普通函数一样,编译完成后都放在代码段。
3. 再次理解虚表指针、虚表、虚函数
4. 多态的实现原理
前面说过要达到多态,有两个条件,一个子类重写基类的虚函数,另一个是对象的指针或引用调用虚函数。为什么?
问题1:为什么子类要重写基类的虚函数?
还是对下面这两个类分析
我们进行下面这个操作,实现多态:
我们可以看到
- 观察下图的红色箭头我们看到,p是指向Person对象p时,p->BuyTicket在对象虚表中找到虚函数是Person::BuyTicket。
- 观察下图的蓝色箭头我们看到,p是指向Student对象s时,p->BuyTicke在对象虚表中找到虚函数是Student::BuyTicket。
- 满足多态以后的函数调用,不是在编译时确定的,而是运行时到指向的对象中的虚表里找对应的虚函数地址来调用。所以指向父类对象,调用的就是父类的虚函数;指向子类对象,调用的就是子类的虚函数。即构成多态,指向谁就调用谁的虚函数。
- 如果不构成多态,那么这里调用的就是编译时编译器确定的调用那个函数,主要是看p的类型来决定。即不构成多态,对象类型是什么就调用哪个函数。
问题2:为何要父类的指针或引用去调用虚函数,父类的对象不可以码?
不能,首先我们要明白子类赋值给基类的对象、指针、引用这些都叫做切片,但它们的实现原理是不同的:
- 对象的切片:子类赋值给基类的对象时只会拷贝(调用基类的拷贝构造)子类中基类那一部分的成员过去,不会把子类的_vfptr拷贝过去,因为虚表是类的所有对象共用的、默认生成的。
- 引用、指针的切片:子类赋值给基类的引用或指针时,基类直接指向或引用子类中基类的那一部分,当然也包括子类的_vfptr。相当于是一种浅拷贝。
五. 多继承关系的虚函数表
前面我们一直搞的是单继承,现在我们看看多继承(不考虑菱形继承和菱形虚拟继承)
1. 单继承中的虚函数表
派生类自己定义的虚函数在派生类的虚表里不会显示出来,只会显示基类的,这是编译器的监视窗口故意隐藏了这两个函数
我们看下面两个类,通过监视窗口我们观察到派生类的虚表里确实重写了基类的Fun1(),但隐藏了自己定义的虚函数Fun2()。
我们可以打印虚表来验证编译器确实隐藏了派生类自己定义的虚函数。
思路:
取出b、d对象的头4bytes,就是虚表的指针,然后遍历虚函数的地址。前面我们说了虚函数表本质是一个存虚函数指针的指针数组,这个数组最后面放了一个nullptr。
验证结果
可以看到编译器确实是隐藏了派生类自定义的虚函数
2. 多继承中的虚函数表
依然打印的虚函数表,看看多继承中派生类的虚函数表示什么样。
既然是多继承,那派生类应该继承了两个虚表。我们分别打印这两个虚表看看。
打印结果发现,多继承中派生类自己定义的未重写的虚函数放在第一张虚函数表中。
3. 总结
单继承
- 派生类自己定义的会虚函数会加到它的虚函数表的最后。
多继承
- 多继承会按照继承基类的顺序在派生类中生成多个虚函数表。
- 派生类的自己定义的未重写的虚函数放在第一个继承的基类那部分的虚函数表中。