Re:从零开始的 C++ 进阶篇(三)彻底搞懂 C++ 多态:虚函数、虚表与动态绑定的底层原理
◆ 博主名称: 晓此方-ZEEKLOG博客大家好,欢迎来到晓此方的博客。⭐️C++系列个人专栏: 主题曲:C++程序设计⭐️ 踏破千山志未空,拨开云雾见晴虹。 人生何必叹萧瑟,心在凌霄第一峰
0.1概要&序論
这里是此方,好久不见。 多态是 C++ 中最核心而且是最难理解的机制之一。它不仅是语法层面的特性,更牵涉到 C++ 的对象模型、对象内存布局以及多态机制的底层实现原理。本文将从底层原理出发,系统全面解析多态的真实运作机制。这里是「此方」。让我们现在开始吧!
一,多态的概念
通俗来说,多态就是多种形态。多态分为编译时多态(静态多态) 和 运行时多态(动态多态),这里我们重点讲运行时多态。
1.1编译时多态(静态多态)
编译时多态主要就是我们前面讲的 函数重载和函数模板。 它们通过传递不同类型的参数就可以调用不同的函数,通过参数不同达到多种形态。之所以叫编译时多态,是因为实参传递给形参的参数匹配是在编译时完成的, 我们将编译时一般归为静态。
1.2运行时多态(动态多态)
运行时多态,具体点说,就是在完成某个行为(函数)时,可以 传递不同的对象来执行不同的行为,从而达到多种形态。
- 例如买瓜这个行为:
- 当普通人买瓜时,是全价买瓜;
- 华强买瓜时,是优惠买瓜(5折或75折);
- 再比如,同样是动物叫的一个行为(函数):
- 传猫对象过去,就是 “喵”;
- 传狗对象过去,就是 “汪汪”。
二,虚函数和重写/覆盖
讲多态实现之前我们必须要先 **补充一个内容:虚函数。**
2.1.什么是虚函数
在类的成员函数前面加上 virtual 关键字进行修饰,该成员函数就被称为虚函数(Virtual Function)。
需要注意的是,只有类的成员函数才能被声明为虚函数,非成员函数不能使用 virtual 关键字进行修饰。此外;不是所有的成员函数都可以设置为虚函数:构造函数和静态成员函数不可以。
classPerson{public:virtualvoidBuyWatermalon(){ cout <<"买瓜:全价"<< endl;}};2.2什么是虚函数的重写/覆盖
虚函数重写是指:在派生类中定义一个与基类虚函数完全相同的虚函数。
所谓“完全相同”通常指以下三个方面(后文称为三同):当满足这些条件时,就称 派生类的虚函数重写(覆盖)了基类的虚函数。
函数名相同参数列表相同返回值类型相同
如下,我们举一个例子:HuaQiang类中的买瓜函数和Person类中的买瓜函数 函数名、参数列表、返回值完全相同。 我们认为HuaQiang类中的买瓜函数重写/覆盖了Person类中的买瓜函数 。
classPerson{public:virtualvoidBuyWatermalon(){ cout <<"买瓜全价"<< endl;}};classHuaQiang:publicPerson{public:virtualvoidBuyWatermalon(){ cout <<"打折"<< endl;}};2.3虚属性会被继承
需要注意的是,在派生类中重写基类虚函数时,即使不显式写出 virtual关键字,该函数依然会保持虚函数属性。这是因为虚函数的属性在继承过程中会被继承下来。(只要基类写了virtual,子类,子类的子类的对应的函数不写virtual都能实现重写)必须注意的是:把基类的virtual去掉会导致不会形成虚函数重写关系,而会变成普通函数覆盖。
还是刚才的那个例子: 我们把子类的买瓜函数的virtual去掉,照样能够构成重写。
classPerson{public:virtualvoidBuyWatermalon(){ cout <<"买瓜全价"<< endl;}};classHuaQiang:publicPerson{public:voidBuyWatermalon(){ cout <<"打折"<< endl;}};不过,从代码规范和可读性的角度来看,仍然建议在派生类中显式写出 virtual 关键字。在一些考试或面试题中,往往会利用这一点设置陷阱,让你判断代码是否能够构成多态。
三,多态的构成条件
3.1 实现多态两个必要重要条件
必须通过基类的指针或引用调用虚函数被调用的函数必须是虚函数,并且在派生类中完成重写(Override)
<接下来我们细讲这两个条件>
3.2必须通过“基类”的“指针或引用”调用虚函数
3.2.1为什么必须通过“基类”
只有基类的指针或引用, 既可以指向基类对象,也可以指向派生类对象,因此才能在运行时根据对象类型产生不同的行为。
这里可以看到,虽然都是 Person 类型的指针ptr在调用Buyticket(),但具体执行哪个函数并不取决于指针的类型,而取决于ptr 实际指向对象的类型 ,这就是 运行时多态的体现。
当我们调用重写后的虚函数时,可以传递基类对象的地址,也可以传递派生类对象的地址。
当传递基类对象地址 时,指针指向的就是基类对象,因此调用的是 基类的虚函数。当传递派生类对象地址 时,会发生 向上类型转换。也就是说,派生类指针会被隐式转换为基类指针,从而赋值给函数参数中的基类指针。(子类指针/引用可以向上类型转换成基类的指针/引用,但是基类的指针/引用不能类型转换成子类的指针/引用,Person的形式参数必须是基类的而不是子类的,如果是子类的久只能传递子类的指针/引用,无法实现多态)。
需要注意的是,这里并不会把派生类对象中的基类部分“切出来”再传递,而只是让 基类指针指向该派生类对象中的基类子对象部分。 整个对象仍然是一个完整的派生类对象,因此在运行时调用虚函数时,程序可以根据对象的实际类型进行动态绑定,最终调用 派生类中重写后的虚函数,从而实现多态。
基类指针虽然只看到基类接口,但它仍然指向一个完整的派生类对象。
3.2.2为什么必须通过“指针或引用”
为什么传值不行传址可以?
- 传值是用HuaQiang 构造了一个新的person,用这个新的person调用自然是person的函数。
- 传址是将HuaQiang的地址给到ptr,ptr用HuaQiang的指针去找自然是HuaQiang的函数。
3.3被调用的函数必须是虚函数,并且在派生类中完成重写
当派生类对基类的虚函数进行了重写后,基类和派生类之间才会形成不同的函数实现。 这样在运行时调用时,程序才能根据对象的实际类型决定调用哪个函数, 从而实现多态效果。
3.4一道非常著名的面试题
猜猜下面的程序输出结果是什么?
classA{public:virtualvoidfunc(int val =1){ std::cout<<"A->"<< val <<std::endl;}virtualvoidtest(){func();}};classB:publicA{public:voidfunc(int val =0){ std::cout<<"B->"<< val <<std::endl;}};intmain(int argc ,char* argv[]){ B*p =new B; p->test();return0;}做出来了吗?答案是B。我想90%以上的小伙伴都会答错。我们来分析一下:/顺便补充几个知识点(我会重点标出)
3.4.1第一步:func 函数构成重写
所谓重写的三同,也是我们常说的函数相同所指的:其中参数相同更加准确的是参数类型相同并且参数个数相同,而和参数名称和缺省值的有无多少无关。所以A类和B类的func函数构成重写。
3.4.2第二步:子类的指针去调用test函数
开辟一块子类空间给到子类的指针,用子类的指针去调用test函数。test函数没有被子类重写或者是隐藏,所以直接调用父类继承过来的。注意:这里调用的是父类里面的test函数,子类指针之所以能够调用是因为父类继承给了子类,所谓继承,是子类对象包含了一份父类对象
3.4.3第三步:test函数的this指针调用
子类指针调用test函数后,子类的指针发生了隐式类型转换,转换为父类类型的this指针。但是this指针指向的任然是子类中的那个父类。 所以调用的是子类中重写父类的那个func函数。
完事儿了?所以最后结果应该是B->0?不,接下来就是这个题目最牛逼的地方:
3.4.4第四步:再次明确重写的定义
在侯捷老师的著作 《EffectiveC++》中第三十七条款:“绝不重定义继承而来的缺省参数值”。放在这道题里面是什么意思呢?函数重写的本质是:重写函数的实现部分。
virtualvoid func(int val = 1){ std::cout<<“A->”<< val <<std::endl;}
void func(int val = 0){ std::cout<<“B->”<< val <<std::endl; }
四,虚函数重写的一些其他问题
4.1协变(了解)
派生类重写基类虚函数时,与基类虚函数返回值类型不同。即 基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用(打破了三同,但是又有特殊条件)时,称为协变。协变的实际意义并不大,所以我们了解一下即可。
classBpublic:A{};classPerson{public:virtual A*BuyWatermalon(){ cout <<"买瓜全价"<< endl;returnnullptr;}};classHuaQiang:publicPerson{public:virtual B*BuyWatermalon(){ cout <<"买瓜打折"<< endl;returnnullptr;}};注意,如上代码,需要纠正一个误区:协变的定义种:基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用。这里的基类和派生类指的是任何一对满足继承关系的基类和派生类,上面代码中class B public:A{};就是协变的前提条件之一,指针和引用返回值是协变条件之二!
如果没有这个前提就会报错!这里很容易搞错,你过几天没准就忘了
error C2555: “HuaQiang::BuyWatermalon”: 重写虚函数返回类型有差异,且不是来自“Person::BuyWatermalon”的协变 4.2析构函数的重写
基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加 virtual 关键字,都与基类的析构函数构成重写。
虽然基类与派生类析构函数名字不同看起来不符合重写的规则,实际上编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成 destructor,实际上实现了三同。
下面的代码我们可以看到,如果 ~A() 不加 virtual,那么 delete p 时只会根据p的类型调用 A 的析构函数,没有调用 B 的析构函数,就会导致内存泄漏问题。
classA{public:virtual~A(){cout <<"~A()"<< endl;}};classB:publicA{public:~B(){ cout <<"~B()->delete:"<< _p << endl;};intmain(){ A* p =new B;delete p;return0;}注意: 这个问题面试中经常考察,大家一定要结合类似下面的样例才能讲清楚,为什么基类中的析构函数建议设计为虚函数。
如果 ~ A() 是 virtual,那么 ~B() 会重写 ~A()。当 A* p2 = new B; delete p2; 时,程序会通过虚函数机制根据对象的真实类型调用~B()。在~B() 执行结束后,编译器会自动调用 A::~A(),从而完成整个对象的析构过程==。
解释一下析构函数的虚函数和一般虚函数的不同之处:析构完子类后自动调用基类的虚函数。
为什么会自动调用基类的虚函数?因为子类里面有一个基类,子类的析构函数虽然重写了基类的析构函数,但是无法完成对基类成员的析构。 这实际上不是一个指针调用了两个函数,而是:(下面的图由GPT生成)
delete p2 ↓ 虚表判断真实类型 ↓ 调用 B::~B() ← 虚函数只负责这一步 ↓ 自动调用 A::~A() 总的来说,可以这么理解:析构函数的重写是为了让析构函数的调用和指针指向的对象有关,和指针的类型无关。
五,override&final关键字
从上面可以看出,C++对虚函数重写的要求比较严格,但是有些情况下由于疏忽,比如函数名写错、参数写错等导致无法构成重写,而这种错误在编译期间是不会报出的,只有在程序运行时没有得到预期结果才来debug会得不偿失,因此C++11提供了override,可以帮助用户检测是否重写。
如果我们不想让派生类重写某个虚函数,那么可以用final去修饰。
以下我们举例来说明一下。
classCar{public:virtualvoidDirve(){}};classBenz:publicCar{public:virtualvoidDrive()override{ cout <<"Benz舒适"<< endl;}};如上,我们想要让我们的Benz类中的虚函数Dirve()去重写Car类中的Dirve()。但是我们笔误写错了。但是好在我们事先加了overrive关键字,编译时直接报错:override和assert又不同,assert是运行时检查,override是编译时检查。
// error C3668: “Benz::Drive”: 包含重写说明符“override”的⽅法没有重写任何基类⽅法再来看这个代码,我们不希望car类中的Drive函数被重写,所以增加了final关键字。然后子类去重写的时候就会报错。
//error C3248: “Car::Drive”: 声明为“final”的函数⽆法被“Benz::Drive”重写classCar{public:virtualvoidDrive()final{}};classBenz:publicCar{public:virtualvoidDrive(){ cout <<"Benz舒适"<< endl;}};六,重载/重写/隐藏的对比
注意:这个概念对比经常考,大家得理解记忆一下
七,纯虚函数和抽象类
在函数后面加上=0;,则这个函数为纯虚函数,纯虚函数不需要定义实现(实现没啥意义因为要被派生类重写,但是语法上可以实现),只要声明即可。包含纯虚函数的类叫做抽象类,抽象类不能实例化出对象,如果派生类继承后不重写纯虚函数,那么派生类也是抽象类。纯虚函数某种程度上强制了派生类重写虚函数,因为不重写实例化不出对象。
classCar{public:virtualvoidDrive()=0;};classBenz:publicCar{public:virtualvoidDrive(){ cout <<"Benz舒适"<< endl;}};如果实例化抽象类了,报错类型如下:
error C2259: “Car” : 无法实例化抽象类 message : “voidCar::Drive(void)” : 是抽象的 父类的对象不能够实例化出来,但是可以作为指针类型来使用,如下图也是多态调用。
intmain(){ Car* pBenz =new Benz; pBenz->Drive(); Car* pBMW =new BMW; pBMW->Drive();return0;}总结一下,抽象类的核心规则:
不能实例化对象不能按值传参不能作为函数返回值(按值返回)可以定义指针或引用
八,多态的原理
在讲原理之前,我们先补充一个知识点
8.1虚函数表指针
先看下面一道题:下面编译为32为程序的运行结果是什么?
classBase{public:virtualvoidFunc1(){ cout <<"Func1()"<< endl;}protected:int _b =1;char _ch ='x';};intmain(){ Base b; cout <<sizeof(b)<< endl;return0;}上面题目运行结果12bytes,有人肯定会问了,怎么会是12字节呢?整型4字节+字符1字节+内存对齐应该是8字节呀?
我们来揭晓原理:除了_b和_ch成员,实际上还多一个__vftpr放在对象的最前面(注意有些平台可能会放到对象的最后面,这个跟平台有关),对象中的这个指针我们叫做虚函数表指针(v代表virtual,f代表function)。
——于是我们知道这个时候对齐后答案就是12字节没跑了。
什么是虚函数表指针?一个含有虚函数的类中都至少都有一个虚函数表指针,因为一个类所有虚函数的地址要被放到这个类对象的虚函数表中,虚函数表也简称虚表。
我们打开监视看看是不是这么回事儿?
你现在只需要知道一句话:有虚函数的类一定会有一个虚函数表来存放虚函数的指针,这个类每一个实例化出来的对象都有一个虚函数表指针去指向这个虚函数表。
我后文会详细讲这两个概念的细节,但是接下来按照我的思路我想先讲多态的原理,然后再讲虚函数表和虚函数表指针的细节。
8.2多态是如何实现的
从底层的角度Func函数中ptr->BuyWatermalon(),ptr是如何指向Person对象调用Person::BuyWatermalon,ptr指向HuaQiang对象调用HuaQiang::BuyWatermalon的呢?
classPerson{public:virtualvoidBuyWatermalon(){ cout <<"买瓜全价"<< endl;}private: string _name;};classHuaQiang:publicPerson{public:virtualvoidBuyWatermalon(){ cout <<"买瓜打折"<< endl;}private: string _id;};voidFunc(Person* ptr){ ptr->BuyWatermalon();}intmain({ Person ps; HuaQiang st;Func(&ps);Func(&st);return0;}通过下图我们可以看到,满足多态条件后,底层不再是编译时通过调用对象确定函数的地址,而是运行时到指向的对象的虚表中确定对应的虚函数的地址,这样就实现了指针或引用指向基类就调用基类的虚函数,指向派生类就调用派生类对应的虚函数。第一张图,ptr指向的Person对象,调用的是Person的虚函数;第二张图,ptr指向的HuaQiang对象,调用的是HuaQiang的虚函数。
用一张图来解释一下就是这样: 这个结构布局是在编译的时候就生成的。

总结一下多态是怎么实现的:对象的指针传过去给Person类指针形式参数ptr,这个指针指向的是一个Person类,但是这个Person类是哪儿的Person类不用去管他,指向什么调用什么。
然后在这个指针指向的对象的内存的前4/8个字节取得虚函数表指针(虚函数表指针放在类的最开头,基类的内存位置也在被继承的子类的最开头),再用虚函数表指针找到虚函数表,再在虚函数表中找到。我要访问的那个虚函数的地址,然后动态绑定这个地址实现调用。
最最关键的还是指向谁调用谁:运行时,到指向对象的虚函数表中找到对应虚函数的地址,进行调用。
8.3静态绑定和动态绑定
8.3.1静态绑定和动态绑定的定义
对不满足多态条件的函数调用是在编译时绑定,也就是编译时确定调用函数的地址,叫做静态绑定。
满足多态条件的函数调用是在运行时绑定,也就是在运行时到指向对象的虚函数表中找到调用函数的地址,也就做动态绑定。
8.3.2从汇编代码看静态绑定和动态绑定
我们打开汇编代码看一看:
1.动态绑定的情形: ptr是指针 + BuyWatermalon是虚函数满足多态条件。编译在运行时到ptr指向对象的虚函数表中确定调用函数地址。
ptr->BuyWatermalon(); 00EF2001 mov eax,dword ptr [ptr] //把 ptr 的值(对象地址)加载到 eax。 00EF2004 mov edx,dword ptr [eax] 取对象开头的值 [eax],通常这是对象的虚函数表指针 00EF2006 mov esi,esp 00EF2008 mov ecx,dword ptr [ptr] ecx 保存 ptr,在 thiscall 调用约定下,ecx 传递 this 指针。 00EF200B mov eax,dword ptr [edx] 把[edx](虚函数表指针)这个地址放到 eax,为调用做准备。 00EF200D call eax调用 eax 指向的函数。(虚函数表中的虚函数) 2.静态绑定的情形:不是虚函数,不满足多态条件,编译器直接确定调用函数地址。
ptr->BuyWatermalon();00EA2C91 mov 00EA2C94 call 8.4虚函数表中的细节
8.4.0虚函数表什么时候产生
首先,不是在运行时创建。虚函数表是在编译期生成结构布局时就已经确定好的。只要一个类中存在 virtual 函数:编译器就会为这个类生成一张虚函数表。每个对象中会隐含一个 vptr(虚表指针),构造对象时,vptr 会被初始化为指向该类对应的虚表。
8.4.1在不同类之间独立,在同对象间共用
基类对象的虚函数表中存放基类所有虚函数的地址。同类型的对象共用同一张虚表,不同类型的对象各自有独立的虚表,所以基类和派生类有各自独立的虚表。
8.4.2“虚”属性能够得到继承的根本原因
派生类由两部分构成,继承下来的基类和自己的成员,一般情况下,继承下来的基类中有虚函数表指针,自己就不会再生成虚函数表指针。但是要注意的是这里继承下来的基类部分虚函数表指针和基类对象的虚函数表指针不是同一个,就像基类对象的成员和派生类对象中的基类对象成员也独立的。
8.4.3派生类的虚表中存了些什么
派生类中重写的基类的虚函数,派生类的虚函数表中对应的虚函数就会被覆盖成派生类重写的虚函数地址。
派生类的虚函数表中包含:(1) 基类的虚函数地址,(2) 派生类重写的虚函数地址完成覆盖,(3)派生类自己的虚函数地址三个部分。
8.4.4虚函数和虚函数表存在哪儿
虚函数存在哪的?这个问题严格说并没有标准答案,一般来说:虚函数和普通函数一样的,编译好后是一段指令,都是存在代码段的,只是虚函数的地址又存到了虚表中。
虚函数表存在哪儿?C++标准并没有规定到底应该存在哪儿,我们写下面的代码可以对比验证一下。(先说结论:vs下是存在代码段(常量区))。
虚函数表指针存在哪里?结论先给出:在主流编译器(如 GCC、Clang、MSVC)的典型实现中,单继承场景下,虚函数表指针通常位于对象内存布局的起始位置。但这不是 C++ 标准强制规定,而是 ABI 约定。
Person b; HuaQiang d; Person* p3 =&b; HuaQiang* p4 =&d;printf("Person虚表地址:%p\n",*(int*)p3);printf("HuaQiang虚表地址:%p\n",*(int*)p4);如上代码,测试虚函数表指针的方法很简单:虚表指针是头上四个字节。但是Base*解引用取出来整个base。但是我们打印不出来并且不需要整个base,所以强制类型转换成int取得头上四个字节的。
8.4.5还有哪些小细节
虚函数表本质是一个存虚函数指针的指针数组,一般情况这个数组最后面放了一个0x00000000标记。(这个C++并没有进行规定,各个编译器自行定义的,vs系列编译器会再后面放个0x00000000标记,g++系列编译不会放)
好了,本期内容到此结束,我是此方,我们下期再见。バイバイ!