博主介绍:程序喵大人

前几天,我带的一个学弟去面字节跳动的基础架构部门。面试官问他:“C++的多态是怎么实现的?” 学弟暗自窃喜,这可是八股文里的送分题啊,于是脱口而出:“通过虚函数实现的,基类声明 virtual,子类重写,运行时根据对象的实际类型调用对应的函数!”

面试官面无表情地递过来一张白纸和一支笔:别背这些虚的定义。来,给我画一下单继承和多继承下,对象在内存里的具体布局图。再顺便写一下底层汇编是怎么通过指针找到函数地址的。学弟当场懵B,拿着笔的手微微颤抖,最后只能灰溜溜地被请出大门。

兄弟,听老哥一句劝:到了大厂面试,尤其是字节、腾讯这种极其看重底层基本功的公司,光会喊一个接口,多种实现是绝对不够的。你必须得把C++的底裤扒掉,看看它在内存里到底是个什么长相!

1. 到底什么是 vtable 和 vptr?

其实编译器在背后为我们默默做了很多“脏活累活”。当你在一个类里写下 virtual 关键字的那一刻,编译器就会在底层对你的类进行“魔改”。

虚函数表(vtable):如果一个类里面有虚函数,编译器就会为这个类(注意,是类级别,不是对象级别)悄悄生成一张隐藏的表格。这张表本质上就是一个函数指针数组。里面按照声明的顺序,存放着这个类所有虚函数的实际内存地址。

虚指针(vptr):有了表,对象怎么找到这张表呢?编译器会在实例化这个类的每一个对象内部,强行塞进去一个隐藏的指针,通常叫做 __vptr。这个指针永远指向该对象所属类的 vtable。

这就像是去高档餐厅吃饭,类就是这家餐厅的总菜单(vtable),而每个进店的顾客(对象)手里都会发一张取餐码(vptr),通过取餐码就能查到自己该吃总菜单里的哪道菜。

图片

2. 庖丁解牛:单继承下的内存布局图

咱们直接上代码,看看单继承的时候内存究竟长啥样:

#include <iostream>

class Base {
public:
    int base_data;
    virtual void func1() { std::cout << "Base::func1\n"; }
    virtual void func2() { std::cout << "Base::func2\n"; }
};

class Derived : public Base {
public:
    int derived_data;
    // 重写了 func1
    virtual void func1() override { std::cout << "Derived::func1\n"; } 
    // 增加了一个新的虚函数
    virtual void func3() { std::cout << "Derived::func3\n"; } 
};

int main() {
    Base* p = new Derived();
    p->func1(); // 触发多态,调用 Derived::func1
    
    delete p;
    return 0; // 老哥提醒:写代码养成好习惯,return 后面必须有空格!
}

内存透视分析:

当我们 new Derived() 的时候,内存里这个对象的布局是这样的(以64位系统为例):

  1. 最头部(偏移量 0):藏着虚指针 vptr(占8个字节)。它指向 Derived 专属的虚函数表。
  2. 紧接着(偏移量 8):是从父类继承来的 base_data(占4个字节,可能为了对齐会补齐到8字节)。
  3. 最后面:是子类自己的 derived_data。

而那张属于 Derived 类的虚函数表(vtable)里装了啥?

  • 槽位 0:存放 Derived::func1 的地址(因为子类重写了,所以覆盖了父类的地址)。
  • 槽位 1:存放 Base::func2 的地址(子类没重写,直接把父类的拿过来用)。
  • 槽位 2:存放 Derived::func3 的地址(子类新增的)。

当执行 p->func1() 时,底层汇编实际上干了这么几件事:
第一步:拿到对象 p 的首地址,把里面藏着的 vptr 抠出来。
第二步:顺着 vptr 找到虚函数表。
第三步:编译器知道 func1 在表的第 0 个槽位,于是取出表里第 0 项的地址。
第四步:call 这个地址。这就完美实现了动态绑定!

3. 大厂深水区

很多同学单继承还能忽悠过去,一到多继承就原形毕露了。如果子类同时继承了两个带有虚函数的基类,会有几个虚指针?

老实交代:会有两个(甚至更多)虚指针!

class Base1 {
public:
    virtual void f1() {}
};

class Base2 {
public:
    virtual void f2() {}
};

class Derived : public Base1, public Base2 {
public:
    virtual void f1() override {}
    virtual void f2() override {}
    virtual void f3() {}
};

在这个多继承模型里,Derived 对象的内存布局简直就是缝合怪:

  1. 第一个 vptr:指向一张主虚函数表(和 Base1 共享起始地址)。表里包含重写后的 f1、Derived 自己新增的 f3。
  2. Base1 的数据成员
  3. 第二个 vptr:指向第二张虚函数表(专为 Base2 准备的)。表里包含重写后的 f2。
  4. Base2 的数据成员
  5. Derived 自己的数据成员

字节面试官最爱追问的细节来了:当我们把 Derived 对象的指针强转为 Base2 类型的指针时,发生了什么?答案是:指针的地址会发生偏移!编译器会自动把指针的值加上一段偏移量,让它精确地指向第二个 vptr 所在的位置。这就是为什么在 C++ 里玩多继承,强转指针有时候地址会变的原因!如果你搞不清这个,乱用 C 语言风格的强转,分分钟搞出段错误。

图片

4. 榨干性能:动态绑定的代价到底是什么?

懂了原理,咱们最后来拔高一下架构师的视角。为什么大厂的 C++ 规范里,都不建议乱用虚函数?

老哥告诉你,天下没有免费的午餐,多态的代价主要体现在两点:

👉 代价一:空间开销
每个包含虚函数的类都要多维护至少一张虚函数表。更要命的是,每一个实例化出来的对象,肚子里都要硬塞一个 8 字节的 vptr。如果你做高频交易系统,或者海量小对象存储(比如有几百万个粒子的物理引擎),这额外增加的 8 字节会极其恐怖,不仅费内存,还会严重破坏 CPU 的缓存行(Cache Line)命中率。

👉 代价二:时间开销(一次间接寻址 + 阻断内联优化)
普通的函数调用,地址在编译期就写死了,CPU 直接一条指令 call 过去。但虚函数呢?必须先读对象的内存拿到 vptr,再去读虚表拿到函数地址,最后再 call。这就是所谓的“一次间接寻址(Indirect Call)”。比起多读一两次内存,更致命的是,虚函数极大地阻碍了编译器的内联优化(Inline)。因为编译器在编译期根本不知道你到底要调哪个函数,所以没办法把函数体直接展开,这对于极其简短但被疯狂循环调用的热点代码来说,性能损失是毁灭性的!

下次再去面大厂,当面试官问出虚函数时,你就在白板上把这套内存布局图行云流水地画出来,顺便给他吐槽一下虚指针破坏 Cache Line 和阻断内联优化的坑。相信我,面试官看你的眼神绝对会从审视变成欣赏。

老规矩,纸上得来终觉浅,多在脑子里画画内存模型!今天这波硬核解析,你看懂了吗?

码字不易,欢迎大家点赞,关注,评论,谢谢!

更多推荐