这篇文章主要讨论C++继承中成员变量的内存排布情况,主要包括单继承,多继承,带虚函数的继承,带虚基类的继承
单继承无多态
C++语言保证在派生类中的基类子对象有其完整原样性。
比如下面这个 Concrete
类,其内存排布可以分析为(32位下) 4B+3x1B=7B + 1B(对齐)= 8B
class Concrete {
private:
int val;
char c1;
char c2;
char c3;
};
如果把上面的 Concrete
分为三层结构
class Concrete1 {
private:
int val;
char bit1;
};
class Concrete2 : virtual public Concrete1 {
private:
char bit2;
};
class Concrete3 : public Concrete2 {
private:
char bit3;
};
此时为了满足C++语言保证在派生类中的基类子对象的原样性,Concrete3
对象占的内存大小应该为 16 字节
我们可以想想为什么C++要保证派生类中基类子对象的原样性,假如我们有下面一组指针:
Concrete2 *pc2;
Concrete1 *pc1_1, *pc1_2;
其中 pc1_1
和 pc1_2
两者都可以指向前述三种类对象,下面这个指定操作:
*pc1_2 = *pc1_1;
应该执行一个默认的逐对象拷贝操作,复制的对象是 Concrete1
那一部分,如果 pc1_1
实际指向一个 Concrete2
或 Concrete3
对象,则上述操作要复制的对象应该是其中 Concrete1
子对象。
此时,如果不保证派生类中基类子对象的原样性,也就是 Concrete2::bit2
或 Concrete3::bit3
和 Concrete1
子对象捆绑在一起,去除填补空间,其内存布局就和一开始 Concrete
类的内存布局一致。
那么下面的复制操作就会产生问题:
pc1_1 = pc2; // pc1_1 指向 Concrete2对象
*pc1_2 = *pc1_1;
因为会把 pc1_1
的 bit2
属性也拷贝过去,因为此时它处于 Concrete1
的padding中,如果 pc1_2
也指向一个 Concrete2
对象,那么其 bit2
的值就会发生我们不期望的改变(我们只是想拷贝Concrete1
子对象)
加上多态
一般多态是通过一个对象的虚函数表指针实现,该虚函数表指针指向该对象的虚函数表(其位于只读数据段.rodata),虚函数表中存放的是每个虚函数指针,下面这张图很直观
一般来说,编译器会把虚函数表指针放在对象的内存起始位置,比如我们在上面的 Concrete2
加入一个虚函数
class Concrete2 : public Concrete1 {
public:
virtual void f() { cout << "调用f()" << endl; }
private:
char bit2;
};
其对象内存布局如下,可以看到确实是在起始位置加入了虚函数表指针
我们知道 Concrete1
里是没有虚函数的,所以其内存布局中不应该有指向虚函数表的指针,那么如果有一个 Concrete1
类型的指针指向一个Concrete2
类型的对象,其指向的地址应该是 Concrete2
对象虚函数表指针之后的位置,由编译器在编译时处理。
Concrete1* c1 = new Concrete2();
可以看到,调用 Concrete2
的默认构造函数后,其起始地址为 0x009b3ca8
而最后赋给 c1 的值为 0x009b3cac 正好为 0x009b3ca8 + 4
多重继承
多重继承需要处理的情况就是,一个派生类,其有三个父类,且有两个父类都有虚函数时,派生类的内存布局是怎么样的。
实际会根据继承的顺序会有不同的内存布局。
有三个父类
class X {
public:
virtual void f() {cout << "X::f()" << endl;}
int m_x;
char m_cx;
};
class Y {
public:
virtual void f() {cout << "Y::f()" << endl;}
int m_y;
char m_cy;
};
class Z {
public:
int m_z;
char m_cz;
};
如果继承顺序如下
class Q : public Z, public Y, public X {
public:
virtual void f() { cout << "Z::f()" << endl; }
int m_q;
char m_cq;
};
因为起始继承的 Z
不包括任何的虚函数,所以会加上一个 Q
的虚函数表指针
其实编译的时候会有优化,会将继承的含有虚函数的基类的子对象放在前面,后面再放不含虚函数的基类的子对象,这样就可以省去一个虚函数表指针的大小,其内存布局像下面这样
要是继承的顺序像下面一样
class Q : pulic Z, public X, public Y{
public:
virtual void f1() { cout << "Z::f()" << endl; }
int m_q{ 2 };
char m_cq{ 1 };
};
那么内存布局如下
上面 Q
的 f()
同时重写了 X
和 Y
的 f()
,比如我们有如下代码:
Y* y = new Q();
X* x = new Q();
y->f();
x->f();
从汇编码中可以看出,y
和x
分别调用各自 Y
和 X
虚函数表指针对应的虚函数表下的虚函数 f()
看 y
的调用跳转
继续跳转
最后跳转到对应虚函数
而 x
的调用跳转为,然后直接跳到了虚函数 f()
,这是因为它们实际上是同一个虚函数,所以 y
这里调用做了个间接跳转操作
虚拟继承
对于虚拟继承,其考虑的场景是在菱形继承下,基类子对象只会在内存中存在一份
考虑下面这个继承关系,Concrete1
会在 Concrete4
存在两份基类子对象
class Concrete1 {
public:
int val;
char bit1;
};
class Concrete2 : public Concrete1 {
private:
char bit2;
};
class Concrete3 : public Concrete1 {
private:
char bit3;
};
class Concrete4 : public Concrete2, public Concrete3{
public:
char bit4;
};
其内存布局如下
且如果要访问 Concrete1
中的成员,会出现访问成员不明确的问题
为了解决这个问题,C++中可以使用虚基类,使得基类子对象在内存中只存在一份,上面的例子可以改为
class Concrete1 {
public:
int val;
char bit1;
};
class Concrete2 : virtual public Concrete1 {
private:
char bit2;
};
class Concrete3 : virtual public Concrete1 {
private:
char bit3;
};
class Concrete4 : public Concrete2, public Concrete3{
public:
char bit4;
};
可以看到虚基类被放在了末尾
那么在编译时无法确定对象的具体类是,如何在运行时找到虚基类成员位置
我们运行如下代码
Concrete2* c2 = new Concrete4();
Concrete3* c3 = new Concrete4();
c2->val = 10;
c3->val = 20;
在 Concrete4
的默认构造函数中,通过将一个指向虚基表的指针初始化,可以在需要找到虚基类成员时,利用指向虚基表的指针,就可以得到虚基类子对象关于当前虚基表指针的偏移,间接得到虚基类子对象的位置。
如果此时还有虚函数,比如 Concrete1
中有一个虚函数 f()
那么,Concrete4
中内存布局如下,其实虚基表指针应该存放于变量之前
如果 c3->f()
调用虚函数,编译器如何找到虚函数表指针的位置,其实也和取变量一致,先找到虚基类子对象的起始位置(通过虚基表中的偏移),然后这个位置其实就是虚函数表指针的位置。
[c3] 是 c3 的起始位置,就是对应 Concrete3
中虚基表指针的位置,然后取出虚基表,找到虚基类子对象对应的偏差,然后加到当前 c3 地址上,得到虚函数表指针的地址,然后根据虚函数表,找到对应虚函数地址,然后调用。
参考资料
《深度探索C++对象模型》—— Stanley B.Lippman著,侯捷译