资讯动态

【c++面向对象编程】第15篇:多态(二):虚函数表(vtable)内存布局揭秘

发布时间:2026/8/21 12:23:27 来源:尧图企业网站定制
目录一、一个 8 字节的“秘密”二、vtable 和 vptr 的工作原理核心概念工作流程简单示例的内存视角三、单继承下的 vtable 布局四、多继承下的 vtable更复杂五、性能开销分析1. 内存开销2. 时间开销3. 禁用虚函数机制的场景六、完整示例观察 vptr 和 vtable 的行为七、三个常见陷阱1. 误以为虚函数调用一定比非虚慢很多2. 在构造函数/析构函数中调用虚函数3. 对象切片导致丢失 vptr八、这一篇的收获一、一个 8 字节的“秘密”先看这段代码猜猜sizeof(Base)是多少cppclass Base { public: int x; void normalFunc() {} // 普通函数 virtual void virtFunc() {} // 虚函数 }; int main() { cout sizeof(Base) endl; // 输出多少 }如果按直觉int占 4 字节函数不占对象内存应该是 4。但实际输出是1664位系统或 832位系统。多出来的字节是什么是一个隐藏的指针——vptr虚函数表指针。cpp// 编译器实际处理成类似这样 class Base { private: void* __vptr; // 隐藏的虚表指针通常 8 字节 public: int x; // 4 字节可能还有对齐填充 };每个包含虚函数的对象都会在内存最前面通常是多出一个 vptr。这正是虚函数实现多态的“机关”。二、vtable 和 vptr 的工作原理核心概念vtable虚函数表每个类有一张。它是一个函数指针数组存储该类的虚函数地址。vptr虚表指针每个对象有一个。它指向对象所属类的 vtable。工作流程当调用ptr-virtFunc()时从ptr指向的对象中取出 vptr通过 vptr 找到该类的 vtable在 vtable 中找到virtFunc对应的函数指针通常是第 n 个槽位跳转到该函数地址执行text对象内存布局 ptr ──→ ┌─────────────┐ │ vptr │ ──→ [ vtable for Derived ] ├─────────────┤ ┌────────────────────┐ │ 成员变量 │ │ 0: func1 的地址 │ └─────────────┘ │ 1: func2 的地址 │ │ 2: 析构函数地址 │ └────────────────────┘简单示例的内存视角cppclass Animal { public: int age; virtual void speak() { cout Animal speak endl; } virtual void move() { cout Animal move endl; } }; class Dog : public Animal { public: void speak() override { cout Dog bark endl; } // move 没有重写使用 Animal 的版本 }; int main() { Dog d; Animal* p d; p-speak(); // 通过 vtable 找到 Dog::speak p-move(); // 通过 vtable 找到 Animal::move }Animal类的 vtabletextAnimal_vtable: [0] → Animal::speak() [1] → Animal::move()Dog类的 vtabletextDog_vtable: [0] → Dog::speak() ← 替换成自己的 [1] → Animal::move() ← 没有重写沿用基类的d对象内部textd: vptr ──→ Dog_vtable age当p-speak()时通过p找到d的 vptr再到Dog_vtable取第 0 个函数指针调用Dog::speak()。三、单继承下的 vtable 布局写一个完整的例子来观察cpp#include iostream using namespace std; class Base { public: int b; virtual void f1() { cout Base::f1 endl; } virtual void f2() { cout Base::f2 endl; } }; class Derived : public Base { public: int d; void f1() override { cout Derived::f1 endl; } virtual void f3() { cout Derived::f3 endl; } }; int main() { cout sizeof(Base) sizeof(Base) endl; // 16 (vptr 8 int 4 对齐4) cout sizeof(Derived) sizeof(Derived) endl; // 24 (vptr 8 b 4 d 4 对齐8) return 0; }vtable 布局textBase_vtable: [0] → Base::f1() [1] → Base::f2() Derived_vtable: [0] → Derived::f1() ← 覆盖 Base::f1 的位置 [1] → Base::f2() ← 沿用 [2] → Derived::f3() ← 新增的虚函数放在后面关键规则基类的虚函数在 vtable 中的索引位置固定派生类重写的函数会覆盖对应索引的槽位派生类新增的虚函数追加到 vtable 末尾这就是为什么通过基类指针调用虚函数时能正确找到派生类的版本——因为索引是编译时确定的而槽位内容被子类覆盖了。四、多继承下的 vtable更复杂多继承时一个派生类有多个基类就需要多个 vptr。cppclass Base1 { public: virtual void f1() { cout Base1::f1 endl; } }; class Base2 { public: virtual void f2() { cout Base2::f2 endl; } }; class Derived : public Base1, public Base2 { public: void f1() override { cout Derived::f1 endl; } void f2() override { cout Derived::f2 endl; } virtual void f3() { cout Derived::f3 endl; } };内存布局textDerived 对象: ┌─────────────────────────┐ │ vptr for Base1 │ ──→ Derived_vtable_part1 ├─────────────────────────┤ │ Base1 的其他成员 │ ├─────────────────────────┤ │ vptr for Base2 │ ──→ Derived_vtable_part2 ├─────────────────────────┤ │ Base2 的其他成员 │ ├─────────────────────────┤ │ Derived 新增的成员 │ └─────────────────────────┘Derived对象内部有两个 vptr分别指向不同的 vtable 片段。vtable 布局textDerived_vtable_for_Base1: [0] → Derived::f1() ← 覆盖 Base1::f1 [1] → Derived::f3() ← 新增函数放在第一个 vtable Derived_vtable_for_Base2: [0] → Derived::f2() ← 覆盖 Base2::f2 [1] → thunk 调整 this 指针 ← 编译器生成的调整代码thunk当通过 Base2 指针调用 Derived::f1 时需要调整 this 指针的位置thunk 负责这个调整。多继承的虚函数调用比单继承稍慢因为可能涉及 this 指针调整。五、性能开销分析虚函数不是免费的理解开销有助于做出合理的设计决策。1. 内存开销每个对象多一个 vptr64 位系统 8 字节每个类多一张 vtable一个虚函数占 8 字节指针100 万个对象每个多 8 字节 → 8MB 额外内存。2. 时间开销操作非虚函数虚函数调用直接跳转vptr → vtable → 跳转多 2~3 次内存访问内联可以通常不可以动态绑定分支预测简单更复杂虚函数调用的汇编伪代码asm; 非虚函数调用 call Base::func ; 虚函数调用 mov rax, [ptr] ; 取出 vptr mov rax, [rax 8] ; 从 vtable 取出函数地址假设偏移 8 call rax ; 间接调用对于大多数应用这个开销可以忽略。但在性能敏感的内层循环如游戏引擎每帧调用数百万次需要谨慎使用。3. 禁用虚函数机制的场景函数很短希望内联循环内调用次数极多嵌入式/实时系统对确定性要求极高解决方案CRTP奇异递归模板模式实现静态多态编译时绑定后续章节会讲。六、完整示例观察 vptr 和 vtable 的行为cpp#include iostream using namespace std; class Base { public: int b 1; virtual void f1() { cout Base::f1 endl; } virtual void f2() { cout Base::f2 endl; } }; class Derived : public Base { public: int d 2; void f1() override { cout Derived::f1 endl; } virtual void f3() { cout Derived::f3 endl; } }; // 辅助函数打印对象内存以字节形式 void printMemory(void* obj, size_t size) { unsigned char* bytes (unsigned char*)obj; for (size_t i 0; i size; i) { printf(%02x , bytes[i]); if ((i 1) % 8 0) cout ; } cout endl; } int main() { cout 对象大小 endl; cout sizeof(Base) sizeof(Base) endl; // 16 cout sizeof(Derived) sizeof(Derived) endl; // 24 Base b; Derived d; cout \n Base 对象内存前16字节 endl; printMemory(b, sizeof(b)); cout \n Derived 对象内存 endl; printMemory(d, sizeof(d)); cout \n 多态调用演示 endl; Base* ptr d; cout ptr-f1(): ; ptr-f1(); // Derived::f1 cout ptr-f2(): ; ptr-f2(); // Base::f2 // ptr-f3(); // ❌ 编译错误Base 不知道 f3 cout \n 通过派生类指针调用 f3 endl; Derived* dptr d; dptr-f3(); // Derived::f3 return 0; }运行结果内存地址每次不同text 对象大小 sizeof(Base) 16 sizeof(Derived) 24 Base 对象内存前16字节 a0 5d 3c 00 00 00 00 00 01 00 00 00 cc cc cc cc Derived 对象内存 90 5d 3c 00 00 00 00 00 01 00 00 00 cc cc cc cc 02 00 00 00 cc cc cc cc 多态调用演示 ptr-f1(): Derived::f1 ptr-f2(): Base::f2 通过派生类指针调用 f3 Derived::f3注意前 8 字节就是 vptr地址值后面跟着成员变量。七、三个常见陷阱1. 误以为虚函数调用一定比非虚慢很多现代 CPU 的分支预测和缓存让虚函数开销通常 5%。不要过早优化先保证设计正确。2. 在构造函数/析构函数中调用虚函数cppclass Base { public: Base() { f(); } // 调用 Base::f不是 Derived::f virtual void f() { cout Base endl; } };构造时派生类还没构造完vptr 指向基类的 vtable。3. 对象切片导致丢失 vptrcppDerived d; Base b d; // 切片b 是 Base 对象没有 Derived 的 vptr b.f(); // 调用 Base::f用指针或引用才能保留多态行为。八、这一篇的收获你现在应该理解每个包含虚函数的对象有一个隐藏的vptr指向该类的vtablevtable 是一个函数指针数组存储该类的虚函数地址单继承时派生类 vtable 先复制基类再覆盖重写的函数最后追加新函数多继承需要多个 vptr调用可能涉及 this 指针调整thunk虚函数有轻微的内存和时间开销但多数场景可忽略 小作业定义一个包含 3 个虚函数的基类和一个重写其中 2 个并新增 1 个虚函数的派生类。用sizeof查看对象大小并在调试器中查看 vtable 的内容如果使用 Visual Studio 或 CLion。下一篇预告第16篇《多态三抽象类与纯虚函数——设计接口的思想》——什么情况下基类函数无法给出有意义的实现纯虚函数和抽象类就是答案。它们是 C 中定义“接口”的方式也是很多设计模式的基础。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价