ZaynPei Lv6

成员变量存储和访问

存储顺序: 成员变量在内存中的存储顺序与它们在类定义中出现的顺序一致。 静态成员:静态成员变量不属于任何一个对象,而是存储在程序的数据段中,因此不占用类对象的内存空间 。 地址连续性:一个类对象的内存空间是连续的,其成员变量的地址也是依次排列的。后声明的成员变量拥有更高的内存地址。

非静态成员变量与成员变量偏移值 (Offset)

非静态成员变量是构成 对象 自身状态的数据。每个对象都有自己的一份副本, 存储在每个对象被分配到的内存块中(可能在栈上、堆上或静态数据区)。其访问机制是 this 指针与偏移值.

当调用一个对象的非静态成员函数时,编译器会隐式地传递一个指向该对象的指针,这个指针就是 this。

访问一个成员变量 m_data 的过程如下:

  • 隐式转换:在成员函数内部,你写的 m_data 会被编译器自动翻译成 this->m_data
  • 偏移值 (Offset):在编译时,编译器会根据成员变量在类定义中的声明顺序和内存对齐规则,计算出每个成员相对于对象起始地址的偏移值。这是一个固定的、在编译时就已知的常量
  • 地址计算:在运行时,访问 this->m_data 的实际操作是:成员地址 = this 指针的值 + m_data 的偏移值

多重继承与 this 指针调整

派生类对象包含了其所有基类的子对象。这些子对象在内存中通常按照继承声明的顺序依次排列。

示例:

1
2
3
class Base1 { public: int m_base1; };
class Base2 { public: int m_base2; };
class Derived : public Base1, public Base2 { public: int m_derived; };

一个 Derived 对象的内存布局示意图:

1
2
3
4
5
6
7
8
9
10
+-------------------+  <- Derived 对象的起始地址
| Base1 subobject |
| (m_base1) |
+-------------------+ <- Base2 子对象的起始地址
| Base2 subobject |
| (m_base2) |
+-------------------+
| Derived's members |
| (m_derived) |
+-------------------+
this 指针调整的必要性: 现在,考虑以下代码:
1
2
3
Derived d;
Base2* pBase2 = &d; // 指针转换(向上转型)
d.some_Base2_method(); // 调用继承自 Base2 的方法
这里出现了两个关键问题,都需要 this 指针调整来解决:

指针转换 (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
2
3
4
struct MyClass { int a; };
int MyClass::* p = &MyClass::a;
MyClass obj;
obj.*p = 42; // 通过成员变量指针访问对象的成员
这样可以在不知道对象实际地址的情况下,描述“某个类的成员变量”。

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
2
3
4
5
6
7
8
9
10
11
12
13
class MyClass1 {
char a; // 1 字节
int b; // 4 字节
char c; // 1 字节
};
// 最终内存占用: 12 字节

class MyClass2 {
int b; // 4 字节
char a; // 1 字节
char c; // 1 字节
};
// 最终内存占用: 8 字节

C++ 特性对内存布局的影响

普通继承

在普通继承中,派生类的内存布局通常是基类成员在前派生类新增成员在后。对齐规则会应用于整个对象。

1
2
3
4
5
6
7
8
9
10
class Base {
int x; // 4
char y; // 1
}; // sizeof(Base) is 8 (padded)

class Derived : public Base {
double z; // 8
};
// sizeof(Derived) is 16 (padded)
// 每个成员按照自己的对齐规则排列,每个类按照最大对齐值进行整体对齐

虚函数 (Virtual Functions)

如果类中包含虚函数(或继承自包含虚函数的基类),编译器会为该类的每个对象添加一个虚函数指针 (vptr)。

  • vptr 的大小与指针大小相同(32位系统为4字节,64位系统为8字节)。
  • vptr 通常被放置在 对象内存布局的最前端。
  • vptr 本身也参与内存对齐计算。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    class 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)
    这里父类的vptr被子类继承,子类对象中只有一个vptr。内存布局为 [vptr(8)][a(4)][padding(4)][b(8)]。

虚继承 (Virtual Inheritance)

虚继承用于解决菱形继承中的数据冗余问题。其实现机制更复杂,通常会引入一个虚基类指针 (vbptr)。vbptr 指向一个虚基类表,表中记录了虚基类子对象相对于 vbptr 地址的偏移量。这导致虚基类的成员不再和派生类成员连续存储。其具体布局因编译器而异,但通常会把共享的虚基类子对象放在派生类对象内存的末尾