成员变量存储和访问
存储顺序: 成员变量在内存中的存储顺序与它们在类定义中出现的顺序一致。 静态成员:静态成员变量不属于任何一个对象,而是存储在程序的数据段中,因此不占用类对象的内存空间 。 地址连续性:一个类对象的内存空间是连续的,其成员变量的地址也是依次排列的。后声明的成员变量拥有更高的内存地址。
非静态成员变量与成员变量偏移值 (Offset)
非静态成员变量是构成 对象 自身状态的数据。每个对象都有自己的一份副本, 存储在每个对象被分配到的内存块中(可能在栈上、堆上或静态数据区)。其访问机制是 this 指针与偏移值.
当调用一个对象的非静态成员函数时,编译器会隐式地传递一个指向该对象的指针,这个指针就是 this。
访问一个成员变量 m_data 的过程如下:
- 隐式转换:在成员函数内部,你写的 m_data 会被编译器自动翻译成 this->m_data。
- 偏移值 (Offset):在编译时,编译器会根据成员变量在类定义中的声明顺序和内存对齐规则,计算出每个成员相对于对象起始地址的偏移值。这是一个固定的、在编译时就已知的常量。
- 地址计算:在运行时,访问 this->m_data 的实际操作是:成员地址 = this 指针的值 + m_data 的偏移值
多重继承与 this 指针调整
派生类对象包含了其所有基类的子对象。这些子对象在内存中通常按照继承声明的顺序依次排列。
示例:
1 | class Base1 { public: int m_base1; }; |
一个 Derived 对象的内存布局示意图:
1 | +-------------------+ <- Derived 对象的起始地址 |
1 | Derived d; |
指针转换 (Base2* pBase2 = &d;):
&d 的地址是 Derived 对象的起始地址,也就是 Base1 子对象的起始地址。
但是 pBase2 必须指向一个 Base2 对象。在 d 的内存布局中,Base2 子对象并不在起始位置。
为了让 pBase2 正确指向 d 内部的 Base2 子对象,编译器必须在赋值时对指针进行调整: pBase2 = (Base2)( (char)&d + sizeof(Base1) );
这个 地址平移 的过程就是 this 指针调整。
成员函数调用 (d.some_Base2_method()):
some_Base2_method() 是 Base2 的成员函数。它被编译时,假定其 this 指针会指向一个 Base2 对象的起始地址,以便用正确的偏移量(例如,m_base2 的偏移量为 0)来访问成员。
当通过 d 对象调用它时,d 的地址是整个对象的起始地址。如果直接把这个地址传给 some_Base2_method() 作为 this 指针,函数内部就会错误地把 Base1 的成员 m_base1 当作 m_base2 来访问。
因此,在调用前,编译器会自动生成代码,将 d 的地址进行调整(加上 sizeof(Base1)),然后将这个 调整后 的地址作为 this 指针传递给 some_Base2_method()。
结论:在多重继承中,当涉及到指向 非首个基类 的指针转换或成员函数调用时,编译器会自动进行 this 指針的平移调整,以确保 this 指针总是正确地指向相应子对象的起始地址。
成员变量指针重申
对象成员变量的指针 (Pointer to a Data Member of an Object):
这是一个普通的指针,例如 int*。
它存储的是一个特定对象中某个成员变量的具体内存地址 。例如 int* p1 = &myobj.m_i; 。
成员变量指针 (Pointer to Member):
这是一种特殊的指针类型,例如 int MyClass::* , 意思是“指向 MyClass 类中 int 类型成员的指针”。
它不存储内存地址,而是存储成员变量在类布局中的偏移值 。它的 sizeof 值通常与普通指针相同(如4字节) 。
1 | struct MyClass { int a; }; |
NULL 成员变量指针:
为了区分“指向类第一个成员(偏移值为0)的指针”和“不指向任何成员的空指针”,编译器对空成员变量指针做了特殊处理 。
将一个成员变量指针赋值为 0 或 NULL,其内部表示通常为 -1(即 0xffffffff),而不是 0 。
内存对齐 (Memory Alignment)
内存对齐 是指数据在内存中的存放位置受到一定的限制,并非可以放置在任意地址。通常,一个大小为 N 字节的数据类型,其存放的起始地址必须是 N 的整数倍。
- char (1字节): 可以在任何地址。
- short (2字节): 存放的起始地址必须是 2 的倍数 (例如 0, 2, 4, …)。
- int, float (4字节): 存放的起始地址必须是 4 的倍数 (例如 0, 4, 8, …)。
- long long, double, void* (8字节): 存放的起始地址必须是 8 的倍数 (例如 0, 8, 16, …)。
这主要是出于性能考虑,是硬件(CPU)对内存访问的特性要求。
- CPU 读取效率:CPU
访问内存不是逐字节进行的,而是以一个“字”(Word)为单位进行块读取。一个字的大小通常是
4 字节(32位系统)或 8 字节(64位系统)。
- 对齐的访问:如果一个 4 字节的 int 存储在 4 的倍数地址上(例如地址 0x1004),那么 CPU 只需要进行 一次 内存读取操作就可以完整获取数据。
- 未对齐的访问:如果这个 int 被存储在非 4 的倍数地址上(例如地址 0x1005),它会跨越两个内存读取边界。CPU 为了读取这个数据,可能需要进行两次内存读取,然后还需要进行位移、合并等操作才能得到完整的数据。这会大大降低访问速度。在某些硬件平台上,未对齐的访问甚至会直接导致硬件异常。
为了提高性能并避免潜在的硬件问题,编译器会自动进行内存对齐,通过插入一些不使用的字节来调整成员变量的位置,这些被插入的字节被称为填充 (Padding)。
内存对齐的核心规则
一个类或结构体的内存布局主要遵循以下三条规则:
规则 1:成员变量的对齐
成员变量的起始地址 必须是 其自身对齐值 和 指定对齐值中 较小者 的整数倍。
- 自身对齐值:该成员变量数据类型本身的大小,例如 int 是 4。
- 指定对齐值:通常由编译器默认或通过 #pragma pack(n) 设置。我们先假设使用编译器默认值。
简单来说,每个成员变量的偏移量 (offset) 必须是它自身大小的整数倍。如果当前偏移量不满足,编译器会在前一个成员后面插入填充字节。
规则 2:类的整体对齐
类(或结构体)的最终总大小 必须是 其所有成员变量中最大对齐值的整数倍。
这个最大的对齐值也称为这个类(或结构体)的对齐模数 (Alignment Modulus)。
如果计算出的总大小不满足此规则,编译器会在最后一个成员变量后面继续插入填充字节,直到满足为止。
规则 3:嵌套结构体的对齐
如果一个结构体 A 包含另一个结构体 B,那么成员 B 的对齐要求以 B 内部最大的成员对齐值为准。
1 | class MyClass1 { |
C++ 特性对内存布局的影响
普通继承
在普通继承中,派生类的内存布局通常是基类成员在前,派生类新增成员在后。对齐规则会应用于整个对象。
1 | class Base { |
虚函数 (Virtual Functions)
如果类中包含虚函数(或继承自包含虚函数的基类),编译器会为该类的每个对象添加一个虚函数指针 (vptr)。
- vptr 的大小与指针大小相同(32位系统为4字节,64位系统为8字节)。
- vptr 通常被放置在 对象内存布局的最前端。
- vptr 本身也参与内存对齐计算。 这里父类的vptr被子类继承,子类对象中只有一个vptr。内存布局为 [vptr(8)][a(4)][padding(4)][b(8)]。
1
2
3
4
5
6
7
8
9
10class VirtualClass {
virtual void func() {}
int a; // 4
};
// sizeof(VirtualClass) is 16 ( 8+4+padded, includes vptr)
class VirtualClassDerived : public VirtualClass {
double b; // 8
};
// sizeof(VirtualClassDerived) is 24 (16+8, includes vptr)
虚继承 (Virtual Inheritance)
虚继承用于解决菱形继承中的数据冗余问题。其实现机制更复杂,通常会引入一个虚基类指针 (vbptr)。vbptr 指向一个虚基类表,表中记录了虚基类子对象相对于 vbptr 地址的偏移量。这导致虚基类的成员不再和派生类成员连续存储。其具体布局因编译器而异,但通常会把共享的虚基类子对象放在派生类对象内存的末尾。