C++ 内存布局详解:类、虚函数、虚表底层原理(一文彻底搞懂)
很多人学 C++ 学到"多态""虚函数"就卡住了,不是因为语法复杂,而是不知道对象在内存里到底长什么样。
这篇文章不贴标准、不玩玄学,我们从内存布局出发,把下面几个问题一次性讲清楚:
-
一个类对象占多少字节?
-
成员函数存在哪里?
-
为什么虚函数能"动态绑定"?
-
虚表(vtable)到底是什么?
-
继承、多继承、虚继承的内存布局差异?
准备好,我们直接拆内存。
一、最基础的起点:空类有多大?
class A {};
问:sizeof(A) 是多少?
直觉可能是 0,但答案是:
sizeof(A) == 1
为什么不是 0?
C++ 要求:每个对象必须有唯一的地址。
如果大小为 0,那下面这种代码就会出问题:
A a[10];
&a[0] == &a[1]; // 如果大小为 0,地址就一样了
所以编译器会偷偷塞一个 char 进去,凑出 1 字节。
✅ 结论:空类 ≠ 零大小,只是为了占位。
二、普通类:只有成员变量才进内存
class Point {
public:
int x;
int y;
};
内存布局(假设 4 字节对齐):
+----------------+
| x (4 bytes) |
+----------------+
| y (4 bytes) |
+----------------+
sizeof(Point) == 8
关键问题:成员函数在哪?
void Point::print() { }
👉 成员函数不在对象里!
所有成员函数都存放在代码段(.text),和普通全局函数没本质区别。
对象里只存成员变量,因为:
-
函数代码对所有对象共享
-
只有数据是每个对象独立的
✅ 对象 = 数据 + 虚表指针(如果有)
❌ 对象 ≠ 函数
三、带虚函数的类:虚表指针登场
一旦类中出现 virtual 函数,事情就变了。
class Base {
public:
virtual void f() {}
int x;
};
内存布局(64 位系统常见情况):
+------------------+
| vptr (8 bytes) | --> vtable
+------------------+
| x (4 bytes) |
| padding (4 bytes)|
+------------------+
sizeof(Base) == 16
发生了什么?
-
编译器在对象最前面 插入了一个隐藏指针:
vptr -
vptr指向一张虚函数表(vtable) -
虚表中存放的是函数指针
四、虚表(vtable)到底是什么?
虚表是一个函数指针数组,由编译器在编译期生成,存放在只读数据段。
Base b;
简化示意:
b:
[vptr] ----> [ &Base::f ]
[ 其他虚函数 ]
x: 0
当你调用:
b.f();
编译器实际做的是:
b.vptr[0](&b);
这是一次间接调用。
五、多态的底层真相:为什么父类指针能调用子类函数?
class Derived : public Base {
public:
void f() override {}
};
Base* p = new Derived();
p->f();
内存布局(Derived 对象):
+------------------+
| vptr | --> Derived 的 vtable
+------------------+
| x |
+------------------+
关键点:
-
p指向的是Derived对象 -
vptr指向的是 Derived 的虚表 -
虚表中
f的位置已经被替换成&Derived::f
所以:
p->f();
// 等价于
p->vptr[0](p);
👉 这就是**动态绑定(运行时多态)**的本质。
✅ 多态 = 虚表 + 虚表指针 + 运行时查表
六、单继承下的虚表变化
class Base {
public:
virtual void f() {}
virtual void g() {}
};
class Derived : public Base {
public:
void f() override {}
virtual void h() {}
};
虚表对比:
Base vtable
[0] Base::f
[1] Base::g
Derived vtable
[0] Derived::f ← 覆盖
[1] Base::g ← 继承
[2] Derived::h ← 新增
规则:
-
虚函数在表中的位置固定
-
子类覆盖父类 → 替换对应槽位
-
子类新增虚函数 → 追加到末尾
七、多继承:多个 vptr 登场
class A {
public:
virtual void fa() {}
int x;
};
class B {
public:
virtual void fb() {}
int y;
};
class C : public A, public B {
public:
void fa() override {}
void fb() override {}
};
内存布局:
+------------------+
| A::vptr | --> C 针对 A 的 vtable
+------------------+
| x |
+------------------+
| B::vptr | --> C 针对 B 的 vtable
+------------------+
| y |
+------------------+
⚠️ 注意:
-
C对象里有 两个 vptr -
A*、B*指向对象的不同位置 -
类型转换可能涉及地址调整
C c;
B* pb = &c; // pb != (void*)&c
八、为什么会有"虚继承"?
考虑经典菱形问题:
class A { public: int x; };
class B : public A {};
class C : public A {};
class D : public B, public C {};
如果不加 virtual:
D:
[B::A::x]
[C::A::x]
👉 x 有两份,歧义。
虚继承解决方式:
class B : virtual public A {};
class C : virtual public A {};
内存布局(典型实现):
+------------------+
| B::vptr |
+------------------+
| offset to A |
+------------------+
| C::vptr |
+------------------+
| offset to A |
+------------------+
| A::x | ← 只有一份
+------------------+
虚继承的关键机制:
-
虚基类偏移表(vbase offset table)
-
通过间接偏移访问公共基类
代价:
-
更复杂的内存布局
-
更慢的访问速度
✅ 虚继承不是为了多态,而是为了共享基类子对象
九、一张总览表
| 场景 | 是否含 vptr | 内存特点 |
|---|---|---|
| 空类 | ❌ | 1 字节占位 |
| 普通类 | ❌ | 仅成员变量 |
| 有虚函数 | ✅ 1 个 | 头部插入 vptr |
| 单继承 | ✅ 1 个 | 共用 vptr |
| 多继承 | ✅ N 个 | 每个基类一个 vptr |
| 虚继承 | ✅ + 偏移信息 | 共享基类,布局复杂 |
十、常见误区澄清
❌ 误区 1:虚函数存在对象里
✅ 错,虚函数在代码段,对象里只有 vptr
❌ 误区 2:多态靠函数名匹配
✅ 错,多态靠 vtable 索引
❌ 误区 3:虚函数一定慢
✅ 只是多了一次间接调用,不是灾难级开销
十一、从汇编视角再看一眼(直觉版)
p->f();
非虚函数:
call Base::f
虚函数:
mov rax, [p] ; 取 vptr
call [rax] ; 取第 0 个虚函数
这就是全部魔法。
十二、总结一句话版本
**C++ 的对象内存布局 = 成员变量 + 可能的虚表指针;
虚函数多态 = 编译期生成虚表 + 运行期查表调用。**