导读:本期聚焦于Ada创作的《C++中的虚函数(virtual)是如何实现的?(虚函数表和虚表指针)》,敬请观看详情。C++ 的虚函数机制并不是编译器的黑魔法,而是依赖对象中隐藏的虚表指针和类对应的虚函数表协同完成。每当一个类声明虚函数,编译器会为该类生成只读的函数指针数组,也就是虚函数表,表中按声明顺序记录该类最终实现的虚函数地址。对象在构造时会初始化一个 vptr 指针,指向所属类的虚表。通过基类指针调用虚函数时,程序先读取对象 vptr,再按函数在虚表中的索引取出真实地址,最终完成间接跳转。这种设计使同一调用点能够根据对象实际类型执行不同函数,实现运行期多态。与普通成员函数相比,虚函数多了一层间接寻址,因此对象大小和调用开销略有增加;在多继承和虚继承下还会出现多个 vptr 或额外偏移表。本文从基本内存布局出发,依次分析单继承、多继承、构造函数特例以及性能开销,帮助读者彻底看清虚函数表和虚表指针背后的实现细节。

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

C++中的虚函数(virtual)是如何实现的?(虚函数表和虚表指针)

一、虚函数表与虚表指针的基本结构

一旦一个类声明了至少一个虚函数,编译器就会为这个类生成一张虚函数表。虚函数表通常放在只读数据段,本质是一个函数指针数组,按虚函数在类中首次声明的顺序记录每个虚函数的入口地址。每个类的虚表内容由该类对虚函数的最终实现决定。也就是说,基类有基类的虚表,派生类有派生类的虚表,虚表之间的差异正好反映了函数覆盖和新增情况。

对象层面,编译器会向对象内存布局中插入一个隐藏成员,即虚表指针。在常见的实现中,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++ 程序很有帮助。

C++虚函数虚函数表虚表指针修改时间:2026-08-20 11:42:07

免责声明:​ 已尽一切努力确保本网站所含信息的准确性。网站内容多为原创整理与精心编撰,观点力求客观中立。本站旨在免费分享,内容仅供个人学习、研究或参考使用。若引用了第三方作品,版权归原作者所有。如内容涉及您的权益,请联系我们处理。
内容垂直聚焦
专注技术核心技术栏目,确保每篇文章深度聚焦于实用技能。从代码技巧到架构设计,为用户提供无干扰的纯技术知识沉淀,精准满足专业提升需求。
知识结构清晰
覆盖从开发到部署的全链路。AI、前端、编程、数据库、服务器、建站、系统层层递进,构建清晰学习路径,帮助用户系统化掌握开发与运维所需的核心技术。
深度技术解析
拒绝泛泛而谈,深入技术细节与实践难点。无论是数据库优化还是服务器配置,均结合真实场景与代码示例进行剖析,致力于提供可直接应用于工作的解决方案。
专业领域覆盖
精准对应开发生命周期。从前端界面到后端编程,从数据库操作到服务器运维,形成完整闭环,一站式满足全栈工程师和运维人员的技术需求。
即学即用高效
内容强调实操性,步骤清晰、代码完整。用户可根据教程直接复现和应用于自身项目,显著缩短从学习到实践的距离,快速解决开发中的具体问题。
持续更新保障
专注既定技术方向进行长期、稳定的内容输出。确保各栏目技术文章持续更新迭代,紧跟主流技术发展趋势,为用户提供经久不衰的学习价值。