11、C++对象模型(上):类与对象的内存布局,虚函数表(vtable)的构造与继承

好,咱们今天聊点硬核的——C++对象模型。说实话,我当年刚入行时,觉得这东西离实际开发很远。直到有一次,我在一个大型游戏引擎里排查一个诡异的崩溃问题,折腾了两天,最后发现是虚函数表指针被踩坏了。嗯,从那以后,我再也不敢说“对象模型不重要”这种话了。

你想想看,C++之所以能兼顾性能和抽象,靠的就是这套精巧的对象内存布局。今天咱们先啃上半部分:类与对象在内存里到底长什么样?虚函数表又是怎么构建和继承的?

11.1 一个空类占多大?

先问个看似简单的问题:一个空类,sizeof 是多少?

class Empty {};

int main() {
    std::cout << sizeof(Empty); // 输出多少?
}

答案是 1 字节。为什么?因为 C++ 标准要求:同一个类型的两个不同对象,必须有不同的地址。如果空类大小为 0,那声明一个数组 Empty arr[10],所有元素的地址都一样,这显然不合理。所以编译器会偷偷塞一个字节进去,保证每个对象有唯一地址。

我个人习惯把这种“看不见的字节”叫做编译器的“小把戏”。你写代码时看不到,但它确实存在。

11.2 非静态成员变量的布局

来看一个稍微复杂点的类:

class Point {
public:
    int x;
    int y;
    static int count;
};

这个类在内存里怎么排?很简单:x 在前,y 在后,各占 4 字节,总共 8 字节。静态成员 count 不占对象空间,它存在全局数据区。

但这里有个坑——内存对齐。我举个例子:

class AlignTest {
    char a;   // 1 字节
    int b;    // 4 字节
    char c;   // 1 字节
};

直觉上,大小应该是 1+4+1=6 字节。但实际 sizeof 往往是 12 字节(32位系统)或 16 字节(64位系统)。为什么?因为编译器会在 a 后面填充 3 个字节,让 b 对齐到 4 字节边界;然后在 c 后面再填充 3 个字节,让整个对象大小是 4 的倍数。

我曾经在嵌入式项目里吃过这个亏。两个结构体成员顺序不同,导致内存占用差了 30%。后来我养成了一个习惯:把大的成员往前放,小的往后放,能省不少空间。

11.3 虚函数表的核心原理

好,重点来了。当一个类有虚函数时,编译器会给它加一个隐藏成员——虚函数表指针(vptr)。这个指针指向一个虚函数表(vtable),表里存的是函数指针。

class Base {
public:
    virtual void func1() { std::cout << "Base::func1\n"; }
    virtual void func2() { std::cout << "Base::func2\n"; }
    void func3() {}  // 非虚函数
};

这个 Base 对象的内存布局大概是这样的:

+------------------+
| vptr (8字节)     |  --> 指向 vtable
+------------------+
| 其他成员变量     |
+------------------+

而 vtable 本身是一张函数指针表:

vtable for Base:
+----------------------+
| &Base::func1()       |
+----------------------+
| &Base::func2()       |
+----------------------+
| (可能还有 RTTI 信息) |
+----------------------+

注意,func3() 不在 vtable 里。只有虚函数才进 vtable。非虚函数是静态绑定的,编译时就确定了调用地址。

核心结论:每个有虚函数的类,都有一个 vtable。每个该类对象,都有一个 vptr 指向该 vtable。vptr 在对象的最前面(大多数编译器如此)。

11.4 单继承下的虚函数表

单继承时,事情还算简单。子类继承父类的 vtable,然后做三件事:

  1. 覆盖(override)父类的虚函数——把 vtable 里对应的函数指针换成自己的
  2. 继承(inherit)父类未覆盖的虚函数——保持 vtable 里的指针不变
  3. 新增(add)自己的虚函数——追加到 vtable 末尾
class Derived : public Base {
public:
    void func1() override { std::cout << "Derived::func1\n"; }
    virtual void func4() { std::cout << "Derived::func4\n"; }
};

Derived 的 vtable 长这样:

vtable for Derived:
+----------------------+
| &Derived::func1()    |  ← 覆盖了 Base::func1
+----------------------+
| &Base::func2()       |  ← 继承自 Base,没变
+----------------------+
| &Derived::func4()    |  ← 新增的
+----------------------+

调用时,通过对象的 vptr 找到 vtable,再通过偏移找到对应的函数指针。这就是所谓的“动态绑定”。

11.5 多继承下的虚函数表

多继承就有点意思了。一个子类继承多个父类,每个父类都有自己的 vptr 和 vtable。子类对象里会有多个 vptr。

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

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

class MultiDerived : public Base1, public Base2 {
public:
    void f1() override {}
    void f2() override {}
};

MultiDerived 对象的内存布局:

+------------------+
| vptr for Base1   |  --> 指向 Base1 相关的 vtable
+------------------+
| Base1 的成员     |
+------------------+
| vptr for Base2   |  --> 指向 Base2 相关的 vtable
+------------------+
| Base2 的成员     |
+------------------+
| MultiDerived 成员|
+------------------+

这里有个关键点:当你把 MultiDerived* 转型为 Base2* 时,指针会偏移,指向对象中 Base2 子对象的起始位置。这个偏移量在编译时就确定了。

多继承下的 this 指针调整是个容易出 bug 的地方。我曾经见过一个 bug:有人把 Base2* 直接 reinterpret_cast 成 MultiDerived*,结果访问成员时全乱了。正确的做法是用 static_cast 或 dynamic_cast,编译器会自动处理偏移。

11.6 虚继承的复杂布局

虚继承是为了解决“菱形继承”问题。但它的代价是内存布局更复杂。

class A { virtual void f() {} };
class B : virtual public A {};
class C : virtual public A {};
class D : public B, public C {};

在虚继承下,子类对象里不直接包含虚基类的成员,而是通过一个指针(或偏移量)来间接访问。这个指针通常叫 vbptr(virtual base pointer)。

D 对象的内存布局大致是:

+------------------+
| B 的 vptr        |
+------------------+
| B 的成员         |
+------------------+
| C 的 vptr        |
+------------------+
| C 的成员         |
+------------------+
| D 的成员         |
+------------------+
| A 的成员         |  ← 虚基类子对象,放在最后
+------------------+

每个包含虚基类的子对象里,都有一个 vbptr,指向一个 vbtable(虚基类偏移量表),表里记录了虚基类子对象相对于当前子对象的偏移量。

说实话,虚继承的性能开销不小。每次访问虚基类成员,都要通过 vbptr 查偏移,相当于多了一次间接寻址。我个人建议:除非真的需要菱形继承,否则尽量别用虚继承。用组合代替继承,往往更清晰。

11.7 动手验证一下

光说不练假把式。你可以用下面这段代码,实际看看对象的内存布局:

#include <iostream>

class Base {
public:
    virtual void f() {}
    int a = 1;
};

class Derived : public Base {
public:
    void f() override {}
    int b = 2;
};

int main() {
    Derived d;
    // 打印地址,观察 vptr 和成员的位置
    std::cout << "对象地址: " << &d << "\n";
    std::cout << "a 的地址: " << &d.a << "\n";
    std::cout << "b 的地址: " << &d.b << "\n";
    // 在 64 位系统上,a 和 b 之间应该有 8 字节的 vptr
    std::cout << "sizeof(Derived): " << sizeof(Derived) << "\n";
}

运行一下,你会发现 vptr 确实占在最前面,成员变量依次排列在后面。

11.8 本章小结

咱们今天聊了这些:

  • 空类占 1 字节,这是 C++ 标准的要求
  • 成员变量按声明顺序排列,但要注意内存对齐
  • 有虚函数的类,对象最前面有一个 vptr,指向 vtable
  • 单继承时,子类覆盖、继承、追加虚函数到 vtable
  • 多继承时,对象里有多个 vptr,转型时需要 this 调整
  • 虚继承通过 vbptr 间接访问虚基类,布局更复杂

理解这些,你就能看懂很多 C++ 的“魔法”了。比如为什么虚函数调用比普通函数慢一点?因为多了一次间接寻址。为什么多继承转型后指针值会变?因为要调整到正确的子对象位置。

这些东西,说白了就是编译器在背后帮你做的一系列“翻译”工作。你写的是高级的面向对象代码,但到了机器层面,全是地址、偏移量和函数指针。理解这一层,你才能真正驾驭 C++。

C++ 对象模型核心结构 vptr(虚函数表指针,8字节) 成员变量(按声明顺序排列) 内存对齐填充(padding) 对象内存布局 vtable(虚函数表) Base::func1() 或 Derived::func1() Base::func2()(继承未覆盖) Derived::func4()(新增) 虚函数表(vtable) 继承体系下的 vtable 构造 Base vtable Derived vtable MultiDerived vtable func1() → Base::func1 func2() → Base::func2 func1() → Derived::func1(覆盖) func2() → Base::func2(继承) func4() → Derived::func4(新增) 多继承:多个 vptr + 多个 vtable 转型时 this 指针需调整偏移

公众号:蓝海资料掘金营,微信 deep3321