什么是虚函数表(vtable)和虚表指针(vptr)?它们是如何工作的?
在 C++ 中,虚函数表(Virtual Table,简称 vtable) 和 虚表指针(Virtual Pointer,简称 vptr) 是编译器用来实现动态多态(Runtime Polymorphism)或动态绑定(Late Binding)的底层机制。
简单来说,当你使用基类指针或引用去调用派生类中重写的虚函数时,C++ 就是通过 vtable 和 vptr 来准确找到究竟该执行哪个版本(基类还是派生类)的函数的。
下面详细拆解它们是什么以及是如何工作的:
1. 核心概念
A. 虚函数表 (vtable)
- 本质: 它是一个函数指针数组。
- 归属: 它属于类(Class)级别。也就是说,同一个类的所有对象共享同一张虚函数表。
- 生成时机: 在编译期由编译器自动生成。
- 内容: 表中记录了该类所有虚函数的实际内存地址。如果派生类重写(override)了基类的虚函数,vtable 中对应的函数指针就会被替换为派生类函数的地址;如果没有重写,则保留基类函数的地址。
B. 虚表指针 (vptr)
- 本质: 它是一个隐藏的指针变量。
- 归属: 它属于对象(Object / Instance)级别。只要一个类包含虚函数,编译器就会在实例化该类对象时,暗中为该对象增加一个指针。
- 初始化时机: 在运行期,当对象被创建(执行构造函数)时,vptr 被初始化,指向该对象所属类的 vtable。
2. 它们是如何工作的?(工作流程)
我们通过一段代码和内存模型来还原它的工作过程。
代码示例
cpp
#include <iostream>
class Animal {
public:
virtual void speak() { std::cout << "Animal speaks" << std::endl; }
virtual void walk() { std::cout << "Animal walks" << std::endl; }
};
class Dog : public Animal {
public:
// 重写了 speak,但没有重写 walk
void speak() override { std::cout << "Dog barks" << std::endl; }
};
int main() {
Animal* myPet = new Dog();
myPet->speak(); // 动态绑定:输出 "Dog barks"
myPet->walk(); // 动态绑定:输出 "Animal walks"
delete myPet;
return 0;
}
编译期:构建 vtable
编译器在编译这段代码时,发现 Animal 和 Dog 都有虚函数,于是为它们分别生成虚函数表:
Animal的 vtable:- Slot 0: 指向
Animal::speak()的地址 - Slot 1: 指向
Animal::walk()的地址
- Slot 0: 指向
Dog的 vtable:- Slot 0: 指向
Dog::speak()的地址 (因为重写了,所以替换掉 Animal 的版本) - Slot 1: 指向
Animal::walk()的地址 (因为没重写,所以继承基类的版本)
- Slot 0: 指向
运行期:初始化 vptr 与 函数调用
- 创建对象:
new Dog()被执行,内存中分配了一块空间给Dog对象。在调用Dog的构造函数时,编译器悄悄插入了代码,将这个Dog对象的vptr指向了Dog类的 vtable。 - 指针赋值:
Animal* myPet = new Dog();。此时,基类指针myPet指向了这个派生类对象。 - 调用函数: 当执行
myPet->speak();时:- 编译器发现
speak()是个虚函数。 - 它不再直接生成跳转到
Animal::speak()的机器码。 - 而是生成类似这样的寻址代码:
- 找到
myPet指向的对象。 - 提取对象内部的
vptr。 - 顺着
vptr找到对应的虚函数表(即Dog的 vtable)。 - 在 vtable 中查找
speak()对应的偏移量(Slot 0)。 - 取出 Slot 0 中的函数地址,并执行跳转(Jump)。
- 找到
- 编译器发现
因为 Dog 的 vtable 中 Slot 0 存的是 Dog::speak(),所以最终执行了狗的叫声。
3. 内存布局图解
为了更直观,我们可以用下图表示上述代码在运行时的内存布局:
plaintext
[ 指针 myPet ] -----------------> [ Dog 对象实例 (堆内存) ]
|-------------------|
| vptr ----------| |
|-----------------|-|
| Dog 的其他成员 | |
|-------------------|
|
V
[ Dog 的 vtable (只读数据段) ]
|-----------------------|
Slot 0 -----> | &Dog::speak() |
Slot 1 -----> | &Animal::walk() |
|-----------------------|
如果你创建了一个 Animal 的实例 (new Animal()),它的布局是这样的:
plaintext
[ Animal 对象实例 ]
|-------------------|
| vptr ----------| |
|-----------------|-|
| Animal 其它成员 | |
|-------------------|
|
V
[ Animal 的 vtable ]
|-----------------------|
Slot 0 -----> | &Animal::speak() |
Slot 1 -----> | &Animal::walk() |
|-----------------------|
4. 重要的推论与代价(面试常考点)
引入虚函数表机制并不是免费的,它带来了空间和时间上的开销:
- 空间开销(Space Overhead):
- 对象体积增大: 每个包含虚函数的对象,都会多出一个指针的体积(在 32 位系统上是 4 字节,在 64 位系统上是 8 字节)。
- 内存占用增加: 每个带有虚函数的类,都需要在可执行文件中保存一张虚函数表。
- 时间开销(Time Overhead):
- 调用变慢: 虚函数调用需要经过“对象 -> vptr -> vtable -> 取出函数地址 -> 调用”这样一个间接寻址的过程,比普通的函数直接调用(静态绑定)要慢一点。
- 无法内联(Inline): 因为具体调用哪个函数要在运行时才能确定,所以编译器通常无法对虚函数进行内联优化(除非在编译期能推断出确切的类型)。
- 构造函数不能是虚函数:
- 在构造函数执行期间,对象的
vptr是逐步被初始化的(先初始化基类部分的 vptr,再初始化派生类部分的 vptr)。如果在构造函数中调用虚函数,它只会调用当前正在构造的类层级的版本,不会发生多态。
- 在构造函数执行期间,对象的
- 基类的析构函数必须是虚函数:
- 如果基类析构函数不是虚函数,当
delete myPet;(myPet是基类指针)时,编译器只会静态绑定到基类的析构函数,导致派生类的析构函数不被执行,引发内存泄漏。如果加了virtual,就会通过 vtable 正确查找到派生类的析构函数并执行。
- 如果基类析构函数不是虚函数,当
右滑查看面试常问