【C++】 虚指针(vptr)与虚函数表(vtable)

从问题出发:多态如何实现?

我们先从一个最简单的多态场景开始:

cpp 复制代码
class Animal {
public:
    virtual void speak() { cout << "Animal speaks"; }
};

class Dog : public Animal {
public:
    void speak() override { cout << "Dog barks"; }
};

Animal* a = new Dog();
a->speak();  // 如何调用到 Dog::speak ?

aAnimal* 类型,但它指向的是 Dog 对象。程序如何知道应该调用 Dog::speak 而非 Animal::speak

答案是:C++ 通过虚函数表(vtable)和虚指针(vptr)实现了运行时动态绑定。这套机制是 C++ 多态的基石,理解它就能透彻理解"重写"与"多态"的本质。

虚函数表(vtable)的构造与内容

vtable 的本质

虚函数表是一个由函数指针构成的数组 (更准确地说是"虚函数指针数组"),每个包含虚函数的类都拥有一张自己的虚函数表。

  • 表是类级别的------同一个类的所有对象共享同一张虚表。
  • 表通常存储在只读数据段(.rodata)中,编译时生成,运行时只读。
  • 表中按声明顺序(或某种编译器决定的顺序)存放该类所有虚函数的地址。

示例类的虚表构造

cpp 复制代码
class Base {
public:
    virtual void f1() { /*...*/ }
    virtual void f2() { /*...*/ }
    void f3() { /*...*/ }      // 非虚,不进表
    virtual ~Base() { /*...*/ }
};

编译器为 Base 生成的虚表(逻辑表示):

复制代码
Base vtable:
[0] -> Base::f1()
[1] -> Base::f2()
[2] -> Base::~Base()          // 析构函数,通常有两个版本(完整析构、删除析构)

当派生类 Derived 继承 Base 并重写 f1,新增虚函数 f4

cpp 复制代码
class Derived : public Base {
public:
    void f1() override { /*...*/ }
    virtual void f4() { /*...*/ }
    ~Derived() override { /*...*/ }
};

Derived 的虚表:

复制代码
Derived vtable:
[0] -> Derived::f1()          // 覆盖了 Base::f1 的槽位
[1] -> Base::f2()            // 未重写,仍指向基类版本
[2] -> Derived::~Derived()    // 覆盖析构函数
[3] -> Derived::f4()         // 新增虚函数,追加到表尾

关键点 :重写函数会覆盖基类对应槽位的函数指针,未重写的函数保持原基类地址,新增虚函数追加到表末尾。

单继承下的 vtable 布局

在单继承且非虚继承的情况下,派生类 vtable 是基类 vtable 的"扩展":

  • 前面若干槽位与基类虚函数一一对应(按基类声明顺序)。
  • 后面依次是派生类新增虚函数。

这种布局保证了通过基类指针调用虚函数时,索引值(offset)在基类和派生类中完全一致------这是动态绑定能正确工作的前提。

vtable 中存储的不仅仅是虚函数

除了虚函数地址,vtable 通常还包含其他 RTTI(运行时类型识别)信息:

  • 指向 type_info 对象的指针(用于 typeid 运算符)。
  • 对于某些编译器,还可能包含"偏移量"信息(用于调整 this 指针,尤其是在多重继承或虚继承中)。

因此,vtable 不仅是函数指针数组,而是一个类的类型元数据结构

虚指针(vptr)的诞生与归宿

vptr 在对象中的位置

每个含有虚函数的对象 (即对象所属类定义了虚函数,或继承了含有虚函数的基类)都包含一个隐式的指针成员,称为虚指针(vptr)

  • vptr 由编译器自动插入,对程序员不可见。
  • 通常 vptr 位于对象的起始地址 (即 this 指针指向的位置),但这不是 C++ 标准强制,实际主流编译器(GCC、Clang、MSVC)都将 vptr 放在对象开头(除非在多重继承下有多个 vptr)。
  • 对象内存布局:[ vptr | 数据成员1 | 数据成员2 | ... ](可能受对齐影响)。

验证:打印对象地址与第一个成员地址的偏移,可以观察到 vptr 占据的空间(非科学但直观的方法)。

cpp 复制代码
class Test {
    int x;
    virtual void func() {}
};
// sizeof(Test) 在 64 位系统下通常为 16(8 字节 vptr + 4 字节 int + 4 字节 padding)

vptr 的初始化与更新过程

对象的 vptr 并不是静态不变的,它在构造过程中逐步更新,以确保构造函数中调用的虚函数能正确绑定到当前正在构造的类的版本。

构造时的 vptr 变化

  1. 开始构造基类子对象:将 vptr 指向基类的虚表。
  2. 基类构造函数执行完毕,进入下一个基类(若有)或派生类自己的成员初始化。
  3. 进入派生类构造函数体之前,将 vptr 更新为派生类的虚表
  4. 执行派生类构造函数体。

析构时反向

  1. 先执行派生类析构函数体(此时 vptr 仍指向派生类虚表)。
  2. 派生类析构函数体执行完毕后,vptr 被重置为基类虚表。
  3. 调用基类析构函数。

正是这种"逐层切换"机制,保证了在构造函数或析构函数中调用虚函数时,调用的是当前构造/析构层次的函数,而不是最派生类的版本(详见第七章)。

动态绑定的完整执行过程

当通过基类指针或引用调用虚函数时,经历了以下步骤(以 a->speak() 为例):

编译时:索引确定

编译器在看到 a->speak() 时,知道 speakAnimal 的虚函数(索引假设为 0)。它不会直接调用某个固定地址,而是生成类似下面的伪代码:

cpp 复制代码
// 假设 vptr 在对象偏移 0 处
vptr = *(void***)a;                 // 取出虚表地址
func_ptr = vptr[0];                // 取出表中第 0 项的函数指针
func_ptr(a);                       // 调用,将 a 作为 this 传入

注意:所有与虚函数调用相关的索引都在编译时确定,真正在运行时变化的只是从哪个虚表中取函数指针

运行时:查表与调用

  1. 通过对象的 vptr 获取虚表地址。
  2. 根据编译时确定的固定偏移,从虚表中取出函数指针。
  3. 通过函数指针调用该函数,并将对象的地址作为 this 参数传递(通常通过寄存器,如 ECX)。

为什么能正确调用到派生类版本?

因为 Dog 对象的 vptr 指向 Dog 的虚表,而 Dog 虚表的第 0 项存放的是 Dog::speak 的地址。所以即使指针类型是 Animal*,实际取出的函数指针仍然是派生类的函数地址。

汇编级示意(x86-64,AT&T 语法,简化):

asm 复制代码
mov rax, QWORD PTR [rdi]       ; rdi = this,取出 vptr
mov rax, QWORD PTR [rax]       ; 取出虚表第一项
call rax                       ; 间接调用

整个过程仅多了一次间接寻址,效率很高。

多重继承下的虚函数表体系

每个基类一个 vptr

当派生类从多个基类继承,且这些基类都有虚函数时,派生类对象中会包含多个 vptr,每个基类子对象对应一个。

内存布局示例

cpp 复制代码
class Base1 { virtual void f1(); };
class Base2 { virtual void f2(); };
class Derived : public Base1, public Base2 {
    void f1() override;
    void f2() override;
    virtual void f3();
};

Derived 对象内存布局(假定 64 位,不考虑对齐):

复制代码
+-------------------+
| vptr for Base1    |  ← 指向 Derived 中为 Base1 准备的虚表
+-------------------+
| Base1 members     |
+-------------------+
| vptr for Base2    |  ← 指向 Derived 中为 Base2 准备的虚表
+-------------------+
| Base2 members     |
+-------------------+
| Derived members   |
+-------------------+

主 vtable 与次要 vtable

  • 主 vtable:与第一个基类(或派生类自身)关联的虚表,包含所有虚函数(包括继承自 Base1 的、重写的、新增的)。
  • 次要 vtable :每个后续非虚基类对应一个调整过的虚表,其中的函数指针通常指向一个"thunk"(见 5.3),以调整 this 指针。

Derived 实际拥有三张虚表(逻辑上):

  • 用于 Base1 子对象的表:包含 Derived::f1Base2::f2 需要通过 thunk 调用、Derived::f3 等。
  • 用于 Base2 子对象的表:主要包含 Derived::f2 以及可能的一些 thunk。
  • 用于完整 Derived 对象的表(可能与前一个合并)。

this 指针调整(thunk 技术)

问题:当通过 Base2* 指针调用 f2 时,Derived::f2 期望的 this 指针是 Derived 对象的起始地址,但 Base2* 指向的是 Base2 子对象的起始地址(位于对象内部偏移处)。直接传递这个指针会导致 Derived::f2 访问成员时位置错乱。

解决方案 :编译器在次要虚表中不直接放入 Derived::f2 的地址,而是放入一个小段汇编代码(thunk),它的功能是:

  1. this 指针减去某个偏移,使其指向 Derived 起始地址。
  2. 跳转到真正的 Derived::f2

这样,通过 Base2* 调用虚函数时,首先执行 thunk 调整 this,再执行派生类函数。

thunk 伪代码

asm 复制代码
; Base2 子对象 this 在 rdi
sub rdi, offset_Base2_in_Derived  ; 调整为完整对象起始地址
jmp Derived::f2                   ; 跳转(不是 call,尾调用优化)

这种技术几乎无额外开销,只是多了一条减法指令。

虚继承与虚函数表的复杂性

虚继承(virtual inheritance)用于解决菱形继承问题,它使得派生类只保留一个共享的基类子对象。这进一步复杂化了虚函数表的布局。

  • 每个虚继承的子对象可能通过偏移量定位,这些偏移量也存储在 vtable 中(或专门的虚基类表中)。
  • 虚继承下的 vtable 结构在不同编译器间差异较大,常见做法是将虚基类子对象的偏移量放在 vtable 的负偏移位置。

由于虚继承在现代 C++ 中已不推荐使用(通常用组合替代),此处仅作提及,不展开。

vptr 在构造与析构期间的行为

我们已经知道 vptr 在构造和析构过程中会变化。来看一个具体示例:

cpp 复制代码
class Base {
public:
    Base() { cout << "Base ctor: "; speak(); }
    virtual void speak() { cout << "Base\n"; }
    virtual ~Base() = default;
};

class Derived : public Base {
public:
    Derived() { cout << "Derived ctor: "; speak(); }
    void speak() override { cout << "Derived\n"; }
};

int main() {
    Derived d;
}

输出

复制代码
Base ctor: Base
Derived ctor: Derived

分析

  1. 构造 Derived,首先进入 Base 构造函数。
  2. 此时对象的 vptr 指向 Base 的虚表(因为派生类部分尚未构造)。
  3. Base() 中调用 speak() ------ 虚函数调用通过 vptr 查表,查到的是 Base::speak,输出 "Base"。
  4. Base 构造完毕,vptr 被更新为指向 Derived 虚表。
  5. 执行 Derived 构造函数体,调用 speak(),现在 vptr 指向派生类虚表,调用 Derived::speak,输出 "Derived"。

析构时过程相反:先执行派生类析构体(vptr 仍指向派生类虚表),然后 vptr 切回基类虚表,再执行基类析构体。

结论:不要在构造函数/析构函数中调用虚函数,除非你完全理解并期望这种静态绑定行为。这往往违反直觉。

性能开销与优化策略

空间开销

  • 每个对象:增加一个 vptr(64 位下 8 字节)。对于大量小对象,这可能导致内存膨胀(例如原本只有 4 字节数据的对象,加上 vptr 后变为 16 字节,包含填充)。
  • 每个类:一张 vtable(函数指针数量 × 8 字节),所有对象共享,开销可接受。

时间开销

  • 直接调用:一次函数调用(call 指令)。
  • 虚函数调用:一次额外的内存读取(取 vptr) + 一次间接 call。在大多数现代 CPU 上,这通常是一个可预测的间接分支,开销约 1-2 个 CPU 周期(加上可能的缓存未命中)。

编译器优化:去虚化(devirtualization)

如果编译器能够确定调用对象的实际类型,它可能会将虚函数调用优化为直接调用,甚至内联展开。

场景1:通过对象直接调用

cpp 复制代码
Derived d;
d.speak();  // 编译器知道 d 的类型是 Derived,可以直接调用 Derived::speak

场景2:静态分析

cpp 复制代码
Base* b = new Derived();
b->speak();  // 如果编译器能分析出 b 一定指向 Derived,也可以去虚化

现代编译器(如 Clang、GCC 带 -O2)会进行推测性去虚化:根据类型继承图、调用上下文,如果大多数情况下是某个具体类型,生成检查代码,若类型匹配则直接调用,否则走虚表。

场景3:final 类或 final 虚函数

cpp 复制代码
class Derived final : public Base { ... };
Base* b = getDerived();   // 如果 getDerived() 返回 Derived*,编译器可去虚化

final 关键字给编译器明确的承诺:没有更进一步的派生类,因此虚函数调用可以被去虚化。

相关推荐
yqj2341 小时前
【无标题】
java·开发语言
REDcker1 小时前
curl开发者快速入门
linux·服务器·c++·c·curl·后端开发
游乐码2 小时前
c#结构体
开发语言·c#
tod1132 小时前
Redis C++ 客户端开发全流程指南
数据库·c++·redis·缓存
Coder_Boy_2 小时前
JDK17_JDK21并发编程:资深架构常用模式+最佳实践
java·开发语言·spring boot·架构
大黄说说2 小时前
Python 实战指南:一键批量旋转 PDF 页面方向
开发语言·python·pdf
郁闷的网纹蟒2 小时前
虚幻5---第16部分---敌人(中)
开发语言·c++·ue5·游戏引擎·虚幻
二年级程序员2 小时前
单链表算法题思路详解(上)
c语言·数据结构·c++·算法
rhett. li2 小时前
Windows系统中使用MinGW-W64(gcc/g++或LLVM)编译Skia源码的方法
c++·windows·ui·用户界面