news 2026/5/14 17:24:51

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

作者头像

张小明

前端开发工程师

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

目录

一、一个 8 字节的“秘密”

二、vtable 和 vptr 的工作原理

核心概念

工作流程

简单示例的内存视角

三、单继承下的 vtable 布局

四、多继承下的 vtable(更复杂)

五、性能开销分析

1. 内存开销

2. 时间开销

3. 禁用虚函数机制的场景

六、完整示例:观察 vptr 和 vtable 的行为

七、三个常见陷阱

1. 误以为虚函数调用一定比非虚慢很多

2. 在构造函数/析构函数中调用虚函数

3. 对象切片导致丢失 vptr

八、这一篇的收获


一、一个 8 字节的“秘密”

先看这段代码,猜猜sizeof(Base)是多少?

cpp

class Base { public: int x; void normalFunc() {} // 普通函数 virtual void virtFunc() {} // 虚函数 }; int main() { cout << sizeof(Base) << endl; // 输出多少? }

如果按直觉:int占 4 字节,函数不占对象内存,应该是 4。但实际输出是16(64位系统)或 8(32位系统)

多出来的字节是什么?是一个隐藏的指针——vptr(虚函数表指针)

cpp

// 编译器实际处理成类似这样 class Base { private: void* __vptr; // 隐藏的虚表指针(通常 8 字节) public: int x; // 4 字节(可能还有对齐填充) };

每个包含虚函数的对象,都会在内存最前面(通常是)多出一个 vptr。这正是虚函数实现多态的“机关”。


二、vtable 和 vptr 的工作原理

核心概念

  • vtable(虚函数表):每个有一张。它是一个函数指针数组,存储该类的虚函数地址。

  • vptr(虚表指针):每个对象有一个。它指向对象所属类的 vtable。

工作流程

当调用ptr->virtFunc()时:

  1. ptr指向的对象中取出 vptr

  2. 通过 vptr 找到该类的 vtable

  3. 在 vtable 中找到virtFunc对应的函数指针(通常是第 n 个槽位)

  4. 跳转到该函数地址执行

text

对象内存布局: ptr ──→ ┌─────────────┐ │ vptr │ ──→ [ vtable for Derived ] ├─────────────┤ ┌────────────────────┐ │ 成员变量 │ │ 0: func1 的地址 │ └─────────────┘ │ 1: func2 的地址 │ │ 2: 析构函数地址 │ └────────────────────┘

简单示例的内存视角

cpp

class 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类的 vtable:

text

Animal_vtable: [0] → Animal::speak() [1] → Animal::move()

Dog类的 vtable:

text

Dog_vtable: [0] → Dog::speak() ← 替换成自己的 [1] → Animal::move() ← 没有重写,沿用基类的

d对象内部:

text

d: 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 布局

text

Base_vtable: [0] → Base::f1() [1] → Base::f2() Derived_vtable: [0] → Derived::f1() ← 覆盖 Base::f1 的位置 [1] → Base::f2() ← 沿用 [2] → Derived::f3() ← 新增的虚函数放在后面

关键规则

  • 基类的虚函数在 vtable 中的索引位置固定

  • 派生类重写的函数会覆盖对应索引的槽位

  • 派生类新增的虚函数追加到 vtable 末尾

这就是为什么通过基类指针调用虚函数时,能正确找到派生类的版本——因为索引是编译时确定的,而槽位内容被子类覆盖了。


四、多继承下的 vtable(更复杂)

多继承时,一个派生类有多个基类,就需要多个 vptr

cpp

class 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; } };

内存布局

text

Derived 对象: ┌─────────────────────────┐ │ vptr for Base1 │ ──→ Derived_vtable_part1 ├─────────────────────────┤ │ Base1 的其他成员 │ ├─────────────────────────┤ │ vptr for Base2 │ ──→ Derived_vtable_part2 ├─────────────────────────┤ │ Base2 的其他成员 │ ├─────────────────────────┤ │ Derived 新增的成员 │ └─────────────────────────┘

Derived对象内部有两个 vptr,分别指向不同的 vtable 片段。

vtable 布局

text

Derived_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. 内存开销

  • 每个对象多一个 vptr(64 位系统 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. 在构造函数/析构函数中调用虚函数

cpp

class Base { public: Base() { f(); } // 调用 Base::f,不是 Derived::f virtual void f() { cout << "Base" << endl; } };

构造时派生类还没构造完,vptr 指向基类的 vtable。

3. 对象切片导致丢失 vptr

cpp

Derived d; Base b = d; // 切片!b 是 Base 对象,没有 Derived 的 vptr b.f(); // 调用 Base::f

用指针或引用才能保留多态行为。


八、这一篇的收获

你现在应该理解:

  • 每个包含虚函数的对象有一个隐藏的vptr,指向该类的vtable

  • vtable 是一个函数指针数组,存储该类的虚函数地址

  • 单继承时,派生类 vtable 先复制基类,再覆盖重写的函数,最后追加新函数

  • 多继承需要多个 vptr,调用可能涉及 this 指针调整(thunk)

  • 虚函数有轻微的内存和时间开销,但多数场景可忽略

💡 小作业:定义一个包含 3 个虚函数的基类,和一个重写其中 2 个并新增 1 个虚函数的派生类。用sizeof查看对象大小,并在调试器中查看 vtable 的内容(如果使用 Visual Studio 或 CLion)。


下一篇预告:第16篇《多态(三):抽象类与纯虚函数——设计接口的思想》——什么情况下基类函数无法给出有意义的实现?纯虚函数和抽象类就是答案。它们是 C++ 中定义“接口”的方式,也是很多设计模式的基础。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/5/14 17:23:55

Claude Code 用户遭遇封号或 Token 不足时转向 Taotoken 的平滑迁移指南

&#x1f680; 告别海外账号与网络限制&#xff01;稳定直连全球优质大模型&#xff0c;限时半价接入中。 &#x1f449; 点击领取海量免费额度 Claude Code 用户遭遇封号或 Token 不足时转向 Taotoken 的平滑迁移指南 对于依赖 Claude Code 作为日常编程助手的开发者而言&…

作者头像 李华
网站建设 2026/5/14 17:23:25

Adafruit nRF52开发板Arduino环境配置与Bootloader更新指南

1. 项目概述与核心价值如果你手头有一块Adafruit的Bluefruit nRF52系列开发板&#xff0c;比如那块小巧的nRF52832 Feather或者功能更强的nRF52840 Feather Express&#xff0c;准备用它来搞点蓝牙物联网或者可穿戴设备&#xff0c;那么第一步往往不是写代码&#xff0c;而是把…

作者头像 李华
网站建设 2026/5/14 17:19:38

实战指南:在OBS中集成VST插件实现专业级音频处理

实战指南&#xff1a;在OBS中集成VST插件实现专业级音频处理 【免费下载链接】obs-vst Use VST plugins in OBS 项目地址: https://gitcode.com/gh_mirrors/ob/obs-vst 如果你正在使用OBS进行直播或录制&#xff0c;是否曾为音频质量不够专业而烦恼&#xff1f;OBS-VST插…

作者头像 李华
网站建设 2026/5/14 17:14:11

ZLUDA终极指南:让AMD显卡也能运行CUDA程序的革命性方案

ZLUDA终极指南&#xff1a;让AMD显卡也能运行CUDA程序的革命性方案 【免费下载链接】ZLUDA CUDA on non-NVIDIA GPUs 项目地址: https://gitcode.com/GitHub_Trending/zl/ZLUDA 你是否曾因为手头只有AMD显卡而无法运行那些依赖CUDA的深度学习框架&#xff1f;是否梦想过…

作者头像 李华