今天有朋友问到一个问题,那就是在C++的多重继承中,出现菱形状继承的情况下,在构造对象时的内存分布及构造函数的调用流程上出现了问题。最后跟他解释清楚之后,我感觉还是有必要把这个过程写下来,有什么说得不对的地方请大家提出宝贵意见,在此感谢,同时知道这里面的朋友可以直接略过本篇。
好了,直接切入正题,所谓的菱形继承,最简单的构造如下:
class A
{
public:
A( void ) : nVar( 0xaaaa0000 ){}
public:
int nVar;
};
class B1 : public A
{
public:
B1( void ){}
};
class B2 : public A
{
public:
B2( void ){}
};
class C : public B1, public B2
{
public:
C( void ){}
};
就是这样一个多重继承,用图形化来表示之间的关系就是:
A
/ /
/ /
B1 B2
/ /
/ /
C
然后,在创建C的对象:
int main( void )
{
C obj;
return 0;
}
我想大家应该知道这样将造成什么情况,在这里可以清楚的知道obj的大小为8,为什么是8,先看内存分布:
假如obj的内存地址为0x0012ff18.
0x0012FF18: 00 00 aa aa 00 00 aa aa
看了obj对象的内存,里面有2个A的副本,红色的就是B1那条线继承下来的内存,蓝色就是B2那条线继承下来的。因此A的构造函数被调用了两次,这里B1在前面,B2在后面是因为一对多继承是从左到右分布内存的。
从这里明显知道这样的结局肯定是很悲剧的。更可怕的是假如使用obj访问nVar成员将导致编译出错:
obj.nVar = 0x100;
对nVar的访问不明确,因为有两个副本,编译器不知道你到底要修改那个副本,从而导致编译错误,这里访问成员函数也是一个道理。
那么,有什么解决办法不让这种现象出现呢,C++提出了虚继承,以解决这个问题:
class A
{
public:
A( void ) : nVar( 0xaaaa0000 ){}
public:
int nVar;
};
class B1 : virtual public A
{
public:
B1( void ){}
};
class B2 : virtual public A
{
public:
B2( void ){}
};
class C : public B1, public B2
{
public:
C( void ){}
};
这样继承下来后,A就只会保留一个副本,再来看内存分布(这里声明,我使用的是VC2008版本来测试的):
假如obj的内存地址为:0x0012FF10
0x0012FF10: 0041580c 00415800 aaaa0000
可以清晰看出这里0xaaaa0000只有一个,而这时前面多了两个值,obj的大小为12字节,前面蓝色的地址就是C类的虚基指针(vbtable)如果A有虚函数的话,在蓝色和红色之间还会加上虚函数表(vftable)这时就占16字节了。这里就不具体介绍多重继承的虚表的内存分布了。
好了,下面就是本文的重点了,来看看obj对象创建时,调用构造函数的流程:
流程大概就是:在obj创建时,首先会调用C类的构造函数,在构造函数中,首先会将两个vbtable的偏移赋值给前面的蓝色部分内存。之后就会调用A的构造函数,调用之后再调B1和B2的构造函数。
用伪代码来表示:
C()
{
vbtable;
vbtable;
A::A();
B1::B1();
B2::B2();
}
那么在调用B1和B2的构造函数是时,按理说会调用A的构造函数,因为B1、B2也是继承于A,但是为什么没有调用A的构造函数呢?来看看反汇编代码:
首先看main函数:
C obj;
004113DE push 1
004113E0 lea ecx,[obj]
004113E3 call C::C (4110E6h)
在红色处调用C的构造函数,再来看C的构造函数:
00411460 push ebp
00411461 mov ebp,esp
00411463 sub esp,0CCh
00411469 push ebx
0041146A push esi
0041146B push edi
0041146C push ecx
0041146D lea edi,[ebp-0CCh]
00411473 mov ecx,33h
00411478 mov eax,0CCCCCCCCh
0041147D rep stos dword ptr es:[edi]
0041147F pop ecx
00411480 mov dword ptr [ebp-8],ecx
00411483 cmp dword ptr [ebp+8],0
00411487 je C::C+47h (4114A7h)
00411489 mov eax,dword ptr [this]
0041148C mov dword ptr [eax],offset C::`vbtable' (41580Ch)
00411492 mov eax,dword ptr [this]
00411495 mov dword ptr [eax+4],offset C::`vbtable' (415800h)
0041149C mov ecx,dword ptr [this]
0041149F add ecx,8
004114A2 call A::A (4110EBh)
004114A7 push 0
004114A9 mov ecx,dword ptr [this]
004114AC call B2::B2 (4110AAh)
004114B1 push 0
004114B3 mov ecx,dword ptr [this]
004114B6 add ecx,4
004114B9 call B1::B1 (41107Dh)
004114BE mov eax,dword ptr [this]
004114C1 pop edi
004114C2 pop esi
004114C3 pop ebx
004114C4 add esp,0CCh
004114CA cmp ebp,esp
004114CC call @ILT+330(__RTC_CheckEsp) (41114Fh)
004114D1 mov esp,ebp
004114D3 pop ebp
004114D4 ret 4
上面蓝色的为加粗字体,可以看出在赋值vbtable。下面的红色为加粗的部分就是调用A的构造函数。这不奇怪。
在调用A的构造之前有一句:add ecx, 8 这一句的目的是为了将this定位到两个vbtable之后,在调用A的构造函数时,直接往this所指向的内存地址下写值:0xaaaa0000。因此就构成了布局:
0x0012FF10: 0041580c 00415800 aaaa0000
C::this/( vbtable) vbtable A::this
C的this在这里看当然是0x0012ff10,A的this就是0x0012ff18,中间相隔两个vbtable,其实this也就是某个类的起始地址,没有什么特别的。
到这里,你可能注意到了蓝色加粗和红色加粗的两条一样的指令push 0,这条语句显然是编译器添加的,B2的构造函数明显没有参数,这样push一个0进去有点类似隐含的一个参数,那么push一个0进去到底做了些什么呢,再看B1的构造函数:
00411550 push ebp
00411551 mov ebp,esp
00411553 sub esp,0CCh
00411559 push ebx
0041155A push esi
0041155B push edi
0041155C push ecx
0041155D lea edi,[ebp-0CCh]
00411563 mov ecx,33h
00411568 mov eax,0CCCCCCCCh
0041156D rep stos dword ptr es:[edi]
0041156F pop ecx
00411570 mov dword ptr [ebp-8],ecx
00411573 cmp dword ptr [ebp+8],0
00411577 je B1::B1+3Dh (41158Dh)
00411579 mov eax,dword ptr [this]
0041157C mov dword ptr [eax],offset B1::`vbtable' (415818h)
00411582 mov ecx,dword ptr [this]
00411585 add ecx,4
00411588 call A::A (4110EBh)
0041158D mov eax,dword ptr [this]
00411590 pop edi
00411591 pop esi
00411592 pop ebx
00411593 add esp,0CCh
00411599 cmp ebp,esp
0041159B call @ILT+330(__RTC_CheckEsp) (41114Fh)
004115A0 mov esp,ebp
004115A2 pop ebp
004115A3 ret 4
红色的那句指令很明显,ebp+8正是函数的第一个参数,这里虽然没有,但是压入了一个0,这样一个cmp与0比较相等,执行蓝色的跳转直接跃过A的构造函数调用到绿色的那条指令。这样便实现了只调用一次A的构造函数的功能。B2的构造函数也是同理,这里就不介绍了。
有了这样一个push 0 然后又检查是否为零的操作,所以就算你在B1、B2中显示调用A的构造函数,结果还是不会调用A的构造函数的。
形如: B1( void ): A(){} 因为判断为零直接跳转到构造函数的用户代码里。
好了,本文就到这里就差不多了,这里只是介绍了虚继承中构造函数调用的原理。望大家多多提意见哈。