C++中的多态性允许程序通过基类指针或引用调用派生类定义的函数版本,这种能力在语法上表现为关键字 virtual,但在运行时真正起作用的是编译器为每个含虚函数的类生成的虚函数表。虚函数表常被简称为虚表,是一张保存函数地址的数组,由编译器在编译期静态生成,而对象在运行时通过虚表指针间接找到需要执行的函数。理解这一机制可以更清楚地解释对象模型、继承布局以及虚函数带来的性能特征。

虚函数与虚表的建立
当一个类中出现虚函数时,编译器会为该类生成一张虚表。这张表并不属于某个对象,而是属于类本身,所有同类型对象共享同一张虚表。虚表本质上是一个函数指针数组,数组中的每一项保存一个虚函数的入口地址。编译器通常会按照虚函数在类中声明的顺序来安排槽位,先声明的虚函数占据较小的偏移,后声明的虚函数占据较大的偏移。
除了虚表之外,每个含有虚函数的类对象在内存中通常还会保存一个指向虚表的指针,这个指针一般被称为 vptr。vptr 的位置与编译器和平台相关,在大多数现代 C++ 实现中它被放在对象内存布局的最前面。构造函数负责在对象创建时把 vptr 初始化为当前类型的虚表地址。正因如此,同一个对象在不同构造阶段可能指向不同的虚表,这也是构造函数和析构函数中虚函数行为发生变化的原因之一。
下面是一个最基本的虚函数示例:
#include <iostream>
class Animal {
public:
virtual void speak() const {
std::cout << "Animal speaks\n";
}
virtual ~Animal() {}
};
class Dog : public Animal {
public:
void speak() const override {
std::cout << "Dog barks\n";
}
};
在这个例子中,Animal 类包含两个虚函数:speak 和虚析构函数。因此 Animal 的虚表有两个有效槽位。Dog 继承自 Animal 并重写了 speak,编译器会为 Dog 生成一张新的虚表,其中 speak 槽位被替换为 Dog::speak 的地址,析构函数槽位则指向 Dog 的虚析构函数。如果 Dog 没有重写某个虚函数,则虚表中的对应槽位会保留基类的函数地址。
单继承下的虚函数表布局
在单继承结构中,派生类和基类通常共享同一个 vptr。也就是说,一个 Dog 对象的前面部分首先是继承自 Animal 的基类子对象,这个子对象中包含 vptr。当 Dog 对象被构造时,vptr 最终会指向 Dog 的虚表,而不是 Animal 的虚表。因此无论通过 Animal* 还是 Dog* 访问对象,读取到的 vptr 都指向 Dog 的虚表,虚函数调用自然能够定位到 Dog 的实现。
虚表中的槽位替换遵循一个简单规则:如果派生类重写了继承来的虚函数,则对应槽位保存派生类版本地址;如果派生类没有重写,则继续保存基类版本地址;如果派生类新增了基类中没有的虚函数,则这些新函数的地址会追加到虚表末尾。可以用下面的表格表示 Animal 与 Dog 的虚表差异:
| 槽位 | Animal 虚表 | Dog 虚表 |
|---|---|---|
| 0 | Animal::speak | Dog::speak |
| 1 | Animal::~Animal | Dog::~Dog |
下面通过一个调用示例观察动态绑定效果:
#include <iostream>
class Animal {
public:
virtual void speak() const {
std::cout << "Animal speaks\n";
}
virtual ~Animal() {}
};
class Dog : public Animal {
public:
void speak() const override {
std::cout << "Dog barks\n";
}
};
int main() {
Dog dog;
Animal* ptr = &dog;
ptr->speak(); // 输出 Dog barks
return 0;
}
上述代码中,ptr 的静态类型是 Animal*,但实际指向的是 Dog 对象。调用 ptr->speak 时,程序并不是直接跳转到 Animal::speak,而是先读取 ptr 指向对象中的 vptr,再根据 speak 在虚表中的偏移间接跳转。因为 vptr 指向 Dog 的虚表,所以最终执行的是 Dog::speak。这正是虚函数动态绑定的核心过程。
多继承下的虚表与this指针调整
多继承会让虚表布局变得复杂。一个派生类如果同时继承多个基类,并且这些基类都含有虚函数,那么派生类对象通常会包含多个 vptr,每个基类子对象各有一个 vptr。比如 Derived 同时继承 Base1 和 Base2,那么 Derived 对象内部会有一个 Base1 子对象和一个 Base2 子对象,它们分别携带自己的 vptr。最终对象的虚表也可能不止一张,Base1 子对象的 vptr 指向一张虚表,Base2 子对象的 vptr 指向另一张虚表。
多继承中还有一个重要问题:this 指针调整。假设通过 Base2* 调用一个被 Derived 重写的虚函数,这个指针实际指向的是 Derived 对象内部的 Base2 子对象,而该子对象并不位于整个 Derived 对象的起始位置。如果被调用的虚函数最终需要访问 Derived 的完整对象,编译器就必须在进入函数前对 this 指针做出偏移调整。很多实现会生成一段被称为 thunk 的代码来完成这个调整,函数地址先指向 thunk,由 thunk 修正 this 后再跳转到真正的函数体。
下面是一个多继承示例:
#include <iostream>
class Base1 {
public:
virtual void f1() { std::cout << "Base1::f1\n"; }
virtual ~Base1() {}
};
class Base2 {
public:
virtual void f2() { std::cout << "Base2::f2\n"; }
virtual ~Base2() {}
};
class Derived : public Base1, public Base2 {
public:
void f1() override { std::cout << "Derived::f1\n"; }
void f2() override { std::cout << "Derived::f2\n"; }
};
在这段代码中,Derived 重写了 Base1::f1 和 Base2::f2。当通过 Base1* 调用 f1 时,this 指针通常不需要调整,因为 Base1 子对象往往位于 Derived 对象的最前面。而当通过 Base2* 调用 f2 时,Base2* 指向的是对象中靠后的 Base2 子对象,编译器需要将 this 调整为 Derived 对象的起始地址,再进入 Derived::f2。虚表机制与 this 调整共同保证了多态调用的正确性。
虚函数调用的运行时开销
虚函数调用比普通成员函数调用多了一次或多次间接寻址。普通函数调用通常可以在编译期确定目标地址,而虚函数调用则需要先读取对象中的 vptr,再根据函数在虚表中的偏移读取函数指针,最后才能跳转到目标函数。这个过程涉及多次内存访问,会增加少量运行时成本。
现代 CPU 对间接跳转有较好的预测能力,因此虚函数调用的绝对开销在很多业务场景中并不明显。但虚函数会阻止编译器进行内联优化,因为编译器在编译期通常无法确定一个基类指针到底指向哪种派生类对象。如果虚函数本身非常短小,例如只有一个返回语句,那么无法内联会导致调用开销占函数总成本的比例上升。在某些编译器能够证明对象具体类型的情况下,会进行去虚化优化,把虚调用转化为直接调用,从而降低开销。
性能敏感场景可以借助一些手段减少虚函数开销。例如,使用 final 关键字标记类或虚函数,让编译器更容易消除虚调用;或者采用基于模板的静态多态作为替代。不过这些手段需要结合具体代码权衡,不应为了微小性能提升牺牲可维护性。
构造函数与析构函数中的虚函数行为
构造函数中调用虚函数不会触发派生类版本。原因是派生类构造函数执行时,基类部分先被构造,此时 vptr 已经指向基类自己的虚表,而派生类成员尚未初始化,派生类虚表也还没有接管对象。因此构造函数中的虚函数调用会绑定到当前正在构造的类版本。这个规则可以避免访问未初始化的派生类数据。
析构函数中的行为类似。当执行派生类析构函数体时,vptr 仍然指向派生类虚表,因此调用虚函数会执行派生类版本。派生类析构函数体执行完毕后,编译器会将 vptr 重置为基类虚表,再进入基类析构函数。因此在基类析构函数中调用虚函数时,已经不会执行派生类版本。这个重置过程可以保证对象销毁时不会访问已经析构的派生类资源。
下面的代码展示了构造函数和析构函数中的虚函数调用顺序:
#include <iostream>
class Base {
public:
Base() { show(); }
virtual void show() const { std::cout << "Base::show\n"; }
virtual ~Base() { show(); }
};
class Derived : public Base {
public:
Derived() { show(); }
void show() const override { std::cout << "Derived::show\n"; }
~Derived() { show(); }
};
int main() {
Derived d;
return 0;
}
实例化 Derived 对象时,先进入 Base 构造函数,Base 构造函数中的 show 调用会执行 Base::show;随后进入 Derived 构造函数,此时 vptr 已经指向 Derived 虚表,因此 show 调用执行 Derived::show。析构时顺序相反:Derived 析构函数体中的 show 调用执行 Derived::show,随后 Base 析构函数体中的 show 调用执行 Base::show。这个结果经常与直觉相悖,是使用虚函数时容易踩到的误区。
虚函数表并非语言标准强制规定的实现方式,但几乎所有主流 C++ 编译器都采用类似模型。理解虚表、vptr、thunk 以及构造析构期间的切换规律,能够帮助开发者在设计继承体系时更准确地预测程序行为,也能在调试内存布局和性能问题时提供清晰的思路。