摘 要:本文从对象语言模型的角度,对“对象语言”中的多态,函数重载,成员布局等特性进行了深刻的剖析。
关键词:面向对象、构造、虚拟函数、虚函数表、多态、编译期、运行期…….
一、前言
在“对象语言”的学习过程中的疑惑,多是因为对“面向对象”本质理解的模糊。作者对“对象语言”的多态,继承,封装等性质也存疑惑多年,直到今日才初解其道。弗敢己专,愿与读者共享。
应该说“面向对象”是一种观念,那么 多态、继承、封装等机制应该是独立于任何语言之上的。具体到每种语言,每种编译器因为所侧重的方向有所不同,故在“面向对象”的实现上有了差异,这种差异也就导致了不同的对象模型。比如c++语言模型它首先的侧重点是对c 的兼容,为了赢得广大 c 语言的使用者的拥护,那么它还必须对“存取效率”,“空间效率”加以特殊的实现(我们知道c 语言是以语言灵活,执行速度快,内存占用小等特点赢得大家的拥护的)。使用过程中的疑惑,皆是因为对“面向对象”实质把握的模糊。庖丁的功力不在其刀,而在对牛脉络的理解。具体的说解总是要有的放矢的,所以以下的理论说明我们主要针对c++的对象模型,具体代码分析主要针对VS.NET集成的C++编译器的编译结果。至于关于C#语言实现特性的分析,我主要是针对程序的反汇编代码和微软的MSIL。
为了作者说明上和读者理解上的方便,我所说的一切“面向对象”的特性皆不考虑多类继承和虚拟继承。 |
二、对象模型的塑造
从自然界的角度来说,对象应该是一个存在或者抽象出来的独立体,它应该有自己的特性和操作这些特性的方法。面向对象的语言把对自然界对象抽象的结果用计算机语言加上某种数据结构模拟出来的过程就叫对象模型的塑造。对“对象模型”的理解是我们对“对象语言”一切特性深刻理解的开始和基础。具体到C++语言就是编译器如何实现class(类)的过程。举个例子:
#include "stdafx.h"
#include"iostream.h"
class Class1
{
public:
int mem_data1;
int mem_data2;
Class1(){ }//constructor
void mem_func1(){ cout<<"this is mem_func1"<<endl; this->mem_data1 =10; }
void virtual mem_vfun1(){ cout<<"this is mem_vfun1"<<endl; this->mem_data2 =20; }
void virtual mem_vfun2(){ cout<<"this is mem_vfun2"<<endl; this->mem_data2 =30; }
};
int _tmain(int argc, _TCHAR* argv[])
{
Class1 * ptemp=new Class1();
ptemp->mem_func1();
ptemp->mem_vfun1();
return 0;
}
上面的代码我们声明一个类Class1,它包括两个成员变量 mem_data1,mem_data2,一个成员函数 mem_func1(),两个成员虚函数mem_vfunc1()、mem_vfunc1()。那么Class1被实例化后它在内存中是如何布局的呢?
vtable
(Class1::*mem_vfun1)() |
(Class1::*mem_vfun2)() |
(Class1::*mem_vfun1)()表示指向Class1成员函数mem_vfun1的指针 |
如果不考虑虚函数的影响,我们发现一个class 实例的内存布局,完全和一个Struct(C语言版)的实例的内存布局一样,成员按照声明的先后在内存中连续分配。那么vptr是什么呢?vptr 是指向虚函数表的指针。虚函数表是一个表格结构,里面放着类的成员虚函数的地址。我们上述类Class1的虚函数表用Microsoft Macro Assembler定义形式如下:
??_7Class1@@6B@ DD FLAT:?mem_vfun1@Class1@@UAEXXZ ; Class1::`vftable'
DD FLAT:?mem_vfun2@Class1@@UAEXXZ |
读者可能对上面代码中出现的很多奇怪的字符感到陌生,“??_7Class1@@6B@”在我的代码中从没有出现过,那么它是哪来的?其实“??_7Class1@@6B@”就等价于字符串“Class1::vftable”,
而 ”mem_vfun1@Class1@@UAEXXZ” 和“?mem_vfun2@Class1@@UAEXXZ”正是Class1中的两个虚函数mem_vfun1,mem_vfun2处理后的名字,“FLAT”表示内存用的线性模式。如果取消名字的处理,上述的定义也就等价如下:
Class1_vftable DD FLAT:: Class1::mem_vfun1; Class1_vftable 表示这是Class1的虚函数表
DD FLAT:: Class1::mem_vfun2; |
很明显虚函数表中存放的是类Class1的两个虚函数mem_vfunc1,mem_vfunc2的内存地址。
为什么编译器对我们定义的名称动了手脚?请考虑下面的操作:
class Class2
{
public:
int mem1;
};
|
class Class3:public Class2
{
public:
int mem1;
}; |
上面的Class3从Class2派生出来,自然Class3将直接继承了Class2的mem1可是Class3中也定义了一个mem1。为了使每个符号变量都能被唯一的区分出来,编译器都会对我们定义的符号名称施以“name-mangling”(微软的帮助文档中称这种操作叫“名字修饰”),让所有的符号名称得到独一无二的名称。Class3施以“name-mangling”后,其形式如下:
(我们仅仅是模仿“name-mangling”,让每个变量加上其类名,真的“name-mangling”要比这复杂)
class Class3
{
public:
int Class2-mem1;
int Class3-mem1;
}; |
一个类生成实例,就是在内存中构造出了它的成员函数和虚函数表指针(当类中声明虚函数的时候),那么类的成员函数是如何操作类的成员数据的呢?其中的奥秘就在this 指针。
每个成员函数都隐藏了一个指向本类实例的指针(this 指针)。
void mem_func1(){ cout<<"this is mem_func1"<<endl; this->mem_data1 =10; }
其实等价于://伪代码
void mem_func1(Class1 * _this){ cout<<"this is mem_func1"<<endl; _this->mem_data1 =10; }
int _tmain(int argc, _TCHAR* argv[])中的ptemp->mem_func1();
也就等价如下:
mem_func1(ptemp);
实际执行的是:
cout<<"this is mem_func1"<<endl;
ptemp->mem_data1 =10;
每个成员函数都是含有一个指向本类类型的指针,通过本指针成员函数也就和成员数据建立了联系。不过这一切我们并不知道,编译器偷偷的为我们做了一切。从下面的Class1::mem_func1()的汇编实现就会显露一切天机(分号后面是作者加的注释)。
_TEXT SEGMENT
_this$ = -8 ;this 指针被函数保存在ebp-8处
?mem_func1@Class1@@QAEXXZ PROC NEAR ; Class1::mem_func1名字处理后的结果
; _this$ = ecx 函数用ecx来接收this指针
push ebp
mov ebp, esp
sub esp, 204 ; 000000ccH
push ebx
push esi
push edi
push ecx
lea edi, DWORD PTR [ebp-204]
mov ecx, 51 ; 00000033H
mov eax, -858993460 ; ccccccccH
rep stosd
pop ecx ;以上代码是堆栈的建立和初始化
;开辟了204个字节的区域,并初始化为ccH
mov DWORD PTR _this$[ebp], ecx ;构造this指针
为了减少读者阅读的难度,我去掉了cout<<"this is mem_func1"<<endl;的汇编实现。 |
mov eax, DWORD PTR _this$[ebp]
mov DWORD PTR [eax+4], 10
使用间接寻址,等价操作:this->mem_data1 =10;因为vptr使用了4个字节,所以
Class1::mem_data1的偏移地址为4,可推之Class1::mem_data2的偏移地址为8
pop edi
pop esi
pop ebx
add esp, 204
cmp ebp, esp
call __RTC_CheckEsp ;恢复堆栈并检验
mov esp, ebp
pop ebp
ret 0
?mem_func1@Class1@@QAEXXZ ENDP ;End Class1::mem_func1
_TEXT ENDS
一旦类的数据成员和成员函数建立了关联,对象模型也就“基本”塑造出来了。那么模型中的vtable和编译器使用的“name-mangling”到底有什么其它作用呢?正是这两种技术成就了两种多态(运行时多态,编译期多态)。
本文在开始就说明了我所阐述的所有观点都是在抛开“多类继承”和“虚拟继承”的情况下讨论的,因为这两种继承无论那一种都会把问题引入复杂,过于深而专的阐述不一定有好的收益。
三、两种多态
多态就是同一调用方式可以映像到不同的函数。至于具体调用哪个函数,一种情况可以在编译阶段确定叫编译期多态,另一种情况只能到程序实际执行的时候才能确定叫运行时多态。
3.1 编译期多态
我首先说一下编译期多态。编译期多态在面向对象语言中的具体体现是函数重载(function overload),即是允许多个函数共享一个函数名。
“需要”往往是推动进步的第一动力。想想“函数名的恶梦”吧,在以前过程语言中因为不允许函数重载,唯一能区别不同函数的就是函数名。为了让有同一动作但是有不同细节的函数区分出来,于是就出现了各种古怪的函数名称。例如:printf 、vprintf、 wprinf、 sprintf、 fprintf 、fwprintf…….。不知道读者看后有何感想,我可是几近昏倒。 函数重载让我们从这些地狱般的词汇中解脱出来。 让我们看一个例子:
class mymath
{
public:
static int add( int mem1 ,int mem2 ){ return mem1+mem2; }
Static double add( double mem1 ,double mem2 ){ return mem1+mem2; }
……………
}; |
当然本类的完全可以用模板实现,不过此例子的出现仅为举例而已。 |
此例子假设我们要设计一个数学运算的类,类中提供了加法函数add。从例子中我们可以看出“函数重载”可让函数名更加清晰,函数就是一种动作,函数名仅应该是动作的表示而不应该夹杂其它信息。
mymath::add (3.999,4.0);
显然用户对函数add的调用会产生几种不同的可能调用。(呈现多态性)
不过这种多态也仅存在编译以前,编译以后用户的类似调用就会被解析到唯一的函数调用,故我称这种多态叫编译期多态。重载的解析原则在<<c++ primer>>中有深刻的阐述,我这里就不提了。一旦我们的调用产生了二异性,这种错误在编译阶段就会抛出,决不会到链接期才决议。因为编译器转接给链接器的都是可以唯一被区分的函数名。此中的奥妙还是“name-mangling”。看看编译器的所作所为:
?add@mymath@@SANNN@Z int mymath::add(int ,int) 被“name-mangling”后的名字
?add@mymath@@SAHHH@Z double mymath::add(double ,double) 被“name-mangling”后的名字 |
函数名最后还是免不了被唯一化,不过这对我们来说都是隐含的。看来面向对象编程是一种包装的艺术,我们是站在了“对象”的上面,但是编译器还是把我们的“对象”代码结构化了(原来的c++的Cfront 编译器就是把C++代码先编译到C 然后再用一个C的编译链接器完成最后的编译和链接的)。打破我们脑中的一切惯性吧,这样我们才可能更近真理。不要把“结构化语言”和“对象语言”看成势不两立,“对象语言”不过是“结构化语言”的彼岸。“面向对象”是一种观念,有了这种观念,用C你也能写出“对象”的味道来!
3.2 运行时多态
运行时多态应该是本文的重中之重,很多读者的疑惑也就是因此而产生的。
什么“需要”导致了运行时多态? “面向对象”的观念是源于现实世界的,是描述现实世界的,现实中的需要最能说明“面向对象”一切特性的由来。让我们看下面的例子:
我们要设计一个关于动物体系的类,整个体系如下图:
自然界中的每种动物应该都会叫(roar),作为一切动物的基类 CAnimal 应该体现这种一般动作。可是每种动物的叫声又各不相同,所以CDog和CCat中应该重写(override)这一动作,CPekinese 也是如此。 |
具体代码实现如下:
class CAnimal
{public:
virtual void jump(){cout<<" this is jump"<<endl;}
virtual void roar()=0;//定义为纯虚拟函数,仅表示有此动作
};
class CDog:public CAnimal
{
public:
virtual void roar(){ cout<<"this is common dog"<<endl;}
//override 实现自己的动作
};
class CPekinese:public CDog
{
public:
virtual void roar(){ cout<<"this is pekinese"<<endl;}
//override 和普通狗的叫声有区别
}; // 所以要重写
class CCat:public CAnimal
{
public:
virtual void roar(){ cout<<"this is common cat"<<endl;}
};
int _tmain()
{
CDog dog;
CCat cat;
CPekinese pekinese;
CAnimal * p[3]={&dog,&cat,&pekinese};
for( int i=0;i<3;i++)
p[i]->roar() ;
}
p[i]->roar();调用哪个类的函数,我们在编译阶段根本无法确定。因为编译器从编译角度仅仅可以知道指针p的类型是CAnimal**但是却无法确定它指向的实例的类型,可能是CDog的实例也可能是CPekinese的实例也可能是CCat的实例(呈现多态)。P指针指向的实例只有到程序实际执行的时候才能决议出来。有人称此现象叫动态绑定(dynamic binging)。 |
但是程序执行的时候 ,代码已经脱离编译器、链接器的干预。那么是谁完成的动态绑定?
所有功能和“类型识别”应该都是在编译和链接的时候完成,动态绑定(dynamic binging)、运行时类型识别(RTTI)只不过是某种技术的结果。
编译器唯有根据变量定义的类型,强制转换、隐式转换 所展现的信息来决定一个变量的类型。而动态绑定、运行时类型识别这些需要在运行的时候动态判定一个变量的类型的特性,一定是依托到了某种技术实现的。编译器不会通过,代码只有在执行的时候才能显露出的信息,来动态判定你的变量类型,决议你所调用了哪个函数。
背后的技术是什么?
如果你对前面“对象模型的塑造”中关于“this”指针和“对象的内存布局”的介绍还没有完全的掌握不妨还是回过头去复习一下,因为下面的解答完全是以它们为基础的。
CAnimal 实例的内存布局
(下转:深入语言的背后(下))
|