虚函数(Virtual Function)和虚表(Virtual Table,简称 vtable)是 C++ 等面向对象编程语言中实现运行时多态(Runtime Polymorphism)的核心机制。
简单来说,虚函数允许你在基类中定义一个接口,而在派生类中提供具体的实现;虚表则是编译器为了实现这种"动态绑定"而在幕后生成的一张查找表。
下面我将分层次详细讲解它们的原理、工作机制以及注意事项。
1.什么是虚函数?
在 C++ 中,如果在基类的成员函数声明前加上关键字 virtual,该函数就成为虚函数。
· 目的:实现动态绑定(Dynamic Binding)。即程序在运行时,根据对象的实际类型(而不是指针或引用的声明类型)来决定调用哪个版本的函数。
**·**语法示例:
cpp
class Base {
public:
virtual void speak() {
cout << "Base speaks" << endl;
}
};
class Derived : public Base {
public:
void speak() override { // override 是可选的,但推荐用于检查
cout << "Derived speaks" << endl;
}
};
行为对比:
· 非虚函数:编译时绑定。如果你用 Base* ptr = new Derived(); ptr->speak();,调用的是 Base::speak()。
**·**虚函数:运行时绑定。同样的代码,调用的是 Derived::speak()。
2.什么是虚表(vtable)?
虚表是编译器为每个包含虚函数的类生成的一张静态数组(通常在只读数据段)。
· 内容:表中存储了该类所有虚函数的地址指针。
· 结构:· 如果一个类有 3 个虚函数,它的虚表里就有 3 个条目,分别指向这 3 个函数的具体实现地址。
· 如果派生类重写了某个虚函数,虚表中对应位置的指针就会指向派生类的函数地址。
**·**如果派生类没有重写,则继承基类虚表中该位置的指针(指向基类的函数)。
虚表的生成规则
1.每个类一张表:只要类中声明了至少一个虚函数,编译器就会为这个类生成一张虚表。
2.继承关系:
· 派生类会复制基类的虚表。
· 对于被重写(Override)的虚函数,派生类虚表中对应的条目会被更新为派生类函数的地址。
**·**对于新增的虚函数,会追加到虚表末尾。
3.核心机制:虚表指针(vptr)
既然每个类有一张虚表,那么对象如何找到自己的虚表呢?答案是虚表指针(vptr)。
· 隐藏成员:编译器会在含有虚函数的类的对象内存布局中,隐式地插入一个指针成员,这就是 vptr。
· 初始化:
当创建对象时(在构造函数执行期间),vptr 会被自动初始化,指向当前正在构造的那个类的虚表。
注意:如果在基类构造函数中调用虚函数,此时 vptr 指向基类的虚表,因此不会发生多态(调用的是基类版本)。
·调用过程:
当你执行 ptr->speak() 时,编译器生成的汇编代码大致如下:
1.通过对象指针找到对象内存中的 vptr。
2.通过 vptr 找到对应的虚表(vtable)。
3.在虚表中查找 speak 函数对应的索引位置(例如第 0 项)。
4.取出该位置的函数地址并跳转执行。
假设我们有以下类结构:
cpp
class Animal {
public:
virtual void sound() { cout << "Animal"; }
virtual void move() { cout << "Move"; }
};
class Dog : public Animal {
public:
void sound() override { cout << "Bark"; }
// move 未重写
};
内存布局示意:
虚表区 (Static Data):
·Animal_vtable: [ &Animal::sound, &Animal::move ]
·Dog_vtable: [ &Dog::sound, &Animal::move ] (注意:sound 被替换,move 继承)
堆/栈上的对象 (Object Instance):·Animal 对象: [ vptr -> Animal_vtable | ...其他成员... ]
·Dog 对象: [ vptr -> Dog_vtable | ...其他成员... ]
调用流程 (Animal* a = new Dog(); a->sound();):
1.访问 a 指向的对象。
2.读取对象头部的 vptr,它指向 Dog_vtable。
3.在 Dog_vtable 的第 0 个位置(对应 sound)找到地址 &Dog::sound。
4.执行 Dog::sound,输出 "Bark"。
4.关键特性与注意事项
A. 构造函数与析构函数
· 构造函数中调用虚函数:
在基类构造函数执行时,派生类部分尚未构造完成,对象的 vptr 指向基类的虚表。
因此,构造函数中调用虚函数不会发生多态,只会调用基类版本。
建议:避免在构造函数中调用虚函数。
· 析构函数必须是虚函数:
如果你打算通过基类指针删除派生类对象(delete basePtr),基类的析构函数必须是虚函数。
如果不是虚函数,delete 只会调用基类析构函数,导致派生类特有的资源(如动态分配的内存)无法释放,造成内存泄漏。
B. 性能开销
· 空间开销:
每个对象增加一个指针的大小(32位系统4字节,64位系统8字节)。
每个含虚函数的类增加一张虚表(存储函数指针数组)。
· 时间开销:
调用虚函数比调用普通函数多一次间接寻址(查表)。
由于目标地址在运行时才能确定,虚函数通常不能被内联(inline)优化(除非编译器能确定对象的具体类型,进行去虚拟化优化)。
在现代 CPU 上,这个开销非常小,通常可以忽略不计,但在极度追求性能的循环中需注意。
C. 纯虚函数与抽象类
如果虚函数声明为 = 0,则为纯虚函数:virtual void func() = 0;。
包含纯虚函数的类称为抽象类,不能实例化对象。
纯虚函数在虚表中通常占用一个特殊位置(有时指向一个报错函数,如 __cxa_pure_virtual),强制派生类必须实现该函数,否则派生类也是抽象类。
一句话总结:虚函数通过引入虚表指针和虚表,以微小的时空代价,换取了代码极大的灵活性和可扩展性,是面向对象设计中"开闭原则"(对扩展开放,对修改关闭)的技术基石。