C++ 多态底层原理
- 什么是多态
- 编译期多态是什么
- 运行期多态是什么
- 虚函数是怎么实现动态绑定的
- 什么是虚函数表(vtable)
- 什么是虚表指针(vptr)
- 一个含虚函数的对象,内存里通常比普通对象多了什么
- 调用虚函数时,程序大致经历了什么过程
- 为什么基类指针/引用指向派生类对象时,调用的是派生类重写后的函数
- 为什么普通成员函数默认不是动态绑定
- 析构函数为什么很多时候要写成虚函数
- 纯虚函数是什么,抽象类和它的关系是什么
追问 1:请你说一下下面这段代码的执行结果,以及底层为什么会这样:
1 |
|
你要讲清楚:
p->f()为什么调到Derived::fdelete p为什么会先调派生析构,再调基类析构- 如果
~Base()不是虚函数,会有什么问题
追问 2
构造函数里调用虚函数,会发生动态绑定吗?
比如:
1 | class Base { |
创建 Derived d; 时,这里打印什么?为什么?
我不要你只答结果,我要你解释:
- 构造期间对象“当前被视为谁”
- 为什么这时候不能认为它已经是完整的派生类对象
追问 3
析构函数里调用虚函数呢?和构造函数有什么相似点?
追问 4
纯虚函数是不是“没有实现”?
比如:
1 | class Base { |
你要回答:
- 纯虚函数能不能在基类里提供定义
- 抽象类能不能有成员变量
- 抽象类能不能有构造函数
- 为什么抽象类不能实例化
追问 5
一个类只要有虚函数,对象里就一定只有一个 vptr 吗?
你可以顺着讲:
- 单继承时通常怎样
- 多继承时会怎样
- 为什么多继承会让对象模型更复杂
追问 6
什么是对象切片(object slicing)?
比如:
1 | class Base { |
这里输出什么?为什么?
加问:菱形继承和虚继承
这个很喜欢被拿来拷底层。
请你说一下:
- 什么是菱形继承
- 为什么会有数据冗余和二义性
virtual public Base是怎么解决的- 虚继承的代价是什么
- 为什么虚继承会让对象内存布局更复杂、访问成员可能多一次间接寻址
小追问 1
虚函数能不能是内联函数?
小追问 2
静态成员函数能不能是虚函数?为什么?
一、编译期多态 和 运行期多态
C++ 里多态一般分两类。
1. 编译期多态
这个也叫静态多态。典型就是:
- 函数重载
- 运算符重载
- 模板
它的特点是:编译阶段就已经决定调用哪个函数了。
比如:
1 | void foo(int) {} |
你传 int 还是 double,编译器在编译期就选好了,不需要运行时再判断。
2. 运行期多态
这个才是平时说的“虚函数多态”。
它的特点是:
编译时先保留一种“动态分派”的机制,真正调用哪个函数,要等运行时看对象的真实类型再决定。
典型就是:
1 | class Base { |
然后:
1 | Base* p = new Derived(); |
这里虽然 p 的静态类型是 Base*,但运行时它实际指向 Derived 对象,所以调用的是 Derived::func()。
二、虚函数怎么实现动态绑定
底层上,主流编译器一般会通过虚函数表 vtable 和 虚表指针 vptr 来实现。
注意,我会说“通常”“一般”,因为这是主流实现方式,不是标准强制规定死的具体内存布局。
三、什么是 vtable,什么是 vptr
1. vtable
虚函数表,本质上你可以把它理解成:
一个函数指针表
里面存着这个类对应的虚函数入口地址。
如果类里有多个虚函数,这张表里通常就有多个槽位,每个槽位对应一个虚函数实现地址。
比如:
1 | class Base { |
那 Base 通常就会有一张属于自己的虚表,里面大概有 &Base::f、&Base::g 这些入口。
如果 Derived 重写了其中某个虚函数,那它自己的虚表对应槽位里就会换成 &Derived::f。
2. vptr
虚表指针,就是:
对象里面偷偷放的一个指针,指向所属类的虚函数表。
也就是说,对象要想在运行时知道“我该调用哪张虚表里的哪个函数”,它自己身上通常得带一个 vptr。
四、一个含虚函数的对象,内存里通常比普通对象多了什么
通常会多一个 vptr。
比如一个普通类:
1 | class A { |
对象里可能就只有 x。
但如果你写:
1 | class A { |
那对象内存里通常就会变成类似:
1 | [vptr][x] |
当然具体顺序、对齐细节跟编译器实现有关,但你可以理解成:
对象里通常多了一个指向虚表的隐藏指针。
所以带虚函数的类对象,通常会比不带虚函数的对象更大一些。
五、调用虚函数时,大致经历了什么过程
这个是面试特别喜欢问的“调用链路”。
比如:
1 | Base* p = new Derived(); |
大致过程可以这样理解:
第一步
编译器看到 func 是虚函数,所以不会像普通函数那样直接把调用点静态绑定到 Base::func。
第二步
程序运行时,先通过 p 找到它指向的对象。
第三步
从对象内存里取出隐藏的 vptr。
第四步
通过 vptr 找到这张对象所属类型的虚函数表 vtable。
第五步
在虚表中找到 func 对应槽位的函数地址。
第六步
跳转到这个地址执行。
如果这个对象实际是 Derived,那它的 vptr 指向的就是 Derived 那张虚表,所以最终找到的是 Derived::func。
六、为什么基类指针/引用指向派生类对象时,调用的是派生类重写后的函数
因为虚调用看的不是“指针本身声明成什么类型”,而是:
这个指针当前指向的对象,里面的 vptr 指向哪张虚表。
比如:
1 | Base* p = new Derived(); |
虽然 p 的静态类型是 Base*,但它指向的那块对象内存其实是 Derived 对象。
而 Derived 对象里的 vptr 通常会指向 Derived 的虚表。
所以调用虚函数时,最终查表得到的是 Derived 版本。
这就是为什么运行期多态能成立:
调用结果取决于对象真实类型,而不是指针表面类型。
七、为什么普通成员函数默认不是动态绑定
因为普通成员函数如果也全都动态绑定,那代价会比较大:
- 每个调用都得多一层间接查表
- 对象模型更重
- 编译器更难做内联、优化、去虚拟化等静态优化
而且大多数函数其实并不需要运行时多态。
很多时候我们就是明确知道该调哪个函数,静态绑定更直接、更高效。
所以 C++ 的设计是:
默认静态绑定,只有你显式写成 virtual,才启用动态绑定。
这也体现了 C++ 一贯的思路:
不为你没用到的特性付额外开销。
普通成员函数
1 | p->f(); |
如果 f 不是虚函数:
- 编译期就决定了调用哪个函数
- 只看指针/引用的静态类型
- 本质接近直接函数调用
- 没有查虚表的过程
比如静态类型是 Base*,那就直接调 Base::f
虚函数 / 重写函数
1 | p->f(); |
如果 f 是虚函数:
- 编译期只知道“这是一次虚调用”
- 运行时再根据对象真实类型查虚表
- 通过对象里的
vptr找函数地址 - 所以可能调到派生类重写版本
八、析构函数为什么很多时候要写成虚函数
这个是高频重点。
如果一个类要被当作基类使用,并且你可能通过基类指针删除派生类对象,那基类析构函数通常必须是虚函数。
比如:
1 | class Base { |
如果 Base 的析构函数不是虚函数,那这里通常只会调用 Base::~Base(),不会正确走到 Derived::~Derived(),这样派生类自己的资源就可能泄漏。
而如果基类析构函数是虚的:
1 | class Base { |
那 delete p 时就会根据对象真实类型走动态绑定,先调 Derived 析构,再回到 Base 析构,这样销毁链条才完整。
所以你可以直接背一句:
只要一个类打算作为多态基类使用,析构函数一般就应该写成虚函数。
九、纯虚函数是什么,抽象类和它的关系是什么
纯虚函数就是在虚函数后面写 = 0:
1 | class Base { |
它的意思是:
这个函数在基类里不给具体实现,要求派生类去实现。
带有至少一个纯虚函数的类,就叫抽象类。
抽象类的特点是:
- 不能直接实例化
- 主要用来定义接口规范
- 派生类如果不实现所有纯虚函数,那它自己也还是抽象类
比如:
1 | class Shape { |
这里 Shape 就更像一个接口层,规定“你们都要有 draw()”,但自己不关心具体怎么画。
十、顺手补一句对象模型上的理解
从对象模型角度说,运行期多态的核心其实就是:
把“调用哪个函数”这件事,从编译期固定死,延迟到运行时通过对象内的 vptr 去查表决定。
所以虚函数多态本质上是一种:
对象中携带类型分派信息,调用时做一次间接跳转。
十一、面试里适合直接背的一版
“多态本质上是同一套接口在不同对象上表现出不同的行为。C++ 里编译期多态主要是函数重载、运算符重载和模板,调用目标在编译期就确定;运行期多态主要靠虚函数实现,真正调用哪个版本要到运行时根据对象真实类型决定。主流实现里,编译器通常通过虚函数表和虚表指针来支持动态绑定。每个含虚函数的类通常会有一张虚表,里面存放虚函数地址;每个该类对象里通常会隐藏一个虚表指针 vptr,指向所属类的虚表。所以一个带虚函数的对象,内存里通常会比普通对象多一个 vptr。调用虚函数时,程序会先通过对象找到 vptr,再找到虚表,再根据槽位取出对应函数地址进行调用。正因为基类指针指向派生类对象时,对象内部的 vptr 指向的是派生类虚表,所以最终能调到派生类重写后的函数。普通成员函数默认不是动态绑定,是因为静态绑定更高效,也符合 C++ 不为没用到的特性付成本的设计。析构函数很多时候要写成虚函数,是因为需要支持通过基类指针正确销毁派生类对象。纯虚函数就是 virtual f() = 0;,含有纯虚函数的类叫抽象类,不能直接实例化,通常用来定义接口。”
追问5
一、单继承时通常怎样
先看最简单的情况:
1 | class Base { |
这种单继承 + 有虚函数的场景下,主流实现里通常会这样做:
Base对象里放一个vptrDerived继承Base后,对象里通常也只需要保留这一套虚函数分派入口- 所以
Derived对象里通常也就是一个vptr
你可以把它粗略想成:
1 | Derived object: |
调用虚函数时,就通过这个 vptr 找到 Derived 对应的虚表。
所以在大多数面试语境里,说:
“单继承时,一个对象通常只有一个 vptr。”
这个是比较稳的。
二、多继承时会怎样
多继承一来,情况就复杂了。
比如:
1 | class B1 { |
这里 D 同时继承了两个都有虚函数的基类:B1 和 B2。
这时对象内存通常不再是“一套基类子对象 + 一根 vptr”那么简单。
因为 D 里实际上包含了两个基类子对象:
- 一个
B1子对象 - 一个
B2子对象
而这两个基类子对象各自都可能需要支持“从各自基类视角出发”的虚函数调用。
所以主流实现里,D 对象通常可能长得像这样:
1 | [D对象]: |
也就是说,对象里可能会有多个 vptr,分别对应不同基类子对象的虚表入口。
所以这时候你再说“有虚函数就一个 vptr”,就不对了。
三、为什么多继承会让对象模型更复杂
因为多继承下,一个派生类对象不再只是“一个基类子对象往后扩展”,而是:
一个对象内部同时包含多个基类子对象。
这会带来几个复杂点。
1)对象里可能有多套基类布局
单继承时比较像一条链:
1 | Base -> Derived |
对象内存可以比较自然地看成“基类部分 + 派生类新增部分”。
但多继承时更像并排拼接:
1 | B1 + B2 + D自己的部分 |
所以对象布局不再线性单纯,而是多个子对象的组合。
2)基类指针转换可能要做地址调整
看这个:
1 | D d; |
这里 p1 和 p2 虽然都指向同一个 D 对象,但它们的值未必一样。
因为:
B1子对象可能就在D对象起始位置B2子对象可能在后面的某个偏移位置
所以把 D* 转成 B2* 时,编译器往往需要做一个指针偏移调整。
这就说明多继承下,“一个对象地址”不再总能简单等同于“所有基类子对象地址”。
3)虚函数调用时可能不只是简单查表,还涉及 this 调整
比如你通过 B2* 去调用一个被 D 重写的虚函数:
1 | B2* p = new D(); |
这时候运行时不仅要找到正确的虚函数入口,
有时还需要把传给函数的 this 指针从 B2 子对象地址调整回适合 D 实现函数使用的位置。
所以多继承下的虚调用,底层可能不仅是:
- 取
vptr - 查函数地址
还可能附带:
this指针修正- 不同子对象视角下的虚表组织
这就是它复杂的地方。
4)析构、RTTI、dynamic_cast 等都会更复杂
一旦涉及:
- 虚析构
dynamic_cast- RTTI
- 菱形继承 / 虚继承
对象模型会更复杂,因为运行时需要知道:
- 当前指针指向哪个子对象
- 最完整对象从哪开始
- 各个基类子对象之间的偏移关系
所以多继承不是不能用,而是底层实现成本和理解成本都明显更高。
加问
菱形继承就是指这样一种继承结构:
1 | Base |
比如代码里写成:
1 | class Base { |
这里 Derived 通过两条路径都继承到了 Base,形成一个菱形结构。
一、为什么会有数据冗余
如果这是普通继承,不是虚继承,那 Derived 对象里通常会真的有两份 Base 子对象:
- 一份来自
Left - 一份来自
Right
所以你可以粗略理解成 Derived 的内存像这样:
1 | [Left里的Base子对象][Left自己的部分][Right里的Base子对象][Right自己的部分][Derived自己的部分] |
也就是说,Base::x 会有两份。
这就是数据冗余。
如果 Base 里只是一个 int x,还只是多占点空间;
但如果 Base 里是更大的公共状态、资源句柄、配置字段,那这份重复就很麻烦了。
二、为什么会有二义性
因为 Derived 里有两份 Base,所以当你直接访问基类成员时,编译器不知道你想要哪一份。
比如:
1 | Derived d; |
这里就会二义性报错。
因为编译器会问你:
- 你是想访问
Left路径继承来的那份Base::x - 还是
Right路径继承来的那份Base::x
同理,把 Derived* 直接转换成 Base* 也会有歧义,因为对象里存在两个 Base 子对象,转换目标不唯一。
所以普通菱形继承的问题本质就是两点:
- 存了两份共同祖先数据
- 访问共同祖先成员时路径不唯一
三、virtual public Base 是怎么解决的
解决办法就是让 Left 和 Right 都不要各自独立拥有一份 Base,而是告诉编译器:
“不管从多少条路径继承,最终整个最派生对象里只保留一份共享的 Base 子对象。”
代码就是这样写:
1 | class Base { |
这里 virtual public Base 的意思不是“Base 的函数虚了”,而是:
这是虚继承,Base 是一个虚基类。
这样一来,Derived 对象里最终只会有一份 Base。
于是:
- 数据不再重复
x也只有一份- 从
Derived看Base也就不再二义了
所以虚继承解决菱形继承问题的核心思想就是:
把重复祖先基类合并成一份共享虚基类子对象。
四、虚继承的代价是什么
虚继承不是白送的,它的代价主要是:
1. 对象内存布局更复杂
普通单继承、普通多继承,很多时候对象布局相对比较直接,基类子对象位置在编译期比较容易固定。
但虚继承下,共享的那个虚基类子对象在最终最派生对象中的位置,不再像普通继承那样简单固定在某个线性位置。
编译器通常需要额外保存一些虚基类偏移信息,用来在运行时找到那唯一的一份 Base 子对象。
所以对象里往往会多出一些用于支持虚继承的隐藏指针或偏移表信息。不同编译器实现细节不同,但你可以理解为:
为了定位共享虚基类,运行时需要额外元信息。
2. 成员访问可能多一次间接寻址
在普通继承里,如果编译器知道 Base 子对象就在对象起始处偏移 0,或者偏移固定值,那访问 Base::x 可能直接就是固定偏移寻址。
但虚继承下,因为 Base 是共享虚基类,它在对象里的位置可能要通过隐藏信息先查出来。
于是访问这份虚基类成员时,常常会变成:
- 先通过对象里的某个隐藏指针/表,找到虚基类
Base的实际偏移 - 再跳到那份
Base子对象位置 - 再访问
x
所以相比普通继承,它可能多一层间接定位。
这就是你说的“多一次间接寻址”的来源。
3. 指针转换和对象模型更复杂
比如把 Derived* 转成 Base*,在普通继承里很多时候只是简单固定偏移。
但在虚继承里,编译器要根据虚基类布局规则去算“共享那份 Base 在哪”。
所以:
- 指针调整更复杂
- RTTI / dynamic_cast 处理也更复杂
- 构造析构时虚基类的初始化顺序也更特殊
4. 运行时开销和实现复杂度更高
虽然通常不是特别大的性能灾难,但和普通继承相比,虚继承确实:
- 布局更复杂
- 寻址可能更绕
- 编译器生成代码更重
- 理解和维护成本更高
所以虚继承一般是“为了解决菱形共享基类问题而付出的代价”。
五、为什么虚继承下对象布局更复杂
这个你可以从“Base 到底放哪”来理解。
普通菱形继承
每条继承路径各带一份 Base,位置简单,虽然重复但直观:
1 | Derived: |
虚继承
要保证最终只有一份 Base,那这份 Base 就得作为“公共共享部分”放在某个统一位置。
问题是:
Left单独存在时Right单独存在时Derived作为最派生对象时
这份虚基类的位置关系会更灵活,不像普通继承那样死板固定。
于是编译器往往需要通过额外机制告诉对象:
“如果你想找虚基类 Base,请按这份偏移信息去找。”
这就让布局和访问都更复杂。
六、你可以怎么总结“虚继承解决了什么,代价又是什么”
它解决了什么
- 解决菱形继承里的共同祖先重复存储问题
- 解决共同祖先成员访问二义性问题
它付出了什么
- 增加对象模型复杂度
- 可能引入额外隐藏指针/偏移表
- 成员访问和指针转换可能多一层间接计算
- 编译器实现和程序员理解成本都更高