多态及其虚函数底层原理
1️⃣ 什么是多态?
众所周知,多态 是面向对象三大特性的核心。很多朋友停留在 “加个 virtual 关键字就能实现多态” 的表层认知。
多态的核心在于:同一个接口,不同的实现。举个简单的例子:
假设有一台通用机器人(基类),它能干很多事,比如打扫、修理等。咱们根据实际需要,造出了清洁机器人(派生类)和修理机器人(另一个派生类),它们具体干的活就不一样了。
那么,在 C++ 实现多态的底层原理是什么呢?这一切的答案就在于 虚函数表。接下来,将会从以下几个方面进行讲解:
C++的绑定方式- 虚函数表
vtable的结构 - 虚指针
vptr与vtable的关系以及内存布局 - 虚函数的实现流程
- 面试常问问题
2️⃣ C++ 的绑定方式
在 C++ 中,“绑定” 通常是将 “函数调用” 和 具体的 “函数实现” 关联起来。这个过程可能发生在 编译期,也可能发生在 运行期,因此,C++ 中的绑定方式分为 静态绑定 和 动态绑定。
静态绑定
静态绑定 又称 早绑定,是 C++ 中默认的绑定方式。
- 发生时机: 在 编译阶段 由编译器完成。
- 核心逻辑: 编译器根据调用的对象类型、函数名及参数列表,直接确定要执行的代码地址。
- 代码案例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
using namespace std;
// 函数重载示例
void display(int i) {
cout << "Here is int: " << i << endl;
}
void display(double d) {
cout << "Here is double: " << d << endl;
}
int main() {
display(5); // 调用 int 版本的 display
display(5.5); // 调用 double 版本的 display
return 0;
}
动态绑定
动态绑定 又称 晚绑定,主要用来实现 多态。
- 发生时机: 在 运行阶段 由对象的实际类型来确定。
- 核心逻辑: 通过 虚函数表 和 虚函数指针 来实现。程序在 运行时 查看对象的实际类型,从而调用对应派生类或基类中的函数。
- 代码案例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20class Base {
public:
virtual void show() {
cout << "Base class" << endl;
}
};
class Derived : public Base {
public:
void show() override {
cout << "Derived class" << endl;
}
};
int main() {
Base* basePtr = new Derived();
basePtr->show(); // Outputs: Derived class
delete basePtr;
return 0;
}
3️⃣ 虚函数表 vtable
定义:类级别的全局只读函数指针数组
存储位置:
vtable存于程序的.rodata段(只读数据区)。与全局变量、字符串常量同级,生命周期与程序一致(程序退出时释放);核心作用: 作为 “地址映射表”,关联类与虚函数的实现。
Base类的vtable存Base::foo()的地址Derived类的vtable存Derived::foo()的地址。
代码案例:类
A有 3 个虚函数foo()、bar()、baz(),其vtable的结构如下:索引 函数地址 对应函数 0 &A::foo foo() 1 &A::bar bar() 2 &A::baz baz() 3 &type_info for A RTTI 信息(可选)
4️⃣ 虚指针 vptr 及其内存布局
定义:虚指针 vptr 是对象实例中的一个隐藏成员,指向当前对象所属类的虚函数表。
- 大小: 4/8 字节,由平台决定。
- 位置: 有编译器决定,通常位于 对象内存头部。
vtable 与 vptr 的关系
- 一个类只有一个
vtable - 通过类生成的对象都有一个
vptr,指向类的vtable - 多对象共享
vtable
内存布局
不含虚函数
- 空类
- 大小: 1 B
- 原因: 编译器的优化,为了让 “两个空类对象的地址不同”
包含虚函数
- 空类
- 大小: 4/8 B
- 原因: 类中的成员隐含了虚指针
vptr
5️⃣ 虚函数机制实现流程
接下来,通过一个案例来讲解 动态绑定 的实现
1 | Robot* robot = new CleaningRobot(); |
- 编译期
- 编译器为类
Robot和CleaningRobot生成各自的虚函数表vtable。 - 虚函数表
vtable存储该类的所有虚函数地址。 - 对于派生类
CleaningRobot,如果重写了基类Robot的某个虚函数(例如performTask),编译器会将派生类的函数地址覆盖基类虚函数表中对应的位置。这一覆盖过程是编译器在生成CleaningRobot的虚函数表时完成的。 - 与此同时,编译器会为支持虚函数的类(如
Robot和CleaningRobot)的对象添加一个隐式指针vptr。 - 编译器还会在构造函数中插入代码,用于初始化对象的
vptr。
- 编译器为类
- 基类指针指向派生类对象:
Robot是一个基类类型的指针,但它实际指向的是CleaningRobot类型的对象。- 在对象构造过程中,
vptr被依次初始化。- 首先调用基类的构造函数,将
vptr指向Robot的虚函数表 - 接着调用派生类的构造函数,更新
vptr,最终指向 CleaningRobot 的虚函数表。
- 首先调用基类的构造函数,将
- 运行期
- 当调用
robot->performTask()时,程序通过robot指针访问到CleaningRobot对象的vptr。 vptr指向CleaningRobot的虚函数表,程序在虚函数表中根据函数的索引找到CleaningRobot::performTask的地址。
- 当调用
- 跳转到派生类函数执行:
- 最终,程序根据虚函数表中记录的地址跳转到
CleaningRobot::performTask并执行该函数。
- 最终,程序根据虚函数表中记录的地址跳转到
示意图

6️⃣ 多重继承
多重继承(派生类有多个直接基类)是复杂场景的核心
- 派生类的
vptr数量与直接基类数量一致, - 每个基类对应一个
vtable,派生类新增的虚函数追加到 “第一个基类的vtable” 中。
案例
1 | class Base1 { |
Derived有 2 个直接基类Base1和Base2,因此包含 2 个vptr:vptr1:指向Base1的vtable。包含
Base1的虚函数 以及Derived新增的baz()vptr2:指向Base2的vtable。仅包含
Base2的vtable。
- 指针偏移
- 不同基类指针指向同一派生类对象时,地址可能不同!!!
1
2
3Derived* d = new Derived();
Base1* b1 = d; // b1地址 == d地址(指向vptr1)
Base2* b2 = d; // b2地址 != d地址(指向vptr2,需要偏移) - 解析:
- 编译器需要让
b2的vptr指向Base2的vtable,因此会自动计算偏移量。比如
d地址 + 8 字节,跳过vptr1和x)。 - 如果手动强制转换,如
(Derived*) b2,会导致地址计算错误,访问到错误的vptr,进而调用错误的虚函数(甚至崩溃)。
- 编译器需要让
- 不同基类指针指向同一派生类对象时,地址可能不同!!!
菱形继承
菱形继承 指的是:一个派生类 Derived 通过两个间接基类 Base1 和 Base2 来继承同一个基类 Base。此时,会导致基类 Base 的成员二义性问题。
1 | class Base { |
此时 Derived 会有 2 个 Base 的副本(x1、x2)和 2 个 Base 的 vtable。
调用 Derived d; d.x 会报二义性错误,vtable 也存在冗余。
底层优化:Offset Table
方法:虚继承通过 “共享基类 + Offset Table(偏移量表)” 解决菱形继承问题。
1 | class Base1 : virtual public Base {}; // 虚继承 |
Derived的底层结构会新增一个Offset Table(偏移量表),它存储 “派生类对象Derived到 共享基类Base的偏移量”,确保所有基类指针都能找到同一个 Base 副本。vtable结构发生变化,Base1和Base2的vtable会包含 “指向Offset Table的指针”,而非直接包含Base的vtable—— 这样Base的vtable被共享,避免冗余。
7️⃣ 纯虚函数
纯虚函数 是抽象类的核心,表现形式常如下:
1 | class Base { |
其常呈现以下两个特点:
- 在
vtable中的地址通常是NULL- 纯虚函数在
vtable中会占据一个条目,但地址通常是NULL或特殊标记。
- 纯虚函数在
- 不可被实例化
- 因为抽象类的
vtable中存在 “未实现的纯虚函数条目”(如NULL)。如果允许实例化,调用纯虚函数会导致 “空指针调用” 崩溃 —— 因此编译器会禁止抽象类实例化。
- 因为抽象类的
8️⃣ RTTI
RTTI 是 Run-Time Type Information 的简称,常被称为: C++ 运行时类型识别。
其核心依赖虚函数表 —— typeid 和 dynamic_cast 都通过 vtable 获取类型信息。
接下来,会讲解具体的工作原理:
1️⃣ type_info 结构体
- 每个含 虚函数 的类,其
vtable中会存储一个type_info结构体的指针。 type_info包含类名、类型 ID 等元数据,是RTTI的核心数据结构。
2️⃣ typeid 的工作流程
当调用 typeid(ptr)(ptr 是 Base*)时,流程是:
- 通过
ptr指向的对象,获取其vptr; - 通过
vptr找到vtable,获取type_info指针; - 返回
type_info对象,用于比较类型。
3️⃣ dynamic_cast 的底层逻辑
dynamic_cast<Derived*>(base_ptr) 用于 “基类指针向派生类指针的安全转换”,底层依赖 vtable:
- 通过
base_ptr的vptr找到vtable,获取type_info; - 检查目标类型(
Derived)是否与type_info匹配,或是否是派生关系; - 若匹配,返回调整后的派生类指针;若不匹配,返回
NULL(指针)或抛出异常(引用)。
9️⃣ 面试常问问题
虚表会不会拖慢程序?
不会太慢。
- 虚函数表查找的时间复杂度是
O(1),可以理解为一次 “定向跳转”。 - 虽然比普通函数调用稍慢一点,但性能差距微乎其微,几乎可以忽略不计。
虚函数表是在编译期间还是运行期间生成的?
vtable是在 编译阶段 生成的,每个含有虚函数 的 类 都会有一张自己的 虚函数表。- 在运行时,程序会通过对象的
vptr指向对应的虚函数表。
如果一个类没有虚函数,还会有虚函数表吗?
不会。
- 如果一个 类 没有 虚函数,就不会生成
vtable虚函数表,也没有vptr(虚指针)。 - 这种情况下,函数调用 就是普通的 “静态绑定”,编译器在 编译时 就决定了调用哪个函数。
虚函数表是对象级别的吗?
不是!虚函数表是类级别的,每个类只会生成一张虚函数表。
所有属于这个类的对象共享同一张虚函数表,每个对象只需要一个虚指针(
vptr)指向这张表。如果每个对象都存储一整张虚函数表,会浪费大量内存,因此虚函数表设计为类级别的,共享使用。
为什么析构函数要设为虚函数?
- 如果 析构函数 不是 虚函数,基类指针 指向 派生类对象 时,
delete ptr会触发静态绑定; - 此时只调用 基类析构函数,导致派生类资源泄漏;
- 设为虚函数后,
delete ptr会通过vtable动态绑定到派生类析构函数,确保基类和派生类都被正确析构。
构造函数和析构函数中调用虚函数,会发生什么?为什么?
会执行当前构造 / 析构类的虚函数版本,而非派生类版本。
- 构造/析构时,对象的类型是 “当前类”,
vptr指向当前类的vtable。 - 基类构造时,
vptr指向基类vtable,调用虚函数会执行 基类版本,避免访问未初始化的派生类成员。
多继承时,派生类有几个 vptr?
派生类的 vptr 数量与 “直接基类的数量” 一致。
- 派生类 新增的虚函数,会追加到 第一个基类 的
vtable中。
什么是对象切片?如何避免?
对象切片 是指用 派生类对象 给 基类对象 进行 赋值 ,派生类的部分(包括 vptr)被 “切掉”,仅保留基类部分,导致 vptr 指向基类 vtable,虚函数调用失效。
避免方法:用指针或引用传递多态对象,而非值传递。
C++ 的 RTTI 依赖什么实现?dynamic_cast 和typeid 的底层逻辑是什么?
RTTI 依赖 虚函数表 实现,vtable 中存储了type_info 结构体的指针。
typeid(ptr):通过ptr的vptr找到vtable,获取type_info指针,返回type_info对象;dynamic_cast<Derived*>(base_ptr):通过base_ptr的vptr获取type_info,检查是否与Derived匹配,若匹配则返回调整后的指针,否则返回 NULL。
纯虚函数和抽象类的关系是什么?抽象类为什么不能实例化?
含纯虚函数的类是抽象类。 抽象类不能实例化的原因是:
- 纯虚函数 在
vtable中的条目是NULL(或特殊标记) - 如果允许实例化,调用纯虚函数会导致空指针调用崩溃,因此编译器禁止抽象类实例化。
