多态,是C++面向对象体系里最核心,也最难啃透的机制之一。
它远不止是停留在语法层面的表层特性,往深了探究,会一路牵扯到C++的对象模型、对象内存布局,以及多态机制整套的底层实现原理。 本文就从底层原理切入,系统全面地拆解多态的真实运作逻辑。
目录
一、多态到底是什么
1.1 编译时多态(静态多态)
1.2 运行时多态(动态多态)
二、虚函数与重写(覆盖)
2.1 什么是虚函数
2.2 什么是虚函数的重写/覆盖
2.3 虚函数的属性会被继承
三、多态的构成条件
3.1 实现多态的两个必要前提
3.2 为什么非得是“基类”的“指针/引用”?
3.2.1 为什么形参一定得是基类类型?
3.2.2 为什么必须是指针或引用?传值不行吗?
3.3 函数必须是虚函数,且派生类完成重写
3.4 一道经典到“坑倒一片”的面试题
3.4.1 第一步:先确认func是否构成重写
3.4.2 第二步:子类指针调用test,执行的是谁的代码?
3.4.3 第三步:test里调用func (),到底调的是哪个?
3.4.4 最反直觉的点:缺省参数是静态绑定的
四、虚函数重写的两个特殊场景
4.1 协变(了解即可)
4.2 析构函数的重写(面试高频考点)
4.2.1 为什么基类析构函数强烈建议写成虚函数?
4.2.2 析构虚函数和普通虚函数的区别
五、override&final:C++11给虚函数加的两道保险
5.1 override:编译期帮你把好重写这道关
5.2 final:给函数/类“封顶”,禁止重写与继承
六,重载/重写/隐藏的对比
七、纯虚函数与抽象类
八、多态的底层原理
8.1 先认识核心角色:虚函数表指针
8.2 多态到底是如何实现的
8.3 静态绑定与动态绑定
8.3.1 两个核心概念
8.3.2 从汇编视角看两者差异
8.4 虚函数表的细节补充
8.4.0 虚表什么时候生成?
8.4.1 同类共用,异类独立
8.4.2 继承下的虚表指针
8.4.3 派生类的虚表里都存了什么?
8.4.4 虚函数、虚表到底存在内存哪?
8.4.5 一些边角细节
一、多态到底是什么
通俗点说,多态就是“同一件事,不同的对象去做,会有不同的结果”,也就是字面意思的多种形态。 在C++里,多态分成两大类:编译时多态(静态多态)和运行时多态(动态多态),我们核心要啃的是后者。
1.1 编译时多态(静态多态)
编译时多态我们其实早就接触过了,就是之前讲的函数重载和函数模板。
它们的共同点是:靠传入的参数类型、参数个数不同去匹配不同的函数逻辑,而整个匹配过程在编译阶段就全部敲定了,程序跑起来之前就定死了要调用哪个函数,所以归为“静态”,叫编译时多态。
1.2 运行时多态(动态多态)
运行时多态才是我们常说的“面向对象多态”。直白讲就是:同一个行为(同一个函数调用),传入不同的类对象,会执行不同的函数逻辑,真正做到“一个接口,多种实现”。
举两个好懂的例子:
- 同样是“买瓜”这个行为:普通人去买,全价付款;华强去买,直接打折拿下。
- 同样是“动物叫”这个动作:传猫对象进去,输出“喵”;传狗对象进去,输出“汪汪”。
二、虚函数与重写(覆盖)
想实现运行时多态,有个绕不开的核心概念,虚函数,它是多态的基础载体。
2.1 什么是虚函数
在类的成员函数前面加上virtual关键字修饰,这个成员函数就叫做虚函数。
这里有几个硬性限制要记牢:
- 只有类的非静态成员函数才能声明为虚函数,全局普通函数不能加virtual。
- 构造函数、静态成员函数,都不能设置成虚函数。
class Person{ public: virtual void BuyWatermalon(){ cout << "买瓜:全价" << endl; } };2.2 什么是虚函数的重写/覆盖
虚函数重写(也叫覆盖)是多态的核心前提:在派生类中,定义一个和基类虚函数完全一致的虚函数,就构成了重写。
这里的“完全一致”有三个硬性标准,简称“三同”:
- 函数名相同
- 参数列表完全相同
- 返回值类型相同
满足这三点,我们就说:派生类的虚函数重写(覆盖)了基类的虚函数,相当于子类用自己的逻辑,替换掉了父类原来的虚函数实现。
举个直观的例子:
class Person { public: virtual void BuyWatermalon() { cout << "买瓜全价" << endl; } }; class HuaQiang : public Person { public: virtual void BuyWatermalon() { cout << "买瓜打折" << endl; } };HuaQiang类里的BuyWatermalon和父类完全满足三同,就构成了重写。
2.3 虚函数的属性会被继承
这里有个很实用的特性:虚函数的virtual属性,会沿着继承链自动传递。
也就是说,只要基类的对应函数加了virtual,子类里重写的函数哪怕不显式写virtual关键字,它也依然是虚函数,依然构成重写。哪怕孙子类、重孙子类继续往下传,这个虚属性也一直保留。
比如下面这段代码,子类没写virtual,照样构成重写:
class Person { public: virtual void BuyWatermalon() { cout << "买瓜全价" << endl; } }; class HuaQiang : public Person { public: void BuyWatermalon() { cout << "买瓜打折" << endl; } // 依然是虚函数,构成重写 };反过来要特别注意:如果基类的函数没加virtual,哪怕子类写了virtual,也构不成虚函数重写,那只是普通的同名隐藏,和多态没关系。
三、多态的构成条件
3.1 实现多态的两个必要前提
运行时多态不是随便写个继承就能触发的,必须同时踩中两个核心条件,缺一不可:
- 必须通过基类的指针或者引用调用虚函数
- 被调用的函数必须是虚函数,且在派生类中完成了重写(Override)
接下来我们逐个拆解,把每个条件背后的道理讲透。
3.2 为什么非得是“基类”的“指针/引用”?
3.2.1 为什么形参一定得是基类类型?
先看第下图的代码:函数Func的形参是Person* ptr,一个父类指针。
我们给它传父类对象的地址&ps,它输出“买票 - 全价”;
传子类Student对象的地址&st,它输出“买票 - 打折”。
同样一句ptr->BuyTicket();执行结果完全不一样。决定结果的不是指针本身的类型,而是指针实际指向的对象是什么类型,这就是运行时多态的核心表现。
有人会问:干嘛不直接用子类指针当形参?
道理很简单:只有基类的指针和引用,才能同时兼容基类对象和所有派生类对象。
这靠的就是我们之前讲的赋值兼容规则:子类指针/引用可以隐式向上转型成基类指针/引用,让基类指针指向子类对象里的基类子对象。反过来基类转子类就行不通。如果形参写成子类指针,那它就只能接收子类对象,父类和其他子类都传不进来,从根上就实现不了“一个接口适配多种对象”的多态。
这里必须纠正一个常见误区:这个转型过程,不是把子类里的父类部分“切下来”单独传过去,只是让基类指针指向了子类对象中属于父类的那一段内存。整个派生类对象自始至终都是完整的,基类指针只是“视角受限”,只能看到父类定义的接口,但它指向的对象本体一点没少。也正因为对象本身是完整的派生类,运行时才能识别出它的真实类型,找到子类重写后的函数完成动态绑定。
说白了:基类指针虽然只看得到父类的接口,但它指着的,可能是个完完整整的子类对象。
3.2.2 为什么必须是指针或引用?传值不行吗?
核心区别就一个:传值会发生对象切片,传指针、传引用不会。 如果形参写成Person p传值调用,你把Student对象传进去,编译器会调用Person的拷贝构造,用子类里的父类成员,拷贝出一个全新的、纯纯的父类对象。这时候对象本身都已经变成父类了,调用函数自然只会跑父类的版本,根本触发不了多态。 指针和引用就不一样了,它们只是拿到了原对象的地址和别名,原对象是什么类型还是什么类型,没有被切割删减,才能保留住多态依赖的真实类型信息。
3.3 函数必须是虚函数,且派生类完成重写
光有基类指针还不够,第二个条件是多态的功能基础:被调用的函数得是虚函数,并且子类完成了重写。 下图把这个关系画得很清楚:父类Person把BuyTicket声明为虚函数,子类Student继承后,写了一个同名、同参、同返回值的BuyTicket,完成了重写,把逻辑换成了“买票半价”。
只有子类重写了父类的虚函数,同一个函数名在父子类里才有了截然不同的实现。运行时程序才能顺着对象的真实类型,找到对应的函数逻辑,指向父类对象就执行全价逻辑,指向子类对象就执行半价逻辑,最终实现“同一个调用,不同对象跑出不同结果”的多态效果。
3.4 一道经典到“坑倒一片”的面试题
先上题,大家可以先在心里预判一下输出结果:
class A{ public: virtual void func(int val = 1){ std::cout<<"A->"<< val <<std::endl;} virtual void test(){ func();} }; class B : public A{ public: void func(int val = 0){ std::cout<<"B->"<< val <<std::endl; } }; int main(int argc ,char* argv[]){ B*p = new B; p->test(); return 0; }猜到答案了吗?这道题的正确率低得离谱,90%的人第一次做都会踩坑。我们一步步拆解,顺便把背后的核心知识点掰碎了讲。
3.4.1 第一步:先确认func是否构成重写
先重申重写的判定标准:我们说的“参数列表相同”,指的是参数类型、参数个数一致,和参数名、缺省参数有没有、缺省值是多少完全没关系。所以A和B里的func,函数名、参数类型、参数个数、返回值全对上了,完完全全构成虚函数重写。这一步很多人就开始错了,以为缺省值不同就不构成重写,其实完全不影响。
3.4.2 第二步:子类指针调用test,执行的是谁的代码?
我们用子类指针p去调用test(),但B类里根本没写test函数,所以调用的是从父类A继承下来的 test,本质执行的是A类里定义的test函数体。 这里再巩固一下继承的本质:子类对象里包含了完整的父类子对象,父类没被重写的成员函数,子类都能直接拿来用,就像自己的一样。
3.4.3 第三步:test里调用func (),到底调的是哪个?
test是A的成员函数,函数里写的func(),本质上是this->func()。 这里的this指针是什么类型?它是A*类型的,因为test是A类的函数,它的this指针天然就是A*。但要注意:这个A类型的this指针,实际指向的是一个B类的对象。
而func是虚函数,通过基类指针调用虚函数,会触发动态绑定:不看指针类型,看对象的真实类型。所以这里最终调用的,是B类里重写后的func函数。
到这儿很多人就拍板了:那结果不就是B->0?恰恰不对,这道题最阴的坑,才刚露出来。
3.4.4 最反直觉的点:缺省参数是静态绑定的
这里直接对应侯捷老师《Effective C++》里的经典条款:绝对不要重定义继承而来的缺省参数值。 为什么会有这条规则?因为虚函数是动态绑定的,但缺省参数是静态绑定的。
什么意思?
- 函数体调用谁:运行时看对象真实类型(动态绑定)→ 最终调用B::func
- 参数默认值用谁:编译时看调用处的静态类型(静态绑定)→ 调用func的this是A *类型,所以用A类func里的默认值
1
重写,重写的只是函数的实现逻辑;函数的缺省参数,是编译阶段就根据调用处的静态类型确定好的,不会跟着重写走。 所以最终的输出结果是:B->1。
这道题的坑就坑在:动态绑定的虚函数,配了个静态绑定的默认参数,两者的规则完全错位,一不留神就掉坑里。这也是为什么工程里极度不建议在重写虚函数时修改默认参数,太容易写出反直觉的bug了。
virtualvoid func(int val = 1){ std::cout<<“A->”<< val <<std::endl;}
void func(int val = 0){std::cout<<“B->”<< val <<std::endl;}
四、虚函数重写的两个特殊场景
虚函数重写的“三同”规则是通用标准,但也存在两个特殊例外:一个是偏冷门的协变,另一个是面试逢考必出的析构函数重写。
4.1 协变(了解即可)
我们常说重写要求返回值类型完全相同,这条规则有一个例外,叫做协变。 协变允许派生类重写虚函数时,返回值类型和基类不一致,但有非常严格的限制条件: 基类虚函数返回某个基类类型的指针或引用,派生类虚函数返回对应派生类类型的指针或引用,并且返回值的这两个类型,本身也必须是public继承的父子关系。
简单说:返回值也得是一对is-a关系的父子类指针/引用,才算合法协变。这个特性实际开发中用得极少,混个眼熟就行。
看个完整示例:
class A {}; class B : public A {}; // 返回值类型本身存在继承关系,这是协变的前提 class Person { public: virtual A* BuyWatermalon() { cout << "买瓜全价" << endl; return nullptr; } }; class HuaQiang : public Person { public: virtual B* BuyWatermalon() { cout << "买瓜打折" << endl; return nullptr; } };这里有个极易踩的误区:很多人以为只要父子类虚函数返回值不同就是协变,完全不是这么回事。必须满足“返回值是有继承关系的指针/引用”这个前提,缺了就会直接编译报错。比如去掉B和A的继承关系,编译器会直接抛出:
error C2555: “HuaQiang::BuyWatermalon”: 重写虚函数返回类型有差异,且不是来自“Person::BuyWatermalon”的协变
这个坑很容易记混,隔段时间回头看大概率会忘,特意标出来提醒一下。
4.2 析构函数的重写(面试高频考点)
另一个更重要的特殊情况,是析构函数的重写。按常理说,父类析构叫~A,子类析构叫~B,连函数名都不一样,根本不符合“三同”规则。但编译器对析构函数做了特殊处理:编译阶段,所有类的析构函数名会被统一处理为内部名称destructor,这样一来,父子类析构就天然满足了重写的条件。
所以只要基类的析构函数加了virtual,派生类的析构函数哪怕不显式写virtual,只要定义了,就会和基类析构构成重写。
4.2.1 为什么基类析构函数强烈建议写成虚函数?
这是C++面试的经典问题,核心原因和“基类指针指向子类对象”的场景深度绑定。
先看反面案例,如果基类析构不是虚函数:
class A{ public: ~A(){cout << "~A()" << endl;} }; class B : public A{ public: ~B(){ cout << "~B()->释放堆资源" << endl; } }; int main(){ A* p = new B; delete p; // 只会调用A::~A(),B的析构完全没执行! return 0; }这时delete p只会根据指针的静态类型A*去调用A的析构,子类B的析构根本没机会执行。如果B类内部申请了堆内存、打开了文件,直接就会造成资源泄漏。
而给基类析构加上virtual之后,问题就迎刃而解了:
class A{ public: virtual ~A(){cout << "~A()" << endl;} };此时执行A* p = new B; delete p;会触发虚函数动态绑定:
先根据对象的真实类型(B 类)调用B::~B(),清理子类独有的资源;等子类析构执行完毕,编译器会自动补调用A::~A(),完成父类部分的清理,整个对象的析构过程就完整了。
4.2.2 析构虚函数和普通虚函数的区别
这里要特别区分一个细节:析构的重写和普通虚函数重写逻辑不一样。 普通虚函数,动态绑定只会调用对应类型的那一个函数;但析构函数不是,虚函数机制只负责帮你定位到子类的析构函数,执行完子类析构后,编译器会自动沿着继承链向上,依次调用每一层父类的析构。
完整的执行流程是这样的:
给基类析构加virtual,本质就是为了保证,用基类指针delete子类对象时,能按照对象的真实类型完整析构,不会漏掉子类的资源清理逻辑。
五、override&final:C++11给虚函数加的两道保险
前面讲完虚函数重写的规则,想必你也能感觉到:重写的条条框框不少,但写代码时手滑写错个字母、参数差个类型,都是常有的事。最头疼的是,这类失误编译期根本不会报错,代码能正常编译通过,但多态就是不生效,等运行出问题再回头排查,费时又费力。
针对这个痛点,C++11专门引入了两个关键字,给虚函数重写加上了编译期的安全保障:
- override:主动校验,帮你检查子类函数是否真的完成了重写
- final:主动封禁,让函数不能被重写、类不能被继承
5.1 override:编译期帮你把好重写这道关
override用在子类重写的虚函数末尾,作用只有一个:让编译器强制检查,这个函数到底有没有成功重写基类的虚函数。一旦不满足重写条件,直接编译报错,把bug掐死在写代码阶段。
举个最常见的翻车场景:手滑拼错了函数名
class Car { public: virtual void Dirve() // 基类函数名是 Dirve {} }; class Benz : public Car { public: // 手误拼成了 Drive,和基类函数名不一致 virtual void Drive() override { cout << "Benz舒适" << endl; } };要是没加override,这段代码能顺利编译,子类的Drive和父类的Dirve只是构成同名隐藏,不会触发任何报错,直到运行时发现多态失效,才要一点点排查问题。
加了override就完全不一样了,编译器当场抛出错误:
error C3668: “Benz::Drive”: 包含重写说明符“override”的方法没有重写任何基类方法
这里顺便区分一下:它和我们熟悉的assert完全不是一回事,assert是运行时检查,程序跑起来才会触发;而override是编译期检查,程序还没运行就能揪出问题,排查成本低得多。 工程实践里非常推荐:只要是子类重写虚函数,都默认加上override,是个性价比极高的编码习惯。
5.2 final:给函数/类“封顶”,禁止重写与继承
final的作用和override刚好相反:它用来做限制,让某个虚函数不能再被子类重写,或是某个类不能再被继承。
修饰虚函数:禁止子类重写
给基类的虚函数加上final关键字,就相当于给这个函数“封了顶”。任何子类再试图重写它,都会直接编译失败。
class Car{ public: virtual void Drive() final {} // 声明为 final,禁止子类重写 }; class Benz : public Car{ public: virtual void Drive() { cout << "Benz舒适" << endl; } // 编译报错 };对应的报错信息:
error C3248: “Car::Drive”: 声明为“final”的函数无法被“Benz::Drive”重写
final也可以直接修饰整个类(比如 class Car final {}),效果是这个类彻底不能被继承,和我们之前讲的“不可被继承的类”是同一种用法。
六,重载/重写/隐藏的对比
注意:这个概念对比经常考,大家得理解记忆一下
七、纯虚函数与抽象类
如果基类里的某个虚函数本身不需要有实际实现,核心意义就是强制子类去重写、给出各自的逻辑,那我们就可以把它定义成纯虚函数。
在虚函数声明的末尾加上=0,这个函数就成了纯虚函数。纯虚函数通常只写声明即可,不需要写函数体(语法上允许实现,但基类里写了也没什么实际价值,它的使命就是等待子类重写)。
包含纯虚函数的类,叫做抽象类(也叫接口类)。抽象类最核心的限制是:不能实例化出对象。如果派生类继承后没有重写这个纯虚函数,那派生类也还是抽象类,照样创建不了对象。 说白了,纯虚函数就是给子类下了一道强制重写的死命令,不把函数实现写出来,你就别想实例化对象。
class Car{ public: virtual void Drive() = 0; // 纯虚函数 }; class Benz : public Car{ public: virtual void Drive(){ cout << "Benz舒适" << endl; } };如果强行实例化抽象类,编译器会直接报错:
error C2259: “Car”: 无法实例化抽象类
message: “void Car::Drive(void)”: 是抽象的
抽象类虽然不能创建对象,但完全可以用来定义指针和引用。靠基类指针指向不同的子类对象,就能实现标准的多态调用:
int main(){ Car* pBenz = new Benz; pBenz->Drive(); Car* pBMW = new BMW; pBMW->Drive(); return 0; }最后收拢一下抽象类的核心规则:
- 不能实例化对象
- 不能按值作为函数参数
- 不能按值作为函数返回值
- 可以定义指针或引用,用于实现多态
八、多态的底层原理
前面讲了多态的用法和规则,这一节我们往底层挖:为什么同一句函数调用,指向不同对象就能执行完全不同的逻辑?多态到底是怎么跑起来的?
8.1 先认识核心角色:虚函数表指针
先从一道经典笔试题切入,看看你能不能答对:32位环境下,下面代码的运行结果是多少?
class Base{ public: virtual void Func1(){ cout << "Func1()" << endl; } protected: int _b = 1; char _ch = 'x'; }; int main(){ Base b; cout << sizeof(b) << endl; return 0; }很多人第一反应是:int 4字节 + char 1字节,内存对齐后总共8字节。但实际运行结果是12字节。
多出来的4字节从哪来?
答案是:只要类里存在虚函数,对象内存的最开头就会多一个隐藏的指针,虚函数表指针(vptr,virtual function table pointer)。32位下指针占4字节,加上原本的成员再做内存对齐,刚好就是12字节。
补充说明:虚表指针的位置不是C++标准强制规定的,x86平台下的VS、GCC等绝大多数编译器都会放在对象开头,少数平台可能放在末尾,属于编译器的实现细节。
这个指针指向的东西,就叫虚函数表(简称虚表,vftable)。一张虚表里,按顺序存着这个类所有虚函数的地址。
一句话先记牢:有虚函数的类,编译器会为它生成一张虚表;这个类实例化出的每个对象,都会自带一个虚表指针,指向这张虚表。
8.2 多态到底是如何实现的
我们回到经典的买瓜案例,从底层视角拆解:ptr->BuyWatermalon()是怎么做到指向Person就跑全价逻辑、指向HuaQiang就跑打折逻辑的?
class Person { public: virtual void BuyWatermalon() { cout << "买瓜全价" << endl; } private: string _name; }; class HuaQiang : public Person { public: virtual void BuyWatermalon() { cout << "买瓜打折" << endl; } private: string _id; }; void Func(Person* ptr){ ptr->BuyWatermalon(); } int main(){ Person ps; HuaQiang st; Func(&ps); Func(&st); return 0; }整个动态调用的过程,其实就三步:
- 基类指针指向了一个具体的对象(可能是纯父类对象,也可能是子类对象里的父类子对象)。
- 程序运行时,先从指针指向的对象内存开头,取出虚表指针,找到这个对象对应的虚表。
- 在虚表里定位到对应虚函数的地址,再跳转执行函数。
父类和子类有各自独立的虚表:Person的虚表里存的是Person::BuyWatermalon的地址;HuaQiang的虚表里,这个位置已经被重写后的HuaQiang::BuyWatermalon覆盖掉了。所以指针指向谁,就去谁的虚表里找函数,自然就跑出了不同结果。
多态本质:运行时根据对象的真实类型,到对应虚表中查找函数地址,再完成调用。而整套虚表、虚表指针的结构布局,在编译阶段就已经全部生成好了。
8.3 静态绑定与动态绑定
我们常说的编译时多态、运行时多态,对应的就是两种函数地址的绑定方式。
8.3.1 两个核心概念
- 静态绑定(早绑定):函数调用的地址,在编译阶段就彻底确定了。普通函数调用、非虚函数的成员调用,都属于静态绑定。
- 动态绑定(晚绑定):函数调用的地址,要等程序运行起来,根据对象的真实类型才能最终确定。满足多态条件的虚函数调用,就是动态绑定。
8.3.2 从汇编视角看两者差异
把调用语句拆成汇编指令,差别一眼就能看出来。
动态绑定场景(基类指针调用虚函数):
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 指向的函数。(虚函数表中的虚函数)
能很清晰地看到:全程要先找对象、再查虚表、最后才取地址调用,不到运行那一刻,根本不知道最终call的是哪个函数。
静态绑定场景(非虚函数调用):没有查表这一步,编译器在编译阶段就直接算出了函数的固定地址,call后面直接跟一个确定的地址,运行时直接跳转执行即可。
8.4 虚函数表的细节补充
8.4.0 虚表什么时候生成?
虚表绝对不是运行时创建的,虚函数表在编译阶段就已经生成完毕。只要类里有虚函数,编译器就会为这个类构造出一张虚表,按顺序填好所有虚函数的地址。 而对象里的虚表指针vptr,是在对象构造阶段,由构造函数完成初始化,让它指向当前类对应的虚表。
8.4.1 同类共用,异类独立
同类型的所有对象,共用同一张虚表,不会每个对象单独生成一份,避免空间浪费。不同的类(基类和派生类)有各自独立的虚表,互不干扰。
8.4.2 继承下的虚表指针
子类继承父类时,父类的虚表指针会一起被继承下来。普通单继承场景下,子类不会再额外生成新的虚表指针,对象开头这一个vptr就够用了。 但要注意:子类对象里的虚表指针,和单独父类对象的虚表指针不是同一个,就像子类里的父类成员,和独立父类对象的成员是各自独立的两份。
8.4.3 派生类的虚表里都存了什么?
派生类的虚表,是在基类虚表的基础上生成的,内容分为三块:
- 基类中没被重写的虚函数,地址直接沿用基类的;
- 基类中被子类重写的虚函数,对应位置会被覆盖成子类重写后的函数地址;
- 子类自己新增的虚函数,会按声明顺序追加到虚表末尾。
这也是为什么重写也叫“覆盖”,本质就是子类函数地址,把虚表里父类原有的地址给盖掉了。
8.4.4 虚函数、虚表到底存在内存哪?
很多人会把这几个概念搞混,我们分开说清楚:
- 虚函数本身:和普通成员函数没有本质区别,编译后就是一段二进制指令,都存在代码段里。区别只是虚函数的地址会额外存一份到虚表中。
- 虚函数表:C++ 标准没有强制规定存储位置,主流编译器(VS、GCC 等)都会把虚表放在代码段(常量区),属于只读数据,程序运行期间不会修改。
- 虚函数表指针:存在对象的内存里,是对象的一部分,对象在栈上创建它就在栈上,对象在堆上创建它就在堆上。
Person b; HuaQiang d; Person* p3 = &b; HuaQiang* p4 = &d; printf("Person虚表地址:%p\n", *(int*)p3); printf("HuaQiang虚表地址:%p\n", *(int*)p4);8.4.5 一些边角细节
虚表本质上就是一个存储虚函数指针的数组。多数编译器实现里,数组末尾会放一个空指针(0x00000000)作为结束标记,但这不是C++标准规定的。VS系列编译器会加这个标记,GCC 就不一定,完全属于编译器自己的实现细节。