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 的倍数。
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。非虚函数是静态绑定的,编译时就确定了调用地址。
11.4 单继承下的虚函数表
单继承时,事情还算简单。子类继承父类的 vtable,然后做三件事:
- 覆盖(override)父类的虚函数——把 vtable 里对应的函数指针换成自己的
- 继承(inherit)父类未覆盖的虚函数——保持 vtable 里的指针不变
- 新增(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 子对象的起始位置。这个偏移量在编译时就确定了。
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(虚基类偏移量表),表里记录了虚基类子对象相对于当前子对象的偏移量。
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++。
公众号:蓝海资料掘金营,微信 deep3321