C++ 的多态性分为运行时多态与编译时多态,其中前者的实现原理就是虚函数表(vtable)与虚函数指针(vptr)。
1. 基本概念
首先看一下二者的定义:
- 虚函数表:每个包含虚函数的类都有一个虚函数表,它是一个函数指针数组,存储该类所有虚函数的地址。
- 虚函数指针:每个对象实例内部隐含的一个指针,指向其所属类的虚函数表。
从定义上的角度需要关注的是,虚函数表每个是类所拥有的,而虚函数指针是每个实例化的对象说拥有的。
因此虚函数表在编译阶段就已经由编译器自动生成,每个累都有且仅有一个虚表,所有该类的对象共享同一份虚表。 而与之不同的是,虚指针是在对象构造时即运行时,由构造函数隐式初始化,指向当前类的虚表。
只从定义和概念的角度是很难搞清楚其背后的运行机制的,所以接下来,我将结合代码以及它们的内存分布进行深入讲解。
2. 单继承中的虚函数机制
虚函数表布局
先来看一下最简单的单继承类中的虚函数表的布局:
1 | class Base { |
- Base 的虚表:[ &Base::func1, &Base::func2 ]
- Derived 的虚表: [ &Derived::func1, &Base::func2, &Derived::func3 ]
可以看到,由于在派生类 Derived 中对基类的 func1
进行重写,相应的,在虚表中也将 func1
的地址替换成了派生类的地址,而没有进行重写的func2
函数的地址则没有变化直接进行继承。
对象内存布局
对象的内存布局如下:
1 | Derived对象: |
动态绑定过程
1 | Base* obj = new Derived(); |
通过 obj
的虚指针找到 Derived
的虚表,然后根据虚表的索引找到 Derived::func1
的地址并调用。
3. 多继承中的虚函数机制
多继承与单继承的相同点在于,每个类也是独立维护虚表,不同的是派生类的对象包含多个虚指针。此外,派生类新增的虚函数会被追加到第一个基类的虚表(在单继承中不会)中。
1 | class Base1 { virtual void f1() {} }; |
虚表布局如下:
- Base1 虚表:[ &Derived::f1, &Derived::f3 ]
- Base2 虚表:[ &Base2::f2 ]
这里有个看似比较矛盾的现象,即虽然 f3
存在于 Base1
的虚表中,但 Base1
类型的指针无法直接调用 f3
,因为 Base1
类没有声明 f3
。这是C++的语法规则限制。
实际上,**只有通过 Derived
类型的指针或引用,才能直接调用 f3
**:
1 | Derived* d = new Derived(); |
其内存布局如下:
1 | Derived对象: |
4. 菱形继承与虚继承的虚函数机制
菱形继承的定义在这里不做介绍,它的问题是, 非虚继承时,顶层基类(Base
)会在派生类中存在多个副本,导致数据冗余和二义性。
示例代码:
1 | class Base { |
- 数据冗余:
Derived
对象包含两个独立的Base
子对象(通过Mid1
和Mid2
继承)。 - 二义性:调用
func()
时需明确路径(如Mid1::func()
或Mid2::func()
),否则编译报错。
解决方案也很简单,将中间的派生类定义为虚继承:
1 | class Base { ... }; // 同上 |
使用虚继承后:
- 共享
Base
子对象:Mid1
和Mid2
虚继承Base
,使得Derived
只保留一个Base
实例。 - 虚基类表(VBTABLE):虚继承的类会包含指向虚基类表的指针(VBPTR),用于定位共享的
Base
子对象。
这里要关注一下,虚基类表与虚函数表是两个不同的概念,上面示例中虚基类表的结构如下:
虚表中存储的 offset_to_Base
是一个整数值,表示从当前子对象(如 Mid1
或 Mid2
)到共享 Base
子对象的内存偏移量。 例如,Mid1
的虚表中 offset_to_Base
可能为 16
(假设 Mid1
子对象到 Base
的偏移为 16 字节)。
5. 虚函数指针的工作流程
1. 构造与析构过程
- 构造顺序:基类 → 派生类,VPTR 逐步更新为当前类的虚表。
- 析构顺序:派生类 → 基类,VPTR 逆向切换回基类的虚表。
2. 动态绑定的实现
- 通过对象的 VPTR 找到虚表,再通过虚表索引定位函数地址。
注意事项:
- 必须使用虚析构函数:- 若基类析构函数非虚,通过基类指针删除派生类对象会导致内存泄漏