(上接:深入语言的背后(上))
(CAnimal) (CAnimal::`vftable')
CDog 实例的内存布局
(CDog) (CDog::`vftable')
CPekinese 实例的内存布局
(CPekinese) (CPekinese::`vftable')
CCat 实例的内存布局
(CCat) (CCat::`vftable')
以上就是各个类的实例的内存布局图。首先要说明一点:为什么CAnimal的虚函数表的(1)处的函数地址是__purecall()而不是CAnimal::roar()呢?在类CAnimal中roar()被定义为纯虚拟函数,其实它是没有函数体的,仅仅说明对象有这个动作,具体的动作是派生类实现的。
所以在CAnimal还没有派生以前,其虚函数表中的roar()的位置被编译器置入了一个内部函数__purecall()。这个函数有两个作用:一是占位,为将来重写(override)它的函数预留空间;二是保护作用,一旦此处的函数被错误调用则结束程序。
__purecall() 在 purevirt.c 中被定义。定义如下:
void __cdecl _purecall(void)
{
_amsg_exit(_RT_PUREVIRT);
} |
从上面的图应该很容易看出每一个内含虚函数的类都对应一个自己的虚函数表,虚函数的调用是一个间接调用。
p[i]->roar();
其实等价如下式子://伪代码
(*p[i]->vptr[1])(p[i]) ;//roar()函数在虚函数表中的索引是1
l 函数的索引值是有调用者的编译类型决定的,即用户实际声明的类型。
l 比如p[i]->roar();就是根据P的声明类型CAnimal * * ,即是p[i]调用的函数的虚函数表索引是参照CAnimal确定的。
从转换后的式子可以看出无论p[i]在实际执行的时候指向哪个派生类的实例都没有关系,因为根据p[i]我们可以寻到它实际类型的虚拟函数表,虚拟函数表中蓄存的正是本类型的每个虚拟函数地址。看!一个简单的间接调用就把动态绑定给解决了(确实伟大)。
试分析如下代码:
CAnimal * panimal;
CDog * pdog;
pdog=new CPekinese( );
panimal=pdog;
panimal->roar(); |
panimal->roar(); 调用哪个函数呢?CDog::roar()? CPekinese::roar()?当然CPekinese::roar()。
至于为什么我想读者稍加分析即可以知道了。我想强调的是同一派生体系中,如果一个类重写(override)了虚函数,那么重写前和重写后的两个虚函数在我们程序中都是存在的。重写(override)只是把自己实现的函数地址写到对应索引值的虚函数表中。每一个虚函数无论它是不是被改写,无论它所在的类继承体系有多深,它在虚函数表中的索引值都是固定不变的。
为了让你确信,还是让你看看 panimal->roar(); 的汇编代码
mov eax, DWORD PTR _panimal$[ebp] ;_panimal$[ebp] 相当于panimal
mov edx, DWORD PTR [eax] ;取vftable地址,vptr的相对偏移为0
mov esi, esp
mov ecx, DWORD PTR _panimal$[ebp] ;this call 约定用ecx 传递this指针
call DWORD PTR [edx+4] ;根据vftable的内容,调用函数,因为
;roar()在虚函数表中的索引是1,32位机中的指针是4个字节,所以edx+1*4 |
四、牛刀初试
看完前面的理论分析,我想大家一定想去实践一下。下面我就用这些理论分析一个实际的例子(这些例子我也曾困惑很久),你会发现,现在再理解这些例子是多么的容易。
我曾说过上述的理论是不分语言的,不过每个语言的实现细节可能会有所不同。为了验证这些理论对别的语言的适用性,我分析的例子是关于C#语言的。
我们分析微软的《C#语言规范》在说明如何判别虚拟方法调用中的一个例子。微软讲述的判别方法,当初我是没有看明白!(他不说我还明白,他一说我反而糊涂了)
这篇文档在Microsoft Visual Studio .NET 文文件中《C#语言规范》的10.5.3,题目是“虚拟方法”。
例子代码如下(注意是C# 代码,不是c++):
class A { public virtual void F() { Console.WriteLine("A.F"); } } class B: A { public override void F() { Console.WriteLine("B.F"); } } class C: B { new public virtual void F() { Console.WriteLine("C.F"); } } class D: C { public override void F() { Console.WriteLine("D.F"); } } class Test { static void Main() { D d = new D(); A a = d; B b = d; C c = d; a.F(); b.F(); c.F(); d.F(); } }
应该说此例子中的代码和我们以前分析的应该没有多大区别,唯一的“难点”也就是C#中new关键词的使用。如果画出每个类的内存布局图,不用我说,你也知道答案了。
在C#中如果一个类没有声明基类,则默认从Object派生,因为继承了Object的虚函数缘故,所以一个没有声明基类的类所声明的第一个虚函数在虚函数表中的偏移是(38h)。我们以下的内存局图暂时先忽略Object的影响,即第一个虚函数的索引值还是定为0。
注:从Object的虚函数个数来看38h这个偏移是大了些。因为指针按4个字节来算38h这个偏移说明Object中已经有了14虚函数指针了,这有些不可能。所以我猜测C#语言模型中虚函数表中可能还存放了其它信息。
实例的内存布局 都是忽略Object 影响后的结果。为了说明的方便我还是借用c++的域运算符::,在C#中已没有此运算符了。
|
(A) (A.`vftable')
(B) (B. `vftable')
(C) (C.`vftable')
(D) (D.`vftable')
说明:因为在类中使用了关键词“new”,它的意思就是隐藏基类的同名成员。所以C中的虚函数F()出现不会重写(override)基类的函数,而是声明了一个新的(new)虚函数。不过它和基类的函数是同一个名字,可是想想我们的“name-mangling”你就会发现它们的名字其实是不一样的(你把函数名前加上函数所在的类名看看)。因为C中隐藏了基类B中的B.F(),所以D中重写的是C中的虚函数。
所以: l a.F(); 根据a的声明类型可判定a调用的是虚函数表中索引为0的函数。根据“a=d;”可以知道实际是调用的D 的虚函数表中索引为0的函数(B::F())。a.F()输出结果是 : B.F l b.F(); 根据b的声明类型可判定b调用的是调用虚函数表中索引为0的函数。根据“b=d;”可以知道实际是调用的D 的虚函数表中索引为0的函数(B::F())。b.F()输出结果是 : B.F l c.F(); 根据c的声明类型可判定c调用的是调用虚函数表中索引为1的函数(因为“new”的缘故)。根据“c=d;”可以知道实际是调用的D 的虚函数表中索引为1的函数(D::F())。c.F()输出结果是 : D.F l d.F();因为C中用“new”隐藏了B中的虚函数的缘故,所以在D中只有C.F()是可见的, d.F()自然调用的是虚函数表中索引值为1的函数(D::F())。所以输出结果是 : D.F 下面就是程序实际执行后的汇编代码:
; A a = d;
00000031 mov ebx,esi ;ebx中是&a
; B b = d;
00000033 mov dword ptr [ebp-0Ch],esi ;&b在 ebp-0Ch
; C c = d;
00000036 mov dword ptr [ebp-10h],esi ;&c在 ebp-10h
;a.F();
00000039 mov ecx,ebx ;this call 用ecx传递this指针 ebx=&a
0000003b mov eax,dword ptr [ecx] ;虚函数表地址
0000003d call dword ptr [eax+38h] ;看上面对38h的解释
;b.F();
00000040 mov ecx,dword ptr [ebp-0Ch] ;ebp-0ch指针变量b在堆栈中的位置
00000043 mov eax,dword ptr [ecx]
00000045 call dword ptr [eax+38h]
30: c.F();
00000048 mov ecx,dword ptr [ebp-10h] ; ebp-10h指针变量c在堆栈中的位置
0000004b mov eax,dword ptr [ecx]
0000004d call dword ptr [eax+3Ch] ;3ch 比38h多4个字节B::F()和C::F()在虚表中相邻
;31: d.F();
00000050 mov ecx,esi ;esi=&d;
00000052 mov eax,dword ptr [ecx] 00000054 call dword ptr [eax+3Ch] | 从上面的代码我们不难看出C#中虚函数的实现原理几乎和C++一样!
五、结束语
经过几个晚上的努力,这篇文章到此也就应该结束了。我总想把每个细节都认真的摊开给你看,毕竟上述的问题曾几何时也让我深深的迷惑,我深切的理解那迷茫中的痛苦。
关于构造、析构、运行时类型识别(RTTI)、静态成员函数(static member function)和内联函数(inline function)等方面的话题,无奈文稿有篇幅所限,这些话题也只能以后再向大家慢慢道来了。认知无涯,若本文中有什么错误,望给于斧正。
参考书目
1. Inside The C++ Object Model 华中科技大学出版社
2. 深入浅出 MFC 第二版 华中科技大学出版社
3. C++ Primer 电力出版社
4. 面向对象的程序设计语言__C++ 人民邮电出版社
|