C++ 虚继承
Posted 嘻嘻兮
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了C++ 虚继承相关的知识,希望对你有一定的参考价值。
对于虚继承而言,是用于解决其在多重继承时出现的二义性问题,所以首先我们先来看看多重继承时会出现的问题
//家具类
class CFurniture
public:
CFurniture()
m_nPrice = 0;
virtual ~CFurniture()
printf("virtual ~CFurniture()\\r\\n");
virtual int GetPrice()
return m_nPrice;
protected:
int m_nPrice;
;
//沙发类
class CSofa : public CFurniture
public:
CSofa()
m_nPrice = 1;
m_nColor = 2;
virtual ~CSofa()
printf("virtual ~CSofa()\\r\\n");
virtual int GetColor()
return m_nColor;
protected:
int m_nColor;
;
//床类
class CBed : public CFurniture
public:
CBed()
m_nPrice = 3;
m_nLength = 4;
m_nWidth = 5;
virtual ~CBed()
printf("virtual ~CBed()\\r\\n");
virtual int GetArea()
return m_nLength * m_nWidth;
protected:
int m_nLength;
int m_nWidth;
;
//沙发床类
class CSofaBed : public CSofa, public CBed
public:
CSofaBed()
m_nHeight = 6;
m_nPrice = 7; //这里编译错误
virtual ~CSofaBed()
printf("virtual ~CSofaBed()\\r\\n");
virtual int GetHeight()
return m_nHeight;
protected:
int m_nHeight;
;
上例总共有4个类,是一个菱形继承的模型,为什么是菱形呢,看完下图你就明白了
上面的对象模型是不是特别像一个菱形的结构,但是对于上面的例子来说,是编译错误的,错误信息如下
"CSofaBed::m_nPrice" 不明确 Test1 xxx.cpp 79
在上面的例子中是第72行的位置,也就是说在对m_nPrice赋值时编译器可能找到了2个m_nPrice,所以无法确定是哪一个,具体我们来画一下其内存结构
从上图可以看出来,对于m_nPrice的赋值其在内存中存在两份,所以在编译时就会出现二义性的问题,有三种解决的方案
首先第一种解决方案就是不要用,也就是说在CSofaBed中不要去操作CFurniture,这个解决方案有点废,大概意思就是哪里有编译错误哪里就注释掉...
所以看第二种,那就是明确其作用域,如下
CBed::m_nPrice = 7;
意思就是我需要对CBed中的m_nPrice赋值,也就是在上面的内存图中this+10的位置赋值。这样子看似解决了问题,但是这个内存数据十分的冗余,因为单纯对于沙发床这个类而言,这个m_nPrice只需存在一份即可,没有必要存在两份。
那么第三种方案就是虚继承了,虚继承可以保证其CFurniture这个基类在CSofaBed的类中只存在一份,所以避免了二义性问题。
class CSofa : virtual public CFurniture //虚继承
//...与最初例子中间代码相同
class CBed : virtual public CFurniture //虚继承
//...与最初例子中间代码相同
这种解决方案就是在沙发类和床类的继承加入virtual关键字,这样子就是虚继承了。但是为什么是在沙发类和床类中加入虚继承呢,为什么不是在家具或者沙发床中加入呢?这里其实算是一个语法的问题,但是与我们现实生活中近亲繁殖的问题很像,由于沙发类和床类都是来自于家具类,所以沙发类和床类算是近亲,而沙发床则是近亲繁殖的产物,那么如果我们需要控制其沙发床这个对象是正确的,自然需要在双亲身上下工夫,所以virtual加在沙发类和床类上。
这个解决方案看似很OK的,但是其实在软件设计的角度考虑,这种多重继承带来的问题,哪怕是修饰了虚继承,也是治标不治本的,因为使用了虚继承避免了多义性,但把对象设计的复杂度和效率都带来了指数增长的隐患,所以这种菱形设计,在C++的设计中要尽量的避免。
所以在Java的一些面向对象语言中,直接就把多重继承给废了(没有语法支持),因为在面向对象的领域中,多父类其实可以换个语意来表达
沙发床继承于沙发和床
沙发床包含沙发和床
成员对象
看上面的描述中,其两者本质是相同的,所以有时候我们可使用组合或者聚合的办法进行替代(组合就是嵌入对象,聚合就是嵌入对象指针)。
虽然说这个设计需要避免的,但是这种结构还是很值得我们研究的,因为单纯说来菱形继承算是最复杂对象结构了,那么如果了解这这种对象结构,平常的单重继承什么的也都理解的会比较透彻了。
OK,上面有说虚继承会给对象设计和效率都会有影响,对于对象设计而言,简单说就是不好维护,比较乱。对于效率,就是接下来要讲的,因为虚继承会额外多出一张表,这张表也称为虚基类偏移表,对于其成员函数的赋值首先需要查表定位虚基类的位置(找到虚基类的this指针位置),所以其效率也就下来了。
简单起见,我们先将上面沙发床的例子放一放,先来通过一个简单的例子了解一下什么是虚基类偏移表。
class A
public:
A()
m_nA = 1;
virtual int GetNumA()
return m_nA;
protected:
int m_nA;
;
class B : virtual public A
public:
B()
m_nB = 2;
virtual int GetNumB()
return m_nB;
protected:
int m_nB;
;
int main(int argc)
B objB;
A* pA = &objB;
return 0;
运行跑起来我们来看一下其B对象的内存结构
可以发现,其虚基类偏移表在该对象的第二项(第一项被虚表占用),注意当该类没有虚函数时,也就是没有虚表存在时,虚基类偏移表就会在该类对象的第一项哦。对于虚表不清楚的可以看一下C++上一篇虚表,所以对于虚表就不分析了,重点放在虚基类偏移表上。
前面有说虚基类偏移表主要用于定位虚基类的位置,从图中也可看出来是这样,不过为什么选择的是虚基类偏移表中的第二项呢,第一项是干什么用呢?这个问题其实不太好回答,因为第一项是没有用,或者说固定有第一项,目前猜测是到自己类的偏移,也就是this+4-4 = this+0,这样子就定位到了自己类的首地址,不过这毕竟是个猜测,因为在汇编层面未使用过该偏移表的第一项。
所以当有虚继承存在时,其表项第一项不用在意,后面的项表示有几个虚继承就会有几项,像上面例子中只有一个虚继承,所以通过第二项就能定位到虚基类了,注意这里的偏移值是相对于虚基类偏移表而言的(this+4的位置),而不是相对于对象的首地址。
OK,下面我们就通过汇编代码来验证一下,因为上例中有出现父类指针指向子类对象的情况,此时肯定需要定位到父类的this位置
A* pA = &objB; //objB的首地址为ebp-18h
00A1188B 8B 4D EC mov ecx,dword ptr [ebp-14h] //ebp-14h=ebp-18h+4,也就是this+4,说明取虚基类偏移表
00A1188E 8B 51 04 mov edx,dword ptr [ecx+4] //取偏移表的第二项,也就是8
00A11891 8D 44 15 EC lea eax,[ebp+edx-14h] //基类在距偏移表8字节的位置 ebp-14h+edx(ebp-14h=this+4,edx=8)
00A11895 89 85 14 FF FF FF mov dword ptr [ebp-0ECh],eax //获取到基类的地址保存
好了,下面再来探讨一下如何保证基类在内存中保留一份,或者说是初始化一次,其实这里编译器在调用构造时会偷偷的传入一个参数,这个参数的主要作用就是区分基类是否需要构造和填写虚基类偏移表,参数为1表示需要初始化,为0表示不需要初始化。
B objB;
0134186E 6A 01 push 1 //传入参数1,表示需要构造基类A
01341870 8D 4D E8 lea ecx,[objB]
01341873 E8 B2 FA FF FF call B::B (0134132Ah) //调用构造
//B构造
0134132A E9 01 04 00 00 jmp B::B (01341730h)
01341730 55 push ebp
//...
01341753 83 7D 08 00 cmp dword ptr [ebp+8],0 //比较传入的标志是否为0
01341757 74 15 je B::B+3Eh (0134176Eh) //为0则跳转,不构造
01341759 8B 45 F8 mov eax,dword ptr [this]
0134175C C7 40 04 54 6B 34 01 mov dword ptr [eax+4],offset B::`vbtable' (01346B54h) //在this+4的地方填写虚基类偏移表
01341763 8B 4D F8 mov ecx,dword ptr [this]
01341766 83 C1 0C add ecx,0Ch
01341769 E8 D6 FA FF FF call A::A (01341244h) //调用A构造
0134176E 8B 45 F8 mov eax,dword ptr [this] //参数为0不构造就直接跳转到这里继续执行
01341771 C7 00 40 6B 34 01 mov dword ptr [eax],offset B::`vftable' (01346B40h) //填写B的虚表
01341777 8B 45 F8 mov eax,dword ptr [this]
0134177A 8B 48 04 mov ecx,dword ptr [eax+4] //获取虚基类偏移表
0134177D 8B 51 04 mov edx,dword ptr [ecx+4] //获取第二项
01341780 8B 45 F8 mov eax,dword ptr [this]
01341783 C7 44 10 04 4C 6B 34 01 mov dword ptr [eax+edx+4],offset B::`vftable' (01346B4Ch) //根据虚基类偏移表定位到虚基类,然后在覆盖其基类的虚表
0134178B 8B 45 F8 mov eax,dword ptr [this]
0134178E C7 40 08 02 00 00 00 mov dword ptr [eax+8],2 //成员对象赋值
//....
OK,下面再记录一个冷门的知识,观察上面的例子,可以发现,其类B并没有重写类A的虚函数,如果类B中有和类A一样的虚函数,此时类的内存结构会多出4字节,这多出的4字节称为vtordisp,这个在msdn上有这么一段解释
Enables the addition of the hidden vtordisp construction/destruction displacement member. The vtordisp pragma is applicable only to code that uses virtual bases. If a derived class overrides a virtual function that it inherits from a virtual base class, and if a constructor or destructor for the derived class calls that function using a pointer to the virtual base class, the compiler may introduce additional hidden “vtordisp” fields into classes with virtual bases.
也就是当虚继承中派生类重写了基类的虚函数,并且在构造函数或者析构函数中使用指向基类的指针调用了该函数,编译器会为虚基类添加vtordisp域。其作用的话只能说是猜测,可能类似于标志分割的用途,上边是非虚基类,而下边是虚基类。
下面修改一下B类,并且运行观察其内存结构
class B : virtual public A
public:
B()
m_nB = 2;
virtual int GetNumB()
return m_nB;
virtual int GetNumA() //添加的虚函数
return m_nB;
protected:
int m_nB;
;
好了,到此对于虚基类偏移表应该有比较清楚的理解了,我们可以继续来看沙发床的例子,下面看其内存分布图,其实上面的理解后在看这个就比较好理解了
此时对于沙发和床而言,都有其对应的虚基类偏移表,这个其实比较好理解,因为对于菱形继承的上一半部分而言其实就是我们前面那个最简单的例子,然后沙发床是继承了沙发和床,那么继承的本质就是数据的复制,所以此时数据是不会少的,原本沙发和床对象中有各自的虚基类偏移表,继承后也还是会原封不动。而换个角度来说,当虚基类偏移表各自独立时,其父类指针指向子类对象的转换查表就比较轻松了
CSofaBed sofaBed;
CSofa *pSofa = &sofaBed;
CBed *pBed = &sofaBed;
//下面只需各自根据其this+4的位置查表定位到CFurniture即可
CFurniture *pFurniture1 = pSofa;
01255968 8B 45 C8 mov eax,dword ptr [pSofa]
0125596B 8B 48 04 mov ecx,dword ptr [eax+4] //虚基类偏移表
0125596E 8B 51 04 mov edx,dword ptr [ecx+4] //获取第二项
01255971 8B 45 C8 mov eax,dword ptr [pSofa]
01255974 8D 4C 10 04 lea ecx,[eax+edx+4] //定位到CFurniture
01255978 89 8D D0 FE FF FF mov dword ptr [ebp-130h],ecx
CFurniture *pFurniture2 = pBed;
01255999 8B 45 BC mov eax,dword ptr [pBed]
0125599C 8B 48 04 mov ecx,dword ptr [eax+4] //虚基类偏移表
0125599F 8B 51 04 mov edx,dword ptr [ecx+4] //获取第二项
012559A2 8B 45 BC mov eax,dword ptr [pBed]
012559A5 8D 4C 10 04 lea ecx,[eax+edx+4] //定位到CFurniture
012559A9 89 8D D0 FE FF FF mov dword ptr [ebp-130h],ecx
可以发现上面的两段代码中,只是第一行汇编不同,其余都一样,区别在于开始的this指针,剩余的都是在this+4的位置查表定位。如果只有一张虚基类偏移表,那么这个复杂度就又会上升很多了。
好了,下面再来分析一下沙发床的构造,分析其如何保证构造一次,其原理和前面那个简单案例是相同的
CSofaBed sofaBed;
0125591E 6A 01 push 1 //传入参数1表示需要构造
01255920 8D 4D D4 lea ecx,[sofaBed]
01255923 E8 4B B7 FF FF call CSofaBed::CSofaBed (01251073h)
01251073 E9 A8 09 00 00 jmp CSofaBed::CSofaBed (01251A20h)
01251A20 55 push ebp
//...
01251A6C 83 7D 08 00 cmp dword ptr [ebp+8],0 //判断参数
01251A70 74 35 je CSofaBed::CSofaBed+87h (01251AA7h) //为零不需要构造则跳转
01251A75 C7 40 04 08 8C 25 01 mov dword ptr [eax+4],offset CSofaBed::`vbtable' (01258C08h) //填写CSofa的虚基类偏移表
01251A7C 8B 45 EC mov eax,dword ptr [this]
01251A7F C7 40 10 14 8C 25 01 mov dword ptr [eax+10h],offset CSofaBed::`vbtable' (01258C14h) //填写Cbed的虚基类偏移表
01251A86 8B 4D EC mov ecx,dword ptr [this]
01251A89 83 C1 20 add ecx,20h
01251A8C E8 AD F8 FF FF call CFurniture::CFurniture (0125133Eh) //调用构造
//...
//不用构造基类则跳转到这继续执行
01251AA7 6A 00 push 0
01251AA9 8B 4D EC mov ecx,dword ptr [this]
01251AAC E8 81 F5 FF FF call CSofa::CSofa (01251032h) //调用CSofa构造,参数为0,不用构造基类
01251AB8 6A 00 push 0
01251ABA 8B 4D EC mov ecx,dword ptr [this]
01251ABD 83 C1 0C add ecx,0Ch //跳转this
01251AC0 E8 0C F7 FF FF call CBed::CBed (012511D1h) //调用CBed构造,参数为0,不用构造基类
//下面开始覆盖填写虚表,三个父类需要填写三次
01251AC5 8B 45 EC mov eax,dword ptr [this]
01251AC8 C7 00 E0 8B 25 01 mov dword ptr [eax],offset CSofaBed::`vftable' (01258BE0h) //CSofa的虚表覆盖为CSofaBed的虚表
01251ACE 8B 45 EC mov eax,dword ptr [this]
01251AD1 C7 40 0C F0 8B 25 01 mov dword ptr [eax+0Ch],offset CSofaBed::`vftable' (01258BF0h) //CBed的虚表覆盖为CSofaBed的虚表
01251AD8 8B 45 EC mov eax,dword ptr [this]
01251ADB 8B 48 04 mov ecx,dword ptr [eax+4]
01251ADE 8B 51 04 mov edx,dword ptr [ecx+4] //需要根据虚基类偏移表定位到CFurniture
01251AE1 8B 45 EC mov eax,dword ptr [this]
01251AE4 C7 44 10 04 FC 8B 25 01 mov dword ptr [eax+edx+4],offset CSofaBed::`vftable' (01258BFCh) //覆盖CFurniture的虚表
//下面为构造函数体内容
01251AEC 8B 45 EC mov eax,dword ptr [this]
01251AEF C7 40 1C 06 00 00 00 mov dword ptr [eax+1Ch],6 //成员变量赋值
//...
最后我们再来简单分析一下析构,因为在构造中传入参数来保证虚基类只构造一次,那么在析构中也是同样的做法么,我们来看一下其汇编的表现形式
012559C2 8D 4D D4 lea ecx,[sofaBed]
012559C5 E8 DF B7 FF FF call CSofaBed::`vbase destructor' (012511A9h) //调用析构代理函数
//析构代理函数
012511A9 E9 92 0C 00 00 jmp CSofaBed::`vbase destructor' (01251E40h)
01251E40 55 push ebp
//...
01251E63 8B 4D F8 mov ecx,dword ptr [this]
01251E66 83 C1 20 add ecx,20h
01251E69 E8 0A F2 FF FF call CSofaBed::~CSofaBed (01251078h) //调用真正的析构函数
01251078 E9 53 0C 00 00 jmp CSofaBed::~CSofaBed (01251CD0h)
01251CD0 55 push ebp
//...
//下面先开始还原虚表,三个基类需要还原三次,其过程和构造相同
01251CF0 89 4D F8 mov dword ptr [this],ecx
01251CF3 8B 45 F8 mov eax,dword ptr [this]
01251CF6 C7 40 E0 E0 8B 25 01 mov dword ptr [eax-20h],offset CSofaBed::`vftable' (01258BE0h) //还原CSofa处的虚表
01251CFD 8B 45 F8 mov eax,dword ptr [this]
01251D00 C7 40 EC F0 8B 25 01 mov dword ptr [eax-14h],offset CSofaBed::`vftable' (01258BF0h) //还原CBed处的虚表
01251D07 8B 45 F8 mov eax,dword ptr [this]
01251D0A 8B 48 E4 mov ecx,dword ptr [eax-1Ch]
01251D0D 8B 51 04 mov edx,dword ptr [ecx+4]
01251D10 8B 45 F8 mov eax,dword ptr [this]
01251D13 C7 44 10 E4 FC 8B 25 01 mov dword ptr [eax+edx-1Ch],offset CSofaBed::`vftable' (01258BFCh) //根据虚基类偏移表定位到虚基类后还原CFurniture的虚表
//下面执行函数体
//打印"virtual ~CSofaBed()\\r\\n"
//...
01251D2B 83 E9 04 sub ecx,4
01251D2E E8 2C F3 FF FF call CBed::~CBed (0125105Fh) //调用CBed的析构
01251D33 8B 4D F8 mov ecx,dword ptr [this]
01251D36 83 E9 14 sub ecx,14h
01251D39 E8 49 F3 FF FF call CSofa::~CSofa (01251087h) //调用CSofa的析构
//...
01251E6E 8B 4D F8 mov ecx,dword ptr [this]
01251E71 83 C1 20 add ecx,20h
01251E74 E8 B8 F2 FF FF call CFurniture::~CFurniture (01251131h) //调用虚基类的析构函数
//...
可以发现其析构使用了析构代理的处理方案,也就是说在析构代理中会做对虚基类的清理工作(调用析构),确保了只会调用一次。而真正的析构函数里面并不会对虚基类的析构进行调用,简单来说,如果需要析构虚基类,那么就调用析构代理函数,否则就调用真正析构函数。
以上是关于C++ 虚继承的主要内容,如果未能解决你的问题,请参考以下文章