| 引言: 了解对象的内存模型能够帮助我们更好地使用对象。 |
- 一、对象的内存模型
- 二、单继承下的对象内存模型
- 三、存在遮蔽时的对象内存模型
- 四、多继承下的对象内存模型
- 五、虚继承关系下的对象内存模型
- 要点总结
一、对象的内存模型
一个类包含成员变量和成员函数,那么按照我们的直观思维很容易想到这么一个内存模型:每个类的对象占据一份独立的内存,内存中分别存储着属于对象的成员变量和成员函数,如下图所示:
只需稍加思考,我们就能想到这一模型的不合理之处:函数代码是要复用的,如此给每个对象都保存一份成员函数代码破坏了函数的复用性,造成了内存空间的浪费,导致程序低效。于是我们稍加改进,就得到了如下的内存模型:
这就是编译器实际使用的模型:将成员变量和成员函数代码分开储存,为每个对象单独分配成员变量的内存,而统一使用一块内存储存类的成员函数代码,所有对象共用这块内存中的成员函数。
随着学习的深入,我们接触到了静态成员变量这一特殊的成员变量——它不属于对象而属于类,所有对象共用一个静态成员变量,独立存储在所有对象空间之外。于是我们加以修改,得到了最终的对象内存模型,如下所示:
- 对象的最终内存模型说明:
- 对象的内存中只包含普通成员变量,存储在栈区或堆区(堆区为用 new 创建对象)
- 静态成员变量独立地存储在全局数据区(.data)
- 成员函数与对象内存分离,存储在程序代码区(.code)
- 普通成员变量在内存中的排布顺序与其在类中的声明顺序相同
存在继承关系时,子类的对象内存模型可以看成是由父类成员和子类新成员合并而成:成员函数代码和静态成员变量都是独立在对象外属于类的成员,他们的合并是作用域上的合并,在逻辑关系上 “分享” 给了子类,允许子类访问,自身并没有改变在内存中的位置;只有对象各自的普通成员变量在内存中进行了实际上的合并,这正是我们要谈论的内容。
- 继承下的子类对象内存模型有一个重要规则:父类层次的成员变量总是被排在地址的前端。
我们先构建一个多层次的继承关系链用以示例说明:
在此关系链中,A 是 B 的直接父类,B 是 C 的直接父类,而 A 是 C 的间接父类,A、B、C 均各有两个 int 型的成员变量。我们假设 C 类对象 oC 的起始地址是 0x4000 ,于是得到如下所示的 C 类对象的内存模型:
- 可以看出,继承关系中的内存排布主要遵循两个规则:
- ① 高层次的类的成员总是在排布在前
- ② 同层次的成员变量按照声明顺序排列
[2.1] 我们通过一个示例来验证并说明这个内存模型:
================================ 定义示例 ================================
class cA {
protected:
int m_A1;
int m_A2;
public:
cA (int A1, int A2) : m_A1(A1), m_A2(A2) {
printf("-> 类 A 对象已构造 n");
}
};
class cB : public cA {
protected:
int m_B1;
int m_B2;
public:
cB (int A1, int A2, int B1, int B2) :
cA(A1,A2),
m_B1(B1),
m_B2(B2)
{
printf("-> 类 B 对象已构造 n");
}
};
class cC : public cB {
protected:
int m_C1;
int m_C2;
public:
cC (int A1, int A2, int B1, int B2, int C1, int C2) :
cB(A1,A2,B1,B2),
m_C1(C1),
m_C2(C2)
{
printf("-> 类 C 对象已构造 n");
}
void output (void) {
printf("n-> 各成员变量的地址: n");
printf(" Addr of m_A1 is : %#X n", (int)&m_A1);
printf(" Addr of m_A2 is : %#X n", (int)&m_A2);
printf(" Addr of m_B1 is : %#X n", (int)&m_B1);
printf(" Addr of m_B2 is : %#X n", (int)&m_B2);
printf(" Addr of m_C1 is : %#X n", (int)&m_C1);
printf(" Addr of m_C2 is : %#X n", (int)&m_C2);
int sAddr = (int)this; // 获取对象地址
printf("n-> 各成员变量的地址偏移: n");
printf(" Offset of m_A1 is : %2d Byte n", (int)&m_A1 - sAddr);
printf(" Offset of m_A2 is : %2d Byte n", (int)&m_A2 - sAddr);
printf(" Offset of m_B1 is : %2d Byte n", (int)&m_B1 - sAddr);
printf(" Offset of m_B2 is : %2d Byte n", (int)&m_B2 - sAddr);
printf(" Offset of m_C1 is : %2d Byte n", (int)&m_C1 - sAddr);
printf(" Offset of m_C2 is : %2d Byte n", (int)&m_C2 - sAddr);
}
};
================================ 程序示例 ================================
int main()
{
cC oC(10,20,30,40,50,60);
oC.output();
return 0;
}
================================ 运行结果 ================================
-> 类 A 对象已构造
-> 类 B 对象已构造
-> 类 C 对象已构造
-> 各成员变量的地址:
Addr of m_A1 is : 0x69FEB8
Addr of m_A2 is : 0x69FEBC
Addr of m_B1 is : 0x69FEC0
Addr of m_B2 is : 0x69FEC4
Addr of m_C1 is : 0x69FEC8
Addr of m_C2 is : 0x69FECC
-> 各成员变量的地址偏移:
Offset of m_A1 is : 0 Byte
Offset of m_A2 is : 4 Byte
Offset of m_B1 is : 8 Byte
Offset of m_B2 is : 12 Byte
Offset of m_C1 is : 16 Byte
Offset of m_C2 is : 20 Byte
可以看见,结果和内存模型完全吻合。
当父类层次和子类中的标识符重名时,遮蔽机制将使父类的同名命名被屏蔽从而优先使用子类的命名。当这个标识符代表一个普通成员变量时,虽然父类的成员变量因重名而被屏蔽,但它仍旧存在于子类的内存之中,只是无法使用而已。
我们构建一个存在遮蔽的继承关系链,用以说明存在遮蔽时的对象内存模型。这个关系链中有 A、B、C 三个类,存在着三个重名的成员变量 m_b、m_C、m_D 。
经过遮蔽之后,类 C 的成员变量所属关系如下图所示:m_C、m_D、m_E 为类 C 的成员变量,m_B 为类 B 的成员变量,m_A 为类 A 的成员变量。
在这存在遮蔽的继承关系网中的最终子类 C 的对象内存模型如下所示:(假设起始地址为 0x4000)
可见,父类成员变量被遮蔽并不会直接令他们消失,他们仍旧通过继承关系被合并到子类对象的内存之中,其排布方式完全遵从继承关系中父类成员变量的排布规则。因为变量并没有消失,我们可以通过 " :: " 指定作用域来访问这些被遮蔽的父类成员变量。
[3.1] 通过一个示例来说明并理解存在遮蔽时的对象内存模型:
================================ 定义示例 ================================
class cA {
public:
int m_A;
int m_B;
int m_C;
public:
cA (int A, int B, int C) : m_A(A), m_B(B), m_C(C) {}
};
class cB : public cA {
public:
int m_B;
int m_C;
int m_D;
public:
cB (int A, int B, int C, int D) : cA(A,B,C), m_D(D) {}
};
class cC : public cB {
public:
int m_C;
int m_D;
int m_E;
public:
cC (int A, int B, int C, int D, int E) : cB(A,B,C,D), m_E(E) {}
void output (void) {
printf("n-> 各成员变量的地址: n");
printf(" Addr of m_A is : %#X n", (int)&m_A);
printf(" Addr of m_B is : %#X n", (int)&m_B);
printf(" Addr of m_C is : %#X n", (int)&m_C);
printf(" Addr of m_D is : %#X n", (int)&m_D);
printf(" Addr of m_E is : %#X n", (int)&m_E);
printf("n-> 每个类的成员变量的地址: n");
printf(" Addr of cA::m_A is : %#X n", (int)&(cA::m_A));
printf(" Addr of cA::m_A is : %#X n", (int)&(cA::m_B));
printf(" Addr of cA::m_A is : %#X n", (int)&(cA::m_C));
printf(" Addr of cB::m_B is : %#X n", (int)&(cB::m_B));
printf(" Addr of cB::m_C is : %#X n", (int)&(cB::m_C));
printf(" Addr of cB::m_B is : %#X n", (int)&(cB::m_D));
printf(" Addr of cC::m_C is : %#X n", (int)&(cC::m_C));
printf(" Addr of cC::m_D is : %#X n", (int)&(cC::m_D));
printf(" Addr of cC::m_E is : %#X n", (int)&(cC::m_E));
int sAddr = (int)this; // 获取对象地址
printf("n-> 每个类的成员变量的地址偏移: n");
printf(" Offset of cA::m_A is : %2d Byte n", (int)&(cA::m_A) - sAddr);
printf(" Offset of cA::m_A is : %2d Byte n", (int)&(cA::m_B) - sAddr);
printf(" Offset of cA::m_A is : %2d Byte n", (int)&(cA::m_C) - sAddr);
printf(" Offset of cB::m_B is : %2d Byte n", (int)&(cB::m_B) - sAddr);
printf(" Offset of cB::m_C is : %2d Byte n", (int)&(cB::m_C) - sAddr);
printf(" Offset of cB::m_B is : %2d Byte n", (int)&(cB::m_D) - sAddr);
printf(" Offset of cC::m_C is : %2d Byte n", (int)&(cC::m_C) - sAddr);
printf(" Offset of cC::m_D is : %2d Byte n", (int)&(cC::m_D) - sAddr);
printf(" Offset of cC::m_E is : %2d Byte n", (int)&(cC::m_E) - sAddr);
}
};
================================ 程序示例 ================================
int main()
{
cC oC(10,20,30,40,50);
oC.output();
return 0;
}
================================ 运行结果 ================================
-> 各成员变量的地址:
Addr of m_A is : 0x69FEAC // 对应 cA::m_A
Addr of m_B is : 0x69FEB8 // 对应 cB::m_A
Addr of m_C is : 0x69FEC4
Addr of m_D is : 0x69FEC8
Addr of m_E is : 0x69FECC
-> 每个类的成员变量的地址:
Addr of cA::m_A is : 0x69FEAC
Addr of cA::m_A is : 0x69FEB0
Addr of cA::m_A is : 0x69FEB4
Addr of cB::m_B is : 0x69FEB8
Addr of cB::m_C is : 0x69FEBC
Addr of cB::m_B is : 0x69FEC0
Addr of cC::m_C is : 0x69FEC4
Addr of cC::m_D is : 0x69FEC8
Addr of cC::m_E is : 0x69FECC
-> 每个类的成员变量的地址偏移:
Offset of cA::m_A is : 0 Byte
Offset of cA::m_A is : 4 Byte
Offset of cA::m_A is : 8 Byte
Offset of cB::m_B is : 12 Byte
Offset of cB::m_C is : 16 Byte
Offset of cB::m_B is : 20 Byte
Offset of cC::m_C is : 24 Byte
Offset of cC::m_D is : 28 Byte
Offset of cC::m_E is : 32 Byte
多继承仍旧遵循「父类成员变量排布在前」这一规则,其相比单继承不同的是 “同一层次中存在多个父类”,所以多继承中子类对象的内存模型相比单继承主要是增加了 “同一层次父类对象的成员变量排布次序” 这一内容。
- 多继承中,同一层次父类的成员变量在子类的内存中的排列次序与其继承关系的声明顺序相关。
我们构筑一个如下所示的继承关系网,用以说明。
假设每个类各自拥有一个成员变量 m_X(X为类名),继承关系声明顺序为先 B 后 C,D 的对象的起始地址为 0x4000,则子类 D 的对象内存模型如下所示:
类 B、C 处于同一层次,由于 B 的继承声明顺序在前,B 被排在了 C 的前面。又因 A 是 B 的父类,所以 A 被排在 B 的对象内存中的前面位置,而 B 排在了后面。D 作为最终子类,在最底层次,于是被排在了最末端,最终得到如图所示的内存模型。
虚继承关系下的对象内存模型只有一点不同:虚基类的成员总是放在对象内存的末尾。
普通情况下,由于总是按类的层次由高到低来进行排布,所以每个层次的类相对于对象起始地址的偏移是固定的,即使随着继承层次的增加使得类的成员增加,新增成员也只会排在后面,不会影响已经排布好的前面的成员。虚继承就不一样了,因为虚基类总是排布在整个内存的末尾,一旦增加继承层次,虚基类成员相对于对象初始地址的偏移就会改变。
我们将上文单继承中的继承关系做一点改动,将 A 和 B 声明为虚继承,得到以下继承关系链:
此继承关系链中类 C 的对象内存模型如下图所示:
可以发现,类内仍旧遵从成员变量的声明顺序进行排布,而类的排布位置相反了:A 作为 B 的虚基类,被排布在了最末端,而 B 作为 C 的虚基类,被排布在了 C 的后面,顺序与非虚基类正好相反。
浅色部分是非虚类,称为子类对象的实部,不会因继承层次的增加而改变偏移距离;
深色部分是虚基类,称为子类对象的虚部,会随着继承层次的增加而改变偏移距离。
访问对象的成员变量需要知道两个信息:对象的首地址 和 变量的偏移地址。对象的首地址容易得到,变量的偏移地址对于偏移固定的非虚类也很容易,而对于虚基类来说,由于其成员变量在对象内存中的偏移容易随着继承层次的改变而改变,偏移地址的获取正是其难点,也是关键点。
由于 C++ 标准仅对 C++ 的实现做了框架性的概述而没有规定具体的实现细节,所以不同厂商的编译器在具体实现方案上会有所差异。对于虚继承,几乎所有的编译器都在 “将子类分为实部和虚部,并把虚部放在最后” 这一点上达成了共识,主要的分歧在于如何计算浮动部分的偏移量。
-
CFront 解决方案
早期的 cfront 编译器会在派生类对象中添加一些指针,每个指针指向一个虚基类的子对象,通过这些指针来访问浮动变化的虚基类成员变量。这个方案的缺点有两点:一者是随着虚继承层次的增加,需要通过指针的索引次数随之增加,访问高层次虚基类就需要花费大量时间在指针寻址过程上,造成效率低下;二者是,随着虚继承关系链的增加,子类需要添加的指针数量也随之增加,从而造成对象的体积膨胀。
-
VC 解决方案
VC 在 CFront 的基础上进行了改进,使用一个指针指向一个线性的虚继承表取代了 CFront 的层级跃进指针链结构,从而使得对任何层次的虚基类的寻址都只需要一次指针寻址,解决了 CFront 方案的时间开销问题;此外,由于对象只需要添加一个指向虚继承表的指针,也避免了对象体积膨胀的问题。虚继承表中记录了所有虚基类相对于当前对象起始地址的偏移量,只需要通过对象中的指针到此表中获得目标虚基类类的偏移量即可找到目标变量。
-
对象的内存模型主包含类的普通成员变量。
静态成员变量不属于对象而属于类,独立在对象外的内存空间储存;成员函数为发挥函数的复用特性,独立存储在内存中的代码区。 -
类内部的成员变量按照各自在类中的声明顺序在对应的子对象中进行排布。
-
继承关系下,不同继承层次的类按照层次高低排布(父类在前),同一层次的类按照声明顺序排布(先声明则在前)。
-
虚继承关系下,虚基类的排列方式恰好与普通类相反,最高层次虚基类排列在最后,从末端按照虚基类层次高低从后向前排布。
| —— DaveoCKII |



