虚函数表到底放在哪?把 C++ 多态的底层拆开看
Jul 28. 2026

AI文摘
此内容由AI根据文章内容自动生成.
加载中。

先抛一个很多人答错的问题:new 出来的对象在堆上,那虚函数表是不是也在堆上?

不是。栈上建的对象呢?也不在栈上。

虚函数表压根不跟对象走。它住在程序的只读数据段里,从程序加载进去那一刻就定好了,跟你在哪创建对象没有半点关系。这个细节,不少人工作几年都没搞明白,面试一问就露馅。

先把两个东西分开

vtable 和 vptr 是两回事,混在一起说永远讲不清楚。

vtable 是一张函数指针数组,编译器为每个"含有虚函数的类"生成一张,放在 .rodata 段,全类的对象共用。

vptr 是藏在每个对象开头的一个隐藏指针,指向这个对象所属类的那张 vtable。对象在堆,vptr 就在堆;对象在栈,vptr 就在栈。但不管对象在哪,它指过去的那张表始终在 .rodata

表是类的,指针是对象的。

虚函数表和虚指针

表里到底装了什么

写个最小的例子:

class Base {
public:
    virtual void foo() { /* ... */ }
    virtual void bar() { /* ... */ }
    int data = 42;
};

编译器给 Base 生成的 vtable 大致长这样:

.rodata 段
+-----------------------+
| type_info*             |   ← 表头,给 RTTI 用的
| &Base::foo             |   ← 槽位 0
| &Base::bar             |   ← 槽位 1
+-----------------------+

RTTI 全称是 Run-Time Type Information(运行时类型信息)。 RTTI 就是 C++ 为了弥补这个“编译时不知道,运行时才知道”的缺口,而额外塞进程序里的一份“类型身份证”。

表头那个 type_info* 不是摆设。typeiddynamic_cast 能跑起来,靠的就是它。它一直待在偏移 0 的位置,真正的虚函数地址从偏移 8(64 位下)才开始,所以别以为表的第 0 项就是第一个虚函数。

对象这边:

对象实例
+----------+
| vptr     |  ──────→ 指向上面的表
| data=42  |
+----------+

.rodata 是只读的,这点很合理。函数地址在运行期不会变,没有理由让它可写。

C++内存布局

一次虚函数调用经历了什么

Base* ptr = new Derived(); ptr->foo(); 来说,编译器生成的代码干的事很机械:

  1. ptr 拿到对象首地址。
  2. 从首地址处读出 vptr(偏移 0)。
  3. 拿 vptr 去找 vtable。
  4. 按槽位偏移取出函数地址。
  5. 跳过去执行。

虚函数调用过程

对应的 x86-64 汇编大概是:

mov rax, [ptr]       ; 取对象地址
mov rax, [rax]       ; 取 vptr
call [rax + 8]       ; 跳过 type_info,调槽位 0

普通函数调用一步就到,虚函数多了两次内存读,但也就两次,整体还是常数时间。所以"虚函数慢"这个说法要打折听——慢的就是那两次访存。

有个细节值得记一下:虚函数在表里的槽位顺序是按声明顺序排的,编译期就定死,运行时只按偏移取地址,不做任何搜索。这也能解释为什么虚函数不能靠返回值区分重载——表里一个槽位只认名字和参数签名,返回值不同编译器也没法给你排两个槽。

派生类拿到的是一张新表

class Base {
public:
    virtual void foo() {}
};

class Derived : public Base {
public:
    void foo() override {}
    virtual void bar() {}
};

派生类不会去改父类那张表。编译器给 Derived 单独生成一张,把覆盖过的 foo 填进去,新增的 bar 接在后面。Derived 对象里的 vptr 指向的就是这张新表。

这解释了一件事:sizeof(Base)sizeof(Derived) 通常都比"纯数据成员大小"多出 8 字节(64 位),那 8 字节就是 vptr。只要类里有虚函数,不管派生多少层,每个对象都带着这个指针。

析构函数为什么非得是虚的

"有继承就写虚析构"这句话大家都背过,但底层为什么?

如果 ~Base() 不是虚函数,它就不进 vtable。当你 Base* ptr = new Derived(); delete ptr; 的时候,编译器看静态类型是 Base*,又发现析构不是虚的,直接静态绑定调 ~Base()~Derived() 根本不会被唤起,派生类自己申请的资源就漏了。

把析构设成虚的之后,它进了 vtable。delete ptr 时走 vptr 查表,实际调到的是 ~Derived(),析构完再自动链上 ~Base(),整条销毁链才算完整。

继承为什么要虚析构

多继承就没那么干净了

单继承下,对象只有一个 vptr。多继承一上来,每个基类子对象都要带自己的 vptr。

class A { virtual void fa(); };
class B { virtual void fb(); };
class C : public A, public B { virtual void fc(); };

C 的对象里可能有 vptr_Avptr_B,对应两张甚至更多张 vtable。调虚函数的时候,编译器还得算 this 指针的偏移,因为通过不同基类指针访问时,指向的位置不一样。多继承的虚函数调用比单继承慢,慢在偏移调整上。

再往里走就是虚继承。为了解菱形继承,编译器又塞进一个虚基类表(vbtbl)和一个 vbptr。这块细节很绕,面试时能说出"虚继承会引入 vbtbl",已经比大多数人答得好了。 vbtbl 表主要是解决虚继承的偏移问题。

非虚继承导致二义性的根本原因在于内存中存在两份独立的基类副本,编译器无法仅凭类型名区分你要访问的是哪一份;而虚继承之所以能解决该问题,是因为它强制所有派生路径共享同一份基类实例,从物理存储上消除了冗余,使得无论通过哪条路径访问,最终指向的都是同一个确定的对象。

面试怎么答

如果被问到"虚函数表存在哪",把这几层串起来:

vtable 存在程序的只读数据段,每个有虚函数的类一张,所有对象共享。对象里存的是 vptr,指向所属类的 vtable。调用虚函数时,通过对象的 vptr 查表取地址,实现运行时多态。

把"表在 .rodata、指针在对象里、调用靠查表"这三层讲清楚,基本到顶了。

> comment on / twitter
>
CC BY-NC-SA 4.0 2021-PRESENT © RYANUO