【C++】揭秘类与对象的内在机制
在 C++ 中,“类”和“对象”是最核心的概念,但很多人只是会用,却不知道它们在内存里到底是怎么布局的、编译器到底做了什么、虚函数表是怎么回事、this 指针藏在哪里……
这篇我们从底层视角彻底把类与对象的内存模型、构造/析构机制、虚函数机制、this 指针、多重继承下的布局等讲透。
一、类与对象在内存中的本质区别
一句话概括:
- 类 是蓝图(类型定义),不占内存(只存在于编译期符号表)
- 对象 是根据蓝图实例化出来的实体,真正占用内存
class Person {
public:
string name; // 8 字节(64位系统,大多数实现)
int age; // 4 字节
double height; // 8 字节
};
Person p1; // p1 是对象,占 8 + 4 + 8 = 20 字节(可能有对齐,实际 24 字节)
Person* p2 = nullptr;// p2 是指针,只占 8 字节
结论:定义类不耗内存,创建对象才真正分配内存。
二、最简单的对象内存布局(无虚函数)
class Student {
public:
string name; // 通常 24 字节(含 SSO 小字符串优化)
int score; // 4
char grade; // 1
// 可能有 padding(内存对齐)
};
典型 64 位系统内存布局(考虑对齐):
偏移 内容 大小
0 name 24 字节(std::string 常见实现)
24 score 4 字节
28 grade 1 字节
29~31 padding 3 字节(对齐到 4 字节)
32 (下一个成员或结束)
总大小:通常 32 字节(而非 24+4+1=29)
内存对齐规则(非常重要):
- 每个成员按自己的对齐要求对齐(int 4 字节,double 8 字节等)
- 整个结构体大小是对齐到最大成员的对齐边界
三、带虚函数的对象内存布局(虚表指针)
class Animal {
public:
virtual void speak() { cout << "Animal\n"; }
virtual void eat() { cout << "Eat\n"; }
int age = 0;
};
Animal a;
关键点:只要类中有虚函数,编译器就会在对象内存布局的最前面插入一个虚表指针(vptr),占 8 字节(64 位系统)。
内存布局变成:
偏移 内容
0 vptr(指向该类的虚函数表 vtable)
8 age(4 字节 + 4 字节 padding)
虚表(vtable) 是什么?
- 每个有虚函数的类(不是对象)在程序启动时(或第一次使用前)会生成一张虚函数表
- 虚表是一个函数指针数组,按声明顺序存放虚函数地址
- 同一个类的所有对象共享同一张虚表
Animal 的虚表(示例):
0: Animal::speak 的地址
1: Animal::eat 的地址
结论:
含虚函数的类,每个对象都会多出 8 字节(vptr),指向类的虚表。
四、this 指针到底是什么?
this 是一个隐式参数,每个非静态成员函数都自动带有一个隐藏的 this 指针。
void speak() { cout << age; }
// 编译器看到的真实样子
void speak(Animal* const this) { cout << this->age; }
调用方式:
Animal a;
a.speak(); // 编译器自动传入 &a 作为 this
this 的类型:
- 在非 const 成员函数中:
T* const this - 在 const 成员函数中:
const T* const this
五、继承下的内存布局(单继承)
class Animal {
public:
virtual void speak() {}
int age = 1;
};
class Dog : public Animal {
public:
virtual void speak() override { cout << "Woof\n"; }
int legs = 4;
};
Dog 对象的内存布局(典型实现):
偏移 内容
0 vptr(指向 Dog 的虚表)
8 age (从 Animal 继承)
12 legs (Dog 自己的成员)
Dog 的虚表:
0: Dog::speak (覆盖了 Animal::speak)
1: Animal::eat (如果有)
六、多重继承 + 虚继承下的布局(最复杂部分)
多继承时,每个基类可能有自己的 vptr,导致对象中有多个 vptr。
class A { virtual void f(); int a; };
class B { virtual void g(); int b; };
class C : public A, public B { int c; };
C 对象可能的布局(非虚继承):
0 vptr for A
8 a
12 vptr for B
20 b
24 c
虚继承(virtual inheritance)会引入 vbptr(虚基表指针),解决菱形继承问题。
虚继承布局(更复杂,包含偏移量调整):
- 包含 vbptr(虚基表指针)
- 虚基表记录虚基类的偏移
- 虚函数表也会有额外的调整(thunk)
一句话总结:
多继承 + 虚继承是 C++ 中最复杂的内存布局,强烈建议能不用就不用(现代设计倾向于组合优于继承)。
七、构造与析构的底层顺序
class Base {
public:
Base() { cout << "Base ctor\n"; }
~Base() { cout << "Base dtor\n"; }
};
class Derived : public Base {
public:
Derived() { cout << "Derived ctor\n"; }
~Derived() { cout << "Derived dtor\n"; }
};
构造顺序:从基类 → 派生类
析构顺序:从派生类 → 基类(与构造完全相反)
带成员变量的顺序:
- 基类构造
- 成员变量按声明顺序构造(不是初始化列表顺序)
- 派生类构造体执行
析构完全相反。
八、总结:类与对象内存机制核心要点
- 普通类:成员按声明顺序 + 内存对齐布局
- 含虚函数的类:对象开头插入 vptr(8 字节),指向类的虚表
- this 指针:隐式传入的指向当前对象的指针
- 继承:子类对象包含基类部分(前置布局)
- 多继承:可能有多个 vptr
- 虚继承:引入 vbptr + 虚基表,解决菱形问题
- 构造顺序:基类 → 成员 → 自身
- 析构顺序:自身 → 成员 → 基类
最后送你一个快速判断内存布局的口诀
- 有虚函数? → 多 8 字节 vptr
- 多继承? → 可能多个 vptr
- 虚继承? → 更复杂,出现 vbptr
- 成员变量? → 按声明顺序 + 对齐
- 空类? → 大多数编译器占 1 字节(避免地址相同)
想继续深入哪个部分?
比如:
- 虚函数表具体长什么样(可以用 gdb 或 dump 看)
- 多继承/虚继承下的 this 指针调整
- 成员函数的地址到底存哪里
- 空基类优化(EBO)
- 虚析构函数为什么必须有
欢迎继续问~