大家好,我是小康。
先看一段代码,猜猜两个 sizeof 的结果:
structA {int x;int y;};structB {int x;int y;virtualvoidfoo(){}};cout << sizeof(A) << endl; // ?cout << sizeof(B) << endl; // ?大多数人的第一反应:一样,都是 8。
两个 int,4+4=8,对吧?
但运行一下你会发现——
sizeof(A) = 8sizeof(B) = 16B 凭空多了 8 个字节。
你没有加任何数据成员,只写了一行 virtual void foo() {},对象就变大了。
这 8 个字节是什么?它藏在哪里?它有什么用?
今天我们来彻底搞清楚。
[ 图1:对象内存布局对比图 ]
误解是怎么来的
我们学 C++ 的时候,老师说过:成员函数不占对象的大小,只有数据成员才占空间。
这是对的——对于没有虚函数的类,完全正确。
structWithMethod {int x;voidfoo(){} // 函数不占对象空间voidbar(){}};// sizeof(WithMethod) == 4,跟你想的一样问题出在 virtual 关键字上。
加了 virtual,规则就变了。编译器需要在运行时决定调用哪个函数版本——要做到这一点,它必须在每个对象里埋一根指针。
这根指针就是 vptr(virtual pointer,虚函数表指针)。
真相:vptr 和 vtable
vptr 长什么样
vptr 是一个普通指针,在 64 位系统上占 8 字节。
编译器把它偷偷插入到对象的最开头,在你写的任何数据成员之前。
你看不见它,但它就在那里,开机就有,每个对象都有一份。
vtable 是什么
vptr 指向一张表,叫 vtable(虚函数表)。
vtable 是一个函数指针数组,编译器在编译期为每个有虚函数的类生成一张 vtable,存放在程序的只读数据段(.rodata),程序运行期间不会改变。
[ 图2:vtable 详细布局图 ]
最关键的一点:vtable 是类级别的,不是对象级别的。
B b1, b2, b3;// b1 b2 b3 的 vptr 指向同一张 vtable// 三个对象,只有一份 vtable所有 B 的对象共享同一张 vtable。这是一个精妙的设计——对象只存一根指针,真正的函数地址集中在一处。
派生类覆盖时发生什么
structDerived : B {voidfoo()override{} // 覆盖了 B::foo};编译器会为 Derived 生成一张新的 vtable:
vtable_Derived[0]=&Derived::foo(被覆盖,换成了新地址)vtable_Derived[1]=&B::bar(没覆盖,继承 B 的实现,槽位内容不变)
Derived 对象里的 vptr 指向 vtable_Derived,B 对象里的 vptr 指向 vtable_B。
这就是运行时多态的底层实现:同一行调用代码,通过 vptr 走到了不同的 vtable,最终调用了不同的函数实现。
虚函数调用比普通调用多做了什么
普通函数调用,编译期就知道目标地址,汇编里是一条直接跳转:
call 0x401234 ; 地址是常量,编译期确定,直接跳虚函数调用,需要先找 vptr,再找函数地址,然后才能跳转:
mov rax, [rdi] ; 从 this 取出 vptr(对象最前面的 8 字节)mov rax, [rax + 0] ; 从 vtable 取出 slot[0] 的函数地址call rax ; 间接调用——目标地址运行时才知道多了两次内存读取 + 一次间接跳转。
在 L1 cache 热的情况下,额外开销只有几个时钟周期,通常不是性能瓶颈。但有一个更隐蔽的问题:间接跳转让 CPU 的分支预测失效——CPU 无法提前预测 rax 里会是什么地址,预测失败一次就是 10-20 个时钟周期的冲刷代价。
[ 图3:普通函数调用 vs 虚函数调用路径对比图 ]
C++ 为什么这样设计
这背后是 C++ 的核心设计哲学:零开销原则。
你不写 virtual,不引入 vptr,sizeof 不变,函数调用零额外开销。
一旦写了 virtual,你明确告诉编译器"我需要运行时多态",才付出那 8 字节 + 间接跳转的代价。
这跟 Java 完全不同——Java 里每个非 final 方法默认都是虚函数,每个对象都有对象头,不管你想不想要多态。C++ 的哲学是:你选择的抽象,不应该比手写等价代码多付一分钱。
三个你很可能会踩的坑
坑一:基类没有虚析构函数
这是最经典、最容易被忽视的问题:
structBase { ~Base() {} // 注意:不是 virtual!virtualvoidfoo(){}};structDerived : Base {std::string data; // 有资源需要释放 ~Derived() {} // 正确释放 data};Base* p = new Derived();delete p; // 只调用 ~Base(),~Derived() 没被调用!// data 泄漏了规则:只要类有虚函数,析构函数也必须是虚函数。
structBase {virtual ~Base() {} // ← 加上 virtualvirtualvoidfoo(){}};坑二:构造函数里调虚函数
structBase { Base() { foo(); // ← 这里调的是 Base::foo,不是 Derived::foo! }virtualvoidfoo(){ cout << "Base::foon"; }};structDerived : Base {voidfoo()override{ cout << "Derived::foon"; }};Derived d; // 输出:Base::foo(不是你以为的 Derived::foo)构造 Derived 时,先构造 Base 子对象,此时 vptr 指向 vtable_Base,派生类还没构造完,多态机制尚未"就位"。
规则:不要在构造函数(和析构函数)里依赖虚函数的多态行为。
坑三:用对象而不是指针存多态类型
sizeof(A) = 8sizeof(B) = 160规则:用 unique_ptr<Base> 或 Base* 存多态对象,不要直接用 Base 值类型。
面试角度
面试官:C++ 虚函数的底层原理是什么?
普通答案:
加分答案:
(第二个答案提到了三个对方没问的关键细节:vtable 是类级别的、vptr 在构造函数里设置、性能风险在分支预测——每一条都能引出下一个问题,面试官想多聊的感觉就来了。)
总结一句话:每个有虚函数的对象里,都有一根 8 字节的 vptr,指向该类专属的 vtable(函数指针数组)。虚函数调用 = 取 vptr → 取 vtable[i] → 间接跳转。这是 C++ 运行时多态的全部底层代价——你不写 virtual,一分钱都不多付。
如果这篇文章让你有收获,点赞、在看、转发 让更多 C++ 开发者看到 ~
小康的 25 个 C++ 项目实战课程
过去一年,小康陆续完成了 25 个 C/C++ 硬核项目课程——线程池、内存池、MySQL 连接池、日志库、无锁队列、协程库、高性能网络库、LSM 存储引擎、WebSocket 聊天室、FlashHTTP Server、Mini-STL、Mini-Redis、Mini-Nginx、XRPC 分布式 RPC……
每个都是可用、高性能、能写简历的那种。
想系统补齐项目经验、丰富简历、提升面试可聊内容,可以看这篇完整介绍:
C++项目实战课程海报:
目前已有 450+ 同学在学,课程从 ¥299 起。对课程感兴趣的朋友,加微信 jkfwdkf,备注「 项目实战 」,打包购买有优惠。
或者扫码加小康微信:
推荐站内搜索:最好用的开发软件、免费开源系统、渗透测试工具云盘下载、最新渗透测试资料、最新黑客工具下载……




发表评论