一、多态的概念
二、多态的定义和实现
三、虚函数
3.1 虚函数的定义
3.2 虚函数的重写
3.3 重写的特例
3.4 override和final(C++11)
3.5 重载、重写和隐藏
三、抽象类
3.1 概念
3.2 接口继承和实现继承
四、多态的原理
4.1 虚函数表
4.2 虚函数表的作用
4.3 用程序打印虚表
五、多继承中的虚函数表
一、多态的概念
多态,即多种形态,也就是当不同的对象去执行相同的接口时会产生不同的状态。
多态是面向对象编程领域的核心概念之一,可以简单的概括为“一个接口,多种方法”。
例如被饱受诟病的“杀熟”行为,新用户的折扣和概率高,而老用户的折扣和概率低,同样的商品或活动,两个群体得到的结果不同,这也是一种多态行为。
二、多态的定义和实现
不同继承关系的类对象,去调用同一个函数,产生了不同的结果,这就是多态。
例如我们定义一个Person类,Person对象买票要付全价。再定义一个继承Person类的Student类,Student对象买票只需要半价,这两个对象在买票时都要调用BuyTicket函数。怎么去实现这个多态呢?结果如下:
可以看到,虽然BuyTicket函数的形参类型是Person的引用,但是当我们传入Student类对象时还是可以调用到Student类的成员函数。
这里引出在继承中构成多态的两个条件:
必须通过基类的指针或引用来调用例如BuyTicket函数中的参数就是Person类对象的引用,而Person类在继承关系中为基类
如果不用指针或引用就无法构成多态,例如:
被调用的函数必须是虚函数,并且派生类中需要对基类的虚函数进行重写何为虚函数,何为重写?
三、虚函数
3.1 虚函数的定义
通过前面的例子可以了解到,被 virtual 修饰的类成员函数称为虚函数。
虽然和虚继承有着一样的关键字和相似的名字,但是二者并没有关系
3.2 虚函数的重写
如果派生类中的一个虚函数与基类中的某虚函数完全相同(返回值类型、函数名和参数列表都完全相同),则称子类的虚函数重写了基类的虚函数。
可以理解为,派生类继承了基类虚函数的声明,重写了其实现方式。
例如上面的例子,在Person类和Student类中有一个完全相同的price虚函数,此时即构成重写。
需要注意,在重写虚函数时即使派生类的虚函数不加virtual关键字,也可以构成重写
因为基类的虚函数在继承到派生类后依旧保持虚函数的属性。
但是这种写法不够规范,不推荐使用
3.3 重写的特例
(1)协变
基类与派生类的虚函数返回值类型不同的情况称为协变。
前提是两个虚函数返回的必须是父子关系的指针或引用
例如:
(2)析构函数的重写
如果将基类的析构函数定义为虚函数,那么派生类的析构函数一定会与基类的析构函数构成重写
即使它们函数名不同。
前面的文章中也提到过,编译器会将析构函数的名称统一处理成destructor,所以符合重写规则
关于析构函数的重写,还有一些需要我们知道的事,例如:
class Person {public:~Person() { cout << "~Person()" << endl; }};class Student : public Person {public:~Student() { cout << "~Student()" << endl; }};int main(){Person* p1 = new Person;Person* p2 = new Student;delete p1;delete p2;return 0;}
上面的情况下,p1和p2指向的对象能正确的调用析构函数吗?
可以看到,p2指向的Student对象只调用了基类的析构函数,而没有调用自己的析构函数,造成空间泄露。
因为析构函数的名字会被处理成destructor,此时基类和派生类的析构函数重名,构成隐藏。
如果不把析构函数定义成虚函数,就没有构成多态,所以只会通过指针的类型去调用对应类的析构函数。因此为了避免出现这种不正确析构的情况,需要把析构函数定义成虚函数。
现在就可以正确的调用析构函数了
3.4 override和final(C++11)
C++11为我们提供了override和final关键字,用于帮助程序员检测函数是否重写
override:检测派生类虚函数是否重写了基类某个虚函数,如果没有重写则报错例如:
final:修饰虚函数,表示该虚函数不能被重写例如:
3.5 重载、重写和隐藏
三个概念比较近似,初学者在学习时可能会混淆
(1)重载:函数重载
两个函数在同一作用域函数名相同,参数列表不同(2)重写(覆盖):多态
两个函数分别在基类和派生类的作用域函数名/参数/返回值都必须相同(协变除外)两个函数必须是虚函数(3)隐藏:继承
两个函数分别在基类和派生类的作用域函数名相同函数不构成重写
三、抽象类
3.1 概念
包含纯虚函数的类就叫做抽象类(接口类)。
纯虚函数:在虚函数的后面加上 = 0
抽象类不能实例化出对象,如果一个派生类继承了一个抽象类,则必须对纯虚函数进行重写,否则也无法实例化对象。
语法规范了派生类中必须对纯虚函数进行重写
3.2 接口继承和实现继承
普通函数的继承是一种实现继承,没有构成多态时,派生类继承的是基类函数的实现
虚函数的继承是接口继承,此时派生类继承的是基类虚函数的接口,需要进行重写构成多态。
因此如果不实现多态,就不要把函数定义成虚函数
四、多态的原理
4.1 虚函数表
提问:以下代码中Base类的大小是多少?
class Base{public:virtual void Func(){}private:int _a = 0;int _b = 1;};int main(){Base b;cout << sizeof(b) << endl;return 0;}
类中有两个int类型的成员变量,所以可能很多人会认为答案是8
结果:
多出来的4bytes是从哪来的?我们可以通过监视窗口观察一下
可以看到,在两个成员变量前面多了个__vfptr,这个东西叫做虚函数表指针(虚表指针)。
虚函数表是多态底层的核心,虚表指针存放在对象的头四个(或八个)字节。
每个含有虚函数的类中都至少有一个虚表指针,用于存放虚函数的地址。
这是基类中虚表指针的情况,那么派生类呢?我们在上面的代码中添加一个派生类和多个虚函数再进行观察
class Base{public:virtual void Func1(){cout << "Base::Func1()" << endl;}virtual void Func2(){cout << "Base::Func2()" << endl;}void Func3(){cout << "Base::Func3()" << endl;}private:int _a = 0;int _b = 1;};class Derive : public Base{public:virtual void Func1() //重写{cout << "Derive::Func1()" << endl;}public:int _c = 1;};int main(){Base b;Derive d;return 0;}
如果你的监视窗口和图中的不太一样,可以尝试重新生成解决方案刷新一下
通过观察可以总结出以下两点:
派生类会继承基类的虚表派生类中对基类虚函数的重写会覆盖原本虚表中的基类的虚函数,因此重写也叫做覆盖另外的:
派生类中新增的虚函数会添加进继承下来的虚表中,但是不会在监视窗口显示虚函数表本质是一个存放虚函数指针的数组,一般情况下这个数组的最后放了一个nullptr还有一点容易混淆:虚函数和虚函数表是存在代码段中的,而虚表指针才是存在对象中的
总结:派生类会继承基类的虚表,如果在派生类中对基类的虚函数进行了重写,则在虚表中用重写后的虚函数对其进行覆盖;如果是新增了虚函数,则按照声明次序添加到虚表尾部
4.2 虚函数表的作用
通过这张图,我们可以简单的了解虚函数表的作用
当p传入BuyTicket函数中,此时引用指向的是Person类对象,通过Person类的虚表指针调用到了Person类的price函数
当s传入BuyTicket函数中,此时虽然进行了切片,但是因为是引用所以还是指向了Student类对象
所以就可以正常访问到Student类的虚表指针,也就可以调用到Student类的price函数了。
这就是为什么多态的条件之一是必须通过基类的指针或引用来调用了:
使用基类的指针或引用,指向派生类后虽然需要进行切割,但是还是指向派生类对象本身的
如果使用基类对象作为参数来调用,那么切割中会将派生类对象拷贝给基类对象,就无法访问到派生类的虚表指针
4.3 用程序打印虚表
前面说过,在派生类中新增虚函数,是不会在监视窗口中显示的,那么我们如何验证新增的虚函数真的添加进虚表中了呢?
方法:用程序打印虚表
其中涉及函数指针和类型转换等知识,会稍显复杂。
完整程序如下:
typedef void(*VF_PTR)();void PrintVFTable(VF_PTR* table){for (int i = 0; table[i] != nullptr;++i){printf("[%d]:%p->", i, table[i]);VF_PTR f = table[i];f();}cout << endl;}class Base{public:virtual void Func1(){cout << "Base::Func1()" << endl;}virtual void Func2(){cout << "Base::Func2()" << endl;}void Func3(){cout << "Base::Func3()" << endl;}private:int _a = 0;int _b = 1;};class Derive : public Base{public:virtual void Func1(){cout << "Derive::Func1()" << endl;}virtual void Func4(){cout << "Derive::Func4()" << endl;}public:int _c = 1;};int main(){Base b;Derive d;PrintVFTable((VF_PTR*)(*(int*)&b));PrintVFTable((VF_PTR*)(*(int*)&d));return 0;}
接下来我们一步步分解疑问
(1)
首先通过观察可以看到基类中有两个虚函数,因此派生类也继承了两个虚函数。
但是派生类中重写了虚函数Func1,又新增了虚函数Func4,因此有三个虚函数。
(2)
虚表中存放的是函数指针,类型为void(*)(),我们这里将其重命名为VF_PTR
(3)
接下来是PrintVFTable函数内部的实现逻辑
首先我们需要把虚表指针传入函数,因此函数的参数类型为VF_PTR*
接下来是遍历虚表,因为虚表的尾部存放nullptr,因此作为跳出循环的条件
循环内部打印出虚函数在虚表中的下标、虚函数的地址和执行虚函数(在上面的代码中,我们让虚函数执行时打印出自己的函数名,方便观察)
(4)
最后是PrintVFTable函数的使用
取对象的地址并强制转换成int*类型,并解引用。因为虚表指针存放在对象的头部,并且32位下是4字节,刚好和int类型大小相同,这样就能取出虚表指针。
将取出的虚表指针再强制转换成VF_PTR*类型,对应函数的参数类型,这样就可以正确调用PrintVFTable函数了。
最后的结果:
注意:由于编译器对虚表的处理不干净,有时候程序会崩溃,这时我们只需要点击目录栏的生成-重新生成解决方案即可
五、多继承中的虚函数表
前面都是单继承下的情况,那么多继承中的多态底层又是如何运行的呢
class Base1{public:virtual void Func1(){cout << "Base1::Func1()" << endl;}virtual void Func2(){cout << "Base1::Func2()" << endl;}private:int _b1 = 1;};class Base2{public:virtual void Func1(){cout << "Base2::Func1()" << endl;}virtual void Func2(){cout << "Base2::Func2()" << endl;}public:int _b2 = 2;};class Derive :public Base1, public Base2{public:virtual void Func1(){cout << "Derive::Func1()" << endl;}virtual void Func3(){cout << "Derive::Func3()" << endl;}private:int _d = 0;};int main(){Derive d;return 0;}
运行上述代码,通过监视窗口我们会发现派生类中同时继承了两个基类的虚表
说明多继承中派生类会同时继承所有基类的虚表
问题又来了,派生类中新增的虚函数该添加到哪个虚表中呢?还是两个虚表中都添加?
我们依旧可以通过打印虚表的方式来观察
可以看到,派生类中新增的虚函数只会添加到继承下来的第一个虚表中
细心的同学可能已经发现了,为什么派生类中重写的虚函数Func1,在两个虚表中的地址不同呢?
这里涉及到汇编指令的执行问题,主要原因是两个虚表指针在对象中的相对位置不同,有偏移量,因此需要通过一些步骤来修正this指针的位置,实际上二者是指向同一个虚函数Func1的。
至于菱形继承和菱形虚继承中的多态底层我们就不深入研究了,因为本身这种设计就带有缺陷,实际中很少用到。
如果有错误的地方,欢迎在评论区指出
完.