深入探究C++虚拟继承

C++的继承作为它的一个特性,必须要做到深入了解,对于C++继承方式,我们之前讲过,有单继承,多继承,菱形继承,虚拟继承,菱形虚拟继承。

这次我们来深入探究一下虚拟继承和菱形虚拟继承。不过在此之前,先让我们简单的了解一下他们的作用。

在不考虑含有虚拟继承的情况下,C++继承模型中大多数情况下是菱形继承,虽然菱形继承是一个很有用的继承方式,但是它存在一个问题,就是访问二义性的问题,而这时我们引入了虚拟继承,就可以完美解决菱形继承二义性的问题。这就是虚拟继承的作用。


菱形继承的二义性问题

菱形继承的对象模型:
这里写图片描述

由上图可知,存在一个基类B,C1类公有继承基类B,C2类公有继承基类B,派生类共有继承C1,C2;对于C1和C2就是普通的单继承方式,而D以多继承方式继承C1,C2。接下来我们一步一步分析D的对象模型—内存地址分配:

C1单继承B,根据单继承的继承规则:所有继承下来的基类成员变量存放在派生类添加的成员变量之前,即基类的成员变量的内存地址低于派生类的内存地址,可以看做是将基类的内存空间进行了一次拷贝,并且在拷贝的内存空间后面加上派生类自己的成员。因此C1的对象模型为:(内存地址向下增大,C2的对象模型与C1的对象模型相同。)
这里写图片描述

派生类D多继承C1和C2,根据多继承的继承继承规则:以单继承的方式按照父类声明的顺序继承每个父类,可以看做是按照声明的顺序将每个父类的内存空间拷贝到一起,并在后面添加上派生类自己的成员。因此派生类D的对象模型为:
这里写图片描述
在D的内存空间中,由于申明顺序class D :public C1, public C2,因此D先继承父类C1,在继承C2,所以C1的成员_b_c1在C2成员_b_c2的前面,最后就是派生类D自己的成员_d

菱形继承的对象模型分析完成之后,来讨论二义性的问题,我们来写一个测试函数。

int main()
{
    D d;        
    d._c1 = 1;
    d._b = 2;
    d._c2 = 3;
    d._b = 4;
    d._d = 5;

    return 0;
}

这个测试函数,先定义一个派生类对象d,然后对d的每个成员赋值,编译一下:
这里写图片描述
会发现编译通不过,并且给出了错误原因 d._b = 2;d._b = 4;,即对D::_b的访问不明确。这是当然的,以为在派生类对象d中存在两个int _b成员(分别从C1,C2继承下来的),因此在对d._b进行赋值时,编译器不知道访问从C1继承下来的,还是从C2继承下来的,因此对_b访问不明确,这就是菱形继承存在的二义性。

在这里如果将每个成员b的作用域加上就可以对正常赋值了,即:

int main()
{
    D d;

    d.C1::_b = 1;
    d._c1 = 2;
    d.C2::_b = 3;
    d._c2 = 4;
    d._d = 5;

    return 0;
}

运行并调试一下:
这里写图片描述

这时菱形继承的二义性问题就可以解决,并且查看一下派生类对象d的内存会发现,此时在我们的内存空间中,各个成员的地理位置和我们分析的相同,这便是我们菱形继承的对象模型。

虽然菱形继承的二义性问题可以通过添加作用域的方式进行解决,但是每次都要加作用域,十分不方便,这里我们便引入了虚拟继承(virtual),含有虚拟继承的菱形虚拟继承方式很好的解决了菱形继承的不足。

在此之前,我们先看一下虚拟继承方式的特点。


虚拟继承

虚拟继承在继承权限前加上virtual关键字即可构成虚拟继承,如:

class D :public B{  //普通继承
    ...
};
class D :virtual public B{  //虚拟继承
    ...
};

我们可以深入了解一下虚拟继承的对象模型;如现有一个基类B,含有一个公有类型的整形成员_b,派生类D公有继承基类B,且派生类D含有自己的一个整形数据成员_d,我们写一个测试函数来研究一下派生类D的对象模型:

//虚拟继承
class B {
public:
    int _b;
};

class D :virtual public B
{
public:
    int _d;
};

int main()
{
    B b;
    D d;
    cout << sizeof(b) << endl;
    cout << sizeof(D) << endl;
    d._d = 1;
    d._b = 2;
    return 0;
}

通过编译器单步调试,可以先看一下基类B和派生类D的空间大小,在查看结果之前,我们可以先用自己目前所了解的知识分析一下他们的空间大小是多少,派生类D单继承基类B,基类B只有一个int类型的成员_b,所以基类B的空间大小应该是4字节,根据单继承的特点派生类D先拷贝基类B的内存空间,在加上自己的内存空间,即派生类D空间大小应该是基类的空间大小4字节加上B的int_d数据成员的大小4字节,共8字节,因此我们得到的结果是:基类是4字节,派生类是8字节。
好了,让我们看一下运行结果:这里写图片描述,这个结果貌似跟我们预想的并不一样,对于基类B是4个字节毫无疑问,可是派生类D为什么多4个字节,且多的4字节存放的是什么呢?

我们可以通过查看汇编代码了解一下编译器在后面到做了什么:
这里写图片描述
我们可以看到在定义基类对象时并没有汇编代码,说明这里仅仅是进行了声明,并没有进行什么具体的步骤,而在定义派生类对象B时,会发现多了三条汇编代码:第一步将1压栈,第二步取派生类对象_d的地址放到ECX寄存器中,第三步调用了派生类D的构造函数D::D()。可是在这里我们并没有给出派生类D的构造函数,那么为什么编译器会调用呢?

这里就涉及到编译器会自动合成构造函数的第三种情况:虚拟继承时派生类会自动合成构造函数。我们再来总结一下编译器会自动合成构造函数的三种情况:

  • 当一个类包含另外一个类的对象,且该对象有自己的缺省构造函数。(含子类情况)

  • 当一个类继承另外一个类,且基类有缺省的构造函数。(派生类情况)

  • 当一个类虚拟继承另外一个类,无论基类是否有缺省的构造函数,编译器都会自动合成派生类的构造函数。(虚拟继承情况)

回到我们的主题,那么这多出来的这三步,到底做了什么,我们接着运行,来看一下系统生成的派生类构造函数(D::D())有哪些操作:
这里写图片描述

① 号汇编语句mov dword ptr [this],ecx,表示将寄存器ecx的值赋值给this指针(直接寻址),此时这里的ecx装的就是派生类对象_d的地址;即之前的三句汇编代码中第二句汇编代码所做的操作,将_d的地址放到ecx寄存器中。因此,使得this指针指向当前对象_d

② 号汇编语句cmp dword ptr [ebp+8],0,通过寄存器间接寻址,将ebp堆栈基指针向下偏移8个字节,取其空间双字字节大小(dword ptr[])的内容,于0进行比较,ebp地址如下:
这里写图片描述
其向下偏移8字节后,所取到的内容为1,即有之前的那三句汇编语句中的第一句,将1压栈得到。

③ 号汇编语句je D::D+32h (0FA17F2h),表示上面的比较成立,即进行本次步骤

④ 号汇编语句mov eax,dword ptr [this],通过直接寻址将this地址空间内容前4个字节放到eax寄存器当中,即将对象_d的地址放到eax中。

⑤ 号汇编语句mov dword ptr [eax],offset D::`vbtable' (0FA7B30h),将地址0x00FA7B30放到eax寄存器间接寻址(地址偏移eax内容的空间)后地址空间的前4个字节中,因为eax存放的是_d的地址,即将0x0FA7B30放到对象空间的前4个字节当中。
这里写图片描述0x00FA7B30所指向的空间内容。

这里写图片描述此时this指针所指向空间的内容(即对象_d空间内容)的前4个字节为0x00FA7B30

⑥ 号汇编语句mov eax,dword ptr [this] ,最后通过直接寻址将this空间的内容,放到eax寄存器当中。

此时,派生类的构造函数D::D()的基本汇编代码已经讲解完毕,同时将上面三条汇编语句的作用也详细的讲解了。整个过程大概来讲,就是编译器给派生类自动生成一个构造函数,并且在生成派生类对象,调用构造函数的同时,将1作为参数传给构造函数,在构造函数内部,将一个指针放在了对象内存空间的前4个字节。因此派生类多出来的4个字节就是该指针。

至于为什么多出来4个字节,我们已经有所了解,即多出来的4个直接又来存放指针,现在我们再来深入探究一下这个指针,所指空间的内容。

在上面可以看到指针0x00FA7B30所指向空间前4个字节的内容为0,后4个字节的内容为8,对于他们的作用,接着来运行的我们的代码进行测试,通过汇编来查看编译器底层的运行过程来看看赋值情况是怎样的:
这里写图片描述
对于派生类成员_d通过直接取值的方式进行赋值,而然对于从基类继承下来的_b的赋值,通过直接寻址先将对象d的前4个字节,存放到寄存器eax中,即取到那个多出来的未知指针,然后通过寄存器间接取值得到eax偏移4字节后所指空间的值,即得到未知指针向后偏移4字节所指的内容8,然后将8存放到exc寄存器中,第三步将要赋的值2赋值给d偏移ecx内容后所指向的地址空间。在此完成d._b = 2;的赋值。可以看出该指针像是一个存放偏移量的指针。

来查看一下此时派生类对象的对象模型(由于多次重新运行,导致对象的地址发生改变,但这并不会影响我们的结果):
这里写图片描述
可以看到对象当中首先是一个指针,通过上面我们可以得到,这是一个偏移量指针,第一个4字节内容存放的是派生类对象的地址偏移量,第二个4字节内容存放的是派生类当中基类对象的偏移量;因此该对象的第二个地址内容装的派生类自己的数据成员_d,而第三个地址存放的是从基类继承下来的数据成员_b

最后,我们可以得到虚拟继承的对象模型:
这里写图片描述

对于虚拟继承的对象模型我们已经讨论清楚了,这里要注意的是虚拟继承看似的单继承,但是与单继承完全不一样,因为虚拟继承的特点,派生类多了4字节的偏移量指针,并且与单继承不同的点是,在单继承中从基类继承下来的数据成员存放在低地址,而在虚拟继承中从基类继承下来的数据成员存放在到地址处。

虚拟菱形继承

虚拟继承介绍完了,接下来我们探究一下用虚拟继承是怎么解决菱形继承中二义性的问题的,二义性的问题是因为在派生类中存在两个相同名称的数据成员,如果直接访问(不加作用域)的话,编译器不知道操作那个变量,而访问不明确报错。

对于这个问题的解决方法,因为两个相同变量所代表的意思和名称完全一样,存在两份浪费空间,那么我们只要存一份,不仅可解决二义性问题,且节省空间。

虚拟继承在此的解决方法就是如此,由于虚拟继承的特点,使得中间两个类在继承最上面一个类的时候通过偏移量指针来寻找从基继承下来的对象模型,因而使得最下面的类在多继承中间两个类时,其派生类中从最上面的类继承下来的对象模型在该内存当中只存在一份,并且通过偏移量指针可以找到。

接下类我们通过测试代码来研究一下派生类的对象模型是什么样子的:

//菱形虚拟继承
class B {
public:
    int _b;
};

class C1 :virtual public B
{
public:
    int _c1;
};

class C2 :virtual public B
{
public:
    int _c2;
};

class D :public C1,public C2
{
public:
    int _d;
};

int main()
{
    D d;
    cout << sizeof(B) << " " << sizeof(C1) << " " \
        << sizeof(C2) << " " << sizeof(D) << " " << endl;

    d._c1 = 1;
    d._c2 = 2;
    d._d = 3;
    d._b = 4;

    return 0;
}

存在一个基类B,两个派生类C1,C2虚拟继承基类B,派生类D多继承基类C1和C2,通过单步运行程序,我们看一下4个类所占空间的大小,不过,在看之前我们可以先分析一下,基类B是4字节这没有什么问题,对于派生类C1,C2虚拟继承基类B,如同上面所述样,C1,C2比普通的单继承多了4字节来存放偏移量指针,因此C1,C2应该是12字节,派生类D多继承C1,C2,按照多继承的规则,D的空间的大小,因该是C1的字节数加上C2的字节数再加上自己的数据成员的字节数,即12+12+4为28字节,可以这里因为C1和C2是虚拟继承,会导致在派生类当中只存在一份基类B的数据成员,所以D所占的空间大小应该是24个字节,好了我们来看一下结果:这里写图片描述,我们预想的一样。

之后我们在来看一下派生类的具体对象模型,接着运行程序:
这里写图片描述
结果和我们分析的一样,之后我们再看看两个偏移量指针所指空间存放的内容是什么:
这里写图片描述这里写图片描述

第一个偏移量所指空间存放的是0和20,第二个偏移量所指空间存放的是0和12,至于这两个数的意思就是C1距第一个指针的偏移量为0,从基类B继承下来的对象_b距该指针的偏移量为20,C2距第二个指针的偏移量为0,从基类B继承下来的对象_b距该指针的偏移量为12。
即:
这里写图片描述
菱形虚拟继承的对象模型,如下:
这里写图片描述


总结

最后,虚拟继承和菱形继承二义性的问题介绍完了,这次深入探究虚拟继承,总体是通过对对象模型的分析,和编写测试代码,并通过单步运行进行验证,查看反汇编了解编译器底层的操作,查看内存深入认识对象模型的存储方式,和整个问题的解决思路。

虚拟继承是C++的一个特性,有了他的存在,我们更加高效的完成我的工作,节省空间,去除二义性问题。然而virtual关键字不仅仅能够用在类的继承方面,更多的是出现在函数的声明处,构成虚函数,这也是我们接下来要学习的C++的另外一个重要特性——多态!

猜你喜欢

转载自blog.csdn.net/xiaozuo666/article/details/80373316