C++ 的虚函数是运行期多态的核心。当通过基类指针或引用调用一个被声明为 virtual 的成员函数时,实际执行的是指针所指向对象的最终重写版本,而不是基类版本。这一行为并非由语言层面的动态类型推断完成,而是依赖编译器在对象中悄悄放置的一个指针和一张函数地址表。这个指针习惯上称为虚表指针 vptr,函数地址表称为虚函数表 vtable。理解这两个结构,就能解释为什么带虚函数的对象会变大、为什么多继承会增加更多指针,以及为什么构造函数里调用虚函数不会产生多态。

一、虚函数表与虚表指针的基本结构
一旦一个类声明了至少一个虚函数,编译器就会为这个类生成一张虚函数表。虚函数表通常放在只读数据段,本质是一个函数指针数组,按虚函数在类中首次声明的顺序记录每个虚函数的入口地址。每个类的虚表内容由该类对虚函数的最终实现决定。也就是说,基类有基类的虚表,派生类有派生类的虚表,虚表之间的差异正好反映了函数覆盖和新增情况。
对象层面,编译器会向对象内存布局中插入一个隐藏成员,即虚表指针。在常见的实现中,vptr 位于对象起始地址,占用一个指针大小,64 位系统上为 8 字节。这个指针在对象构造期间由构造函数负责初始化,指向当前类对应的虚表。于是 sizeof 一个带虚函数的空类不再为 1,而是变为一个指针大小,这也从侧面说明了虚函数机制不是零成本。可以通过下面的类定义观察这一变化。
class Base {
public:
virtual void func1() {}
virtual void func2() {}
int data = 0;
};
在这个例子中,Base 对象的内存里除了 int 类型的 data 成员,还额外包含一个 vptr。由于虚函数表本身是类级别共享的,所有 Base 对象都指向同一张 Base 虚表,所以对象之间不会重复保存虚表内容,只是各自保存一个指向虚表的指针。这也是虚函数机制在对象数量很多时仍然能够被接受的重要原因。
二、单继承中的虚表结构
单继承是最容易观察虚表继承和覆盖规则的情形。派生类如果重写了某个虚函数,它的虚表对应槽位会被替换为派生类函数地址;如果没有重写,则沿用基类函数地址;派生类新增虚函数则追加到虚表末尾。以 Base 和 Derived 为例:
class Derived : public Base {
public:
void func1() override {}
virtual void func3() {}
};
Derived 的虚表中,func1 指向 Derived::func1,func2 仍指向 Base::func2,func3 指向 Derived::func3。当执行 Base* p = new Derived(); p->func1(); 时,编译器生成的调用逻辑可以理解为:从 p 指向的对象中读取 vptr,再从 vptr 指向的虚表中取出第 0 项函数指针,最后通过该指针调用函数,并将 p 作为 this 传入。正因如此,同一个调用点在运行期可以跳到不同函数,这就是动态绑定。
这种间接调用并不是在源代码里写出来的,而是编译器在生成机器码时自动完成的。程序员看到的是一个简单的成员函数调用,但底层实际上经历了读取对象首地址、读取虚表指针、按索引读取函数指针、间接跳转等多个步骤。理解这个过程后,再分析虚函数开销和多继承布局就会清晰很多。
三、多继承与虚继承下的虚表变化
多继承让对象中可能出现多个 vptr。假设 Derived 同时继承 Base1 和 Base2,两个基类都有虚函数,那么 Derived 对象通常会包含两个虚表指针,分别对应 Base1 子对象和 Base2 子对象。编译器可能生成两张虚表,或一张主虚表和一张次虚表。对于 Base1 的指针,可以直接使用第一个 vptr;对于 Base2 的指针,编译器会先把指针调整到 Base2 子对象的起始地址,再通过第二个 vptr 完成调用。
class Base1 {
public:
virtual void f() {}
};
class Base2 {
public:
virtual void g() {}
};
class Derived : public Base1, public Base2 {
public:
void f() override {}
};
如果派生类重写了同时出现在两个基类中的虚函数,两个虚表中的相应槽位都需要更新。更新为同一个函数地址时,该函数必须能够处理来自不同子对象起始地址的 this 调整。C++ 编译器通常会生成一小段称为 thunk 的代码:thunk 先修正 this 指针,再跳转到真正的派生类函数。这样外层调用仍然认为是从对应基类子对象发起,而实际执行的函数体可以安全访问完整的派生类对象。
虚继承进一步增加了复杂度。虚基类子对象在派生类中只有一份,需要所有直接或间接继承路径共享。为此编译器会在虚表中使用负索引或单独的虚基类表来保存虚基类子对象相对于当前对象的偏移。不同编译器实现差异较大,但核心思想一致:先读取偏移量,调整 this,再访问虚基类成员或调用其虚函数。虚继承下的虚表结构比普通多继承更加复杂,通常不建议在没有明确需求时过度使用。
四、构造与析构中的虚函数行为
构造函数里调用虚函数是一个典型误区。很多人以为 Derived d; 构造时会调用派生类版本,但实际调用的是当前正在构造的类的版本。原因是编译器会在进入构造函数体之前把对象的 vptr 指向当前类的虚表。构造 Base 时 vptr 指向 Base 虚表,此时即使派生类已经重写,也看不到;构造 Derived 时,vptr 才会更新为 Derived 虚表。这样设计是为了避免在派生类成员尚未初始化时错误地调用派生类函数。
class Base {
public:
Base() { call(); }
virtual void call() { }
};
class Derived : public Base {
public:
void call() override { }
};
执行 Derived d; 时,Base 构造函数中的 call() 调用的是 Base::call。如果希望构造期间执行不同逻辑,应当显式传参或使用工厂函数,而不是依赖虚函数多态。析构函数也有类似规则:进入派生类析构函数体后,vptr 会先降为当前类的虚表。因此析构函数中调用虚函数同样不会派发到更派生类。
对于可能作为基类使用的类,析构函数应该声明为 virtual,否则通过基类指针 delete 派生类对象时不会调用派生类析构函数,造成资源泄漏。虚析构函数本身也会进入虚表,占用一个槽位,但它保证了对象销毁时沿着继承链正确释放资源。这一条规则在实际工程中非常重要,尤其是涉及多态继承体系时。
五、性能开销与常见优化误区
虚函数调用比普通成员函数多了一次间接寻址:先读 vptr,再读虚表项,最后跳转。这种间接跳转还可能让 CPU 分支预测失败,产生流水线停顿。不过在现代 CPU 和编译器优化下,这个开销在大多数业务代码中可以忽略。真正需要关注的是虚函数阻碍内联:当调用点的对象静态类型明确时,编译器可以确认具体函数并内联;当通过基类指针或引用调用时,由于运行期才确定目标,通常无法内联。
如果性能敏感并且确定不需要多态,可以移除 virtual,或者使用模板、CRTP 等静态多态方案替代。不要因为虚函数存在就盲目进行复杂的性能优化。更常见的优化是让热路径中的数据局部性更好,减少间接调用次数,以及使用 final 标记终止虚表派发,帮助编译器去虚化。编译器在能够证明对象实际类型的情况下,也可能将虚调用转化为直接调用,这被称为去虚化优化。
查看虚表内容可以通过调试器观察对象内存中的第一个指针,再查看该地址附近的函数指针。部分编译器的 RTTI 信息会放在虚表附近,这也是 dynamic_cast 和 typeid 能工作的原因之一。理解这一点有助于分析对象布局和定位虚表损坏问题。虚函数表和虚表指针虽然由编译器自动维护,但它们的布局规则决定了多态行为的正确性和性能表现,掌握这些底层细节对编写健壮的 C++ 程序很有帮助。