对C++内存布局有过一定了解,但是一直都很暧昧。为了搞清楚,决定探究一下C++内存布局。
本文在VC2008下实验,有兴趣的同学可以在G++实验下
经过仔细研究。现在总结出来3条规则:
规则1.一个没有继承的C++对象的布局总是(虚表指针 + 成员变量),虚表指针至少为4字节,如果你的成员有double的话,由于内存对齐的原因,虚表指针为8字节。如果没有虚函数,那么虚表指针可以省去。
规则2.如果一个类Derived从ClassB1, ClassB2, ....ClassBN类多继承(非虚继承),那么该类对象的内存结构生成过程(这里是指猜测的编译时的生成过程而非运行时的过程)是:
a. 把ClassB1到ClassBN的内存布局全部按代码顺序依次搬过来拼接起。
b. 把Derived的虚表揉合到第一张表虚表(ClassB1的虚表,若ClassB1无虚表则依次找后面有虚表的作为第一虚表)后。揉合的方法是,把Derived新增的虚方法(不是重写覆盖基类的那些方法)加到第一张表虚表末尾。
c. 遍历所有的虚表,替换基类中被Derived重写覆盖的的方法为基类函数指针。
d. 把基类的成员变量加了整个内存布局的最后。
看一个实例
class B1
{
public:
virtual void fun1(){};
virtual void fun2(){};
int m_b1;
};
class B2
{
public:
virtual void method1(){};
virtual void method2(){};
int m_b2;
};
class Derived : public B1, public B2
{
public:
virtual void fun1(){};
virtual void method2(){};
virtual void df(){};
int m_b3;
};
Derived的内存布局构造顺序出下(编译期计算推测)
第a步
第b步
第c步
第d步
经过四步操作,Derived内存布局已经完成。这里有个疑问没有解决:为什么Derived的虚表要揉合到第一个基类的虚表中去?
解决这个疑惑,我们先来看看如果调用
Derived* pdd = new Derived;
pdd->fun1();
pdd->df();
pdd->method2();
生成的代码执行流程是如何的。
调用 pdd->fun1();
步骤:取this指针,取第一张虚表头,取第一个函数地址,执行。
调用 pdd->df();
步骤:取this指针,取第一张虚表头,取第三个函数地址,执行。
调用 pdd->method2();
步骤:取this指针,偏移到第二个基类对象,取第二张虚表头,取第二个函数地址,执行。
可以看到在调用派生自第二个基类的方法时,this指针多做了一次偏移计算。
因此可以解释上面的疑惑:因为C++用得最多的是单继承,为了在单继承的时候调用基类和子类虚方法不至于偏移指针,就把子类的虚表和第一个基类虚表进行融合。
根据上述的生成规则。我们可以推导当产生菱形继承时的布局
class A;
class B : publicA; // B的内存布局包括了一份A
class C : publicA; // C的内存布局包括了一份A
class D : publicB, public C // D的内存布局包括了一份B和一份C,因此D间接包含了两份A
书上说virtual public能解决这个问题。那么现在总结一下 virtual public的生成规则
规则3.如果一个类Derived从ClassB1, ClassB2, ....ClassBN类多继承(既有虚的,又有非虚继承),那么该类对象的内存结构生成过程(这里是指猜测的编译时的生成过程而非运行时的过程)是:
等下,在介绍这个之前,我先介绍下一个类的完整的内存布局应该由四部分组成。
我们看到的规则2生成的布局只是“非虚继承部分”和“Derive成员部分”,当引入虚继承后,每个类的内存布局应该有这四部分。下面再说虚继承的生成过程
a.将所有基类整理顺序,非虚继承基类在前,虚继承基类在后。
b.先不考虑虚继承类,将非虚继承基类和Derived类按照规则2进行内存布局。但处理过程不包含Derived类覆盖的虚继承类的方法(这一点VC和GCC的处理可能不一样,没有亲自实验GCC),也不包括非虚继承基类的“虚继承部分”,也就是说如果非虚继承基类还虚继承于另一个类,那么非虚继承基类本内的布局就已经包括了上述三部分,但在参与规则2的计算时,它的“虚继承部分”暂时不被考虑。
c.在目前已经生成的内存布局“非虚继承部分”和“Derive成员部分”之间插入虚基类指针列表vbptr(virtual base table pointer)
d.将所有的虚继承的基类内存布局依次放到步骤c生成的内存布局之后(包括步骤b暂时忽略的“虚继承部分”和本来的虚继承类,在放入过程中,如果发现有相同的类名,那么就只放一份)
e.遍历“虚继承部分”中的虚表,把虚表替中被覆盖的方法换成Derived方法。
f.根据“虚继承部分”中的虚继承基类的偏移,填充虚基类指针列表vbptr
说了这么多,看个例子吧
class B1
{
public:
virtual void fun1(){cout << "fun1";}
virtual void fun2(){cout << "fun2";}
int m_b1;
};
class B2
{
public:
virtual void method1(){cout << "method1";}
virtual void method2(){cout << "method2";}
int m_b2;
};
class Derived : virtual public B1, public B2
{
public:
virtual void fun1(){cout << "df::fun";}
virtual void method2(){cout << "df::method2";}
int m_b3;
};
按照步骤画出构造图
第a步,调整顺序为
class Derived : public B2,virtual public B1
第b步
第c步
第d步
第e步
第f步
根据这个步骤,我们可以解释在菱形继承时,如果使用了虚继承,那么在步骤d里面会根据同名类型只有一份的原则,把多余的干掉。
但是这样一来,我们调用来自于虚继承的接口就就颇费周折,如:
Derived*pdd = new Derived;
pdd->fun1();
调用过程是:取 this指针,计算vbptr的偏移,取vbptr第二项为+8,把+8加在vbptr地址上得到B1vptr,取fun1地址,调用。
很复杂。或许你会问,在编译ppd->fun1()的时候,其实编译器知道B1 vptr的偏移,为什么不直接生成找B1 vptr的代码?
原因如下,在目前的Derived中,B1 vptr的位置是固定的。但是如果把Derived作为某类的父类,再虚继承下,再乱继承几下。根据规则3的第d步,在合并之后,你就不知道B1vptr在哪个地方了。而编译器面对Derived指针,必须生成统一的代码,这就是必须查vbptr表的原因。以上是我自己实验的结论,如果有不妥的地方,请各位高手不吝指正。我的联系方式 chengyongxin1983@qq.com,转载请注明出处,码字费神。谢谢!