从虚函数到继承布局,系统理解 C++ 对象模型

C++ 对象模型里有一块内容,几乎每次学到继承、多态、虚函数时都会反复碰到,那就是:

  • 虚函数到底怎么实现
  • vtable、vptr 分别是什么
  • 为什么含虚函数的类 sizeof 会变大
  • 单继承、多继承、虚继承下对象内存到底怎么排

这些知识点如果一个个零碎地记,很容易越记越乱。更好的方式是把它们放到同一张图里去理解:虚函数解决的是多态问题,而 vtable/vptr、对象布局、继承层次,都是编译器为实现这件事付出的具体代价。

这篇文章就把这部分内容系统整理一遍。

一、虚函数的底层实现思路

C++ 为了支持运行时多态,主流编译器通常会采用一套非常经典的方案:

  • 为类生成一张虚函数表(vtable)
  • 为对象保存一个隐藏指针(vptr)
  • 调用虚函数时通过 vptr 找到 vtable,再找到最终函数地址

注意:C++ 标准并没有强制要求虚函数必须通过 vtable/vptr 实现。下面讲的是 GCC、Clang、MSVC 等主流编译器的常见实现方式。

1. vtable 是什么

vtable 可以理解成一张“虚函数地址表”,里面存放这个类对应的虚函数入口地址。

例如:

1
2
3
4
5
class Base {
public:
virtual void f();
virtual void g();
};

可以近似理解为编译器会生成:

1
2
3
4
5
6
Base::vtable
+------------------+
| &Base::f |
+------------------+
| &Base::g |
+------------------+

2. vptr 是什么

每个多态对象内部通常都会有一个隐藏成员,叫 vptr,它指向所属类型的 vtable。

例如:

1
Base b;

对象内存通常可以近似理解为:

1
2
3
4
5
+------------------+
| vptr | --> Base::vtable
+------------------+
| 普通成员变量... |
+------------------+

所以可以先记一句最重要的话:

vtable 属于类,vptr 属于对象。


二、vptr 在对象中的位置

vptr 的位置标准没有强制规定,但主流实现里通常把它放在对象起始地址处,也就是对象内存最前面。

为什么通常放在最前面

这样做的好处很直接:

  • 编译器更容易生成统一访问逻辑
  • 运行时取 vptr 的偏移固定
  • 多态调用路径更简单

大小是多少

vptr 本质上只是一个普通指针:

  • 32 位系统下一般是 4 字节
  • 64 位系统下一般是 8 字节

所以只要类支持多态,对象里通常就要多出一个指针大小的隐藏开销。


三、虚函数表存在哪里

vtable 一般存放在只读数据段,比如 .rodata 这类区域。

原因是:

  • vtable 是类级别的数据,不属于某个具体对象
  • 它通常在程序运行期间不会变化
  • 放在只读区更安全

这也意味着:

vtable 不算进对象本身的 sizeof,但 vptr 会算进去。


四、虚函数表什么时候生成

可以按三个阶段理解。

1. 编译期

编译器扫描类定义,只要发现类里有虚函数,就会为这个类准备对应的 vtable 信息。

例如:

1
2
3
4
5
6
7
8
9
10
11
class Base {
public:
virtual void f();
virtual ~Base();
};

class Derived : public Base {
public:
void f() override;
~Derived() override;
};

可以近似理解为:

1
2
Base::vtable    -> [&Base::f, &Base::~Base]
Derived::vtable -> [&Derived::f, &Derived::~Derived]

2. 链接期

链接器会把这些符号和真实函数地址拼接起来,最终放进可执行文件或动态库。

3. 运行期

对象构造时,编译器生成的代码会把对象里的 vptr 设置成当前动态类型对应的 vtable 地址。

例如:

1
2
Base b;     // b.vptr = &Base::vtable
Derived d; // 最终 d.vptr = &Derived::vtable

这里还有一个很容易忽略的点:

在派生类构造过程中,基类子对象先构造,此时它对应部分的 vptr 先指向基类 vtable;等派生类构造完成后,再更新成派生类版本。这也是为什么“构造函数/析构函数里调用虚函数”经常会出现和预期不同的行为。


五、虚函数调用时发生了什么

普通成员函数调用,编译期通常就能确定目标地址。虚函数调用不一样,它要依赖动态分派

例如:

1
2
Base* p = new Derived();
p->f();

编译器通常会生成近似逻辑:

  1. 取出 p 对象中的 vptr
  2. 找到 vtable 中 f 对应的槽位
  3. 跳转到该地址执行

可以近似理解为:

1
call [p->vptr + offset_of_f]

最终调用到的是 Derived::f()

这就是运行时多态真正落地的地方。


六、一个类有多个虚函数,会有几个 vptr

通常只有一个 vptr

例如:

1
2
3
4
5
6
class A {
public:
virtual void f();
virtual void g();
virtual void h();
};

虽然 A 有多个虚函数,但对象里一般仍然只需要一个 vptr:

1
2
3
4
5
+------------------+
| vptr | --> A::vtable
+------------------+
| 成员变量... |
+------------------+

这个 vptr 指向整张 vtable,表里再存多个虚函数地址。

所以:

虚函数多,不代表 vptr 也多。


七、子类重写虚函数时发生了什么

如果子类重写了父类的虚函数,那么子类 vtable 中对应槽位会被替换。

例如:

1
2
3
4
5
6
7
8
9
10
class Base {
public:
virtual void f();
virtual void g();
};

class Derived : public Base {
public:
void f() override;
};

则可以近似理解为:

1
2
3
4
5
6
7
8
9
10
11
12
13
Base::vtable
+------------------+
| &Base::f |
+------------------+
| &Base::g |
+------------------+

Derived::vtable
+------------------+
| &Derived::f | <- f 被重写
+------------------+
| &Base::g | <- g 未重写,沿用父类
+------------------+

结论

  • Base 对象不受影响
  • Derived 对象的 vptr 指向 Derived::vtable
  • 动态分派时会调到重写后的版本

八、含虚函数的类为什么 sizeof 会变大

只要类支持多态,对象里通常就需要放一个 vptr。因此对象大小一般包含:

  • 所有非静态成员变量
  • 对齐填充(padding)
  • vptr

例如:

1
2
3
4
5
class A {
public:
virtual void f();
int x;
};

在 64 位系统下,常见布局近似为:

1
2
3
4
5
6
7
8
+------------------+
| vptr (8B) |
+------------------+
| x (4B) |
+------------------+
| padding (4B) |
+------------------+
sizeof(A) = 16

所以记住:

vptr 算进 sizeof,vtable 不算。


九、父类没有虚函数,子类加了虚函数会怎样

父类完全不受影响,只有子类开始支持多态。

例如:

1
2
3
4
5
6
7
8
9
10
class Base {
public:
int x;
};

class Derived : public Base {
public:
virtual void f();
int y;
};

Base 对象

1
2
3
4
5
6
+------------------+
| x (4B) |
+------------------+
| padding (4B) |
+------------------+
sizeof(Base) = 8

Derived 对象

1
2
3
4
5
6
7
8
9
+------------------+
| vptr (8B) |
+------------------+
| x (4B) |
+------------------+
| y (4B) |
+------------------+
| padding... |
+------------------+

也就是说:

  • Base 没有 vptr
  • Derived 新增 vptr
  • Base 大小不变,Derived 大小增大

十、单继承下的对象布局

来看这组类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class A {
public:
virtual void fA() {}
int a;
};

class B : public A {
public:
virtual void fB() {}
int b;
};

class C : public B {
public:
virtual void fC() {}
int c;
};

1. 单继承下通常只有一个 vptr

对象整体可以近似理解为“基类子对象 + 派生类新增成员”。

A 对象

1
2
3
4
5
+----------------------+
| vptr -> A::vtable |
+----------------------+
| a |
+----------------------+

B 对象

1
2
3
4
5
6
7
+----------------------+
| vptr -> B::vtable |
+----------------------+
| a |
+----------------------+
| b |
+----------------------+

C 对象

1
2
3
4
5
6
7
8
9
+----------------------+
| vptr -> C::vtable |
+----------------------+
| a |
+----------------------+
| b |
+----------------------+
| c |
+----------------------+

2. B、C 没新增成员或虚函数时会怎样

例如:

1
2
3
4
5
6
7
8
class A {
public:
virtual void fA() {}
int a;
};

class B : public A {};
class C : public B {};

这时通常有:

1
sizeof(A) == sizeof(B) == sizeof(C)

因为对象里仍然只是:

  • 一个 vptr
  • 一个成员 a
  • 加上必要对齐

不过要注意:

“大小相同”不等于“类型完全没有差别”。

B 和 C 仍然可能有各自的动态类型信息和各自的 vtable,只是对象大小没有继续增加。


十一、多继承下的对象布局

多继承时,最重要的结论是:

每个需要多态支持的基类子对象,通常都有自己独立的 vptr。

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class A {
public:
virtual void fA();
int a;
};

class B {
public:
virtual void fB();
int b;
};

class C : public A, public B {
public:
int c;
};

C 对象通常近似这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
+----------------------+
| A::vptr |
+----------------------+
| a |
+----------------------+
| padding |
+----------------------+
| B::vptr |
+----------------------+
| b |
+----------------------+
| padding |
+----------------------+
| c |
+----------------------+
| padding |
+----------------------+

为什么对象会变大

因为多继承会带来:

  • 多个基类子对象
  • 多个 vptr
  • 更多对齐填充

所以多继承对象通常比单继承对象更大、更复杂。


十二、虚继承是什么,解决什么问题

虚继承主要用来解决菱形继承中的公共基类重复问题。

经典例子:

1
2
3
4
class A { public: int a; };
class B : virtual public A {};
class C : virtual public A {};
class D : public B, public C {};

如果没有虚继承:

  • D 中会有两份 A
  • 访问 a 时会出现二义性

使用虚继承后:

  • B 和 C 不再各自持有独立 A
  • 最终 D 中只保留一份共享的 A 子对象

虚继承解决的问题

  • 避免菱形继承中基类重复
  • 避免成员访问二义性
  • 保证公共虚基类只有一份实体

十三、虚继承会带来什么开销

虚继承不是没有代价的,它通常会带来:

  • 更复杂的对象布局
  • 额外的偏移量信息
  • 访问虚基类时可能需要额外间接寻址
  • 构造和析构顺序更复杂

所以它本质上是:

为了解决继承结构问题,接受一定实现复杂度和运行开销。


十四、虚函数调用为什么比普通函数慢

虚函数调用通常比普通函数慢,主要不是因为“函数体本身执行更慢”,而是因为它需要多一步动态分派

普通函数调用:

  • 编译期就能确定目标地址
  • 更容易内联

虚函数调用:

  • 运行时先取 vptr
  • 再查 vtable
  • 再跳到真实函数地址

这带来的影响通常有:

  • 一次额外间接寻址
  • 更难被内联优化
  • 某些场景下不利于分支预测

不过也要客观看:

在大多数普通业务代码里,这点开销未必是真正瓶颈。只有在高频、小函数、极致性能敏感路径里,虚调用成本才更值得关注。


十五、如何减少虚函数调用开销

常见思路有:

  • 能静态绑定时就不要强行做动态多态
  • 对热点路径减少极短小虚函数的高频调用
  • 使用 final 帮助编译器做去虚拟化优化
  • 在性能极敏感场景下考虑模板、CRTP 等静态多态方案

不过前提永远是:

先确认瓶颈真的在这里,再谈优化。


十六、虚函数一定通过 vtable 实现吗

不一定。

C++ 标准只规定虚函数要支持动态绑定语义,但没有规定编译器必须使用 vtable/vptr。

现实里主流编译器几乎都采用这套方案,因为它工程上成熟、效率高、实现稳定。

所以学习对象模型时,默认按 vtable/vptr 理解是完全合理的。


十七、最后做一个总总结

如果把这篇文章压缩成几句话,最关键的结论就是:

关于虚函数

  • 虚函数用于支持运行时多态
  • 主流实现依赖 vtable + vptr

关于 vptr

  • vptr 通常位于对象起始位置
  • 它属于对象
  • 单继承通常一个对象一个 vptr
  • 多继承通常一个多态基类子对象对应一个 vptr

关于 vtable

  • vtable 属于类级别数据
  • 通常放在只读数据段
  • 不计入 sizeof

关于继承

  • 单继承布局相对简单
  • 多继承会让对象更大、更复杂
  • 虚继承主要用来解决菱形继承中的重复基类问题

对象模型这部分的关键,不是死背每一种布局图,而是抓住一条主线:

编译器为了支持多态,需要把“类型信息”和“调用入口”嵌进对象模型里;而 vtable、vptr、继承布局变化,本质上都是这件事的实现结果。

理解了这条主线,再回头看虚函数、继承、多继承、虚继承,就不会再是一些零碎结论了。


从虚函数到继承布局,系统理解 C++ 对象模型
https://breaker505.github.io/2026/03/01/cpp-object-model-virtual-functions/
作者
爱发呆的鱼
发布于
2026年3月1日
许可协议