偷梁换柱——揭开多态的面纱
——从C++到.NET
声明:本文的前半部分纯粹是为初学者写的,如果你对C++对象模型稍有了解,就不用看了。后半部分才把.NET的对象模型拉进来做了个简单的比较——总之,如果你不是初学者就不必看了,我也实在不想浪费你的时间:-)
刘未鹏(pongba) /文
多态是面向对象理论中的重要概念之一,从而也成为现代程序设计语言的一个主要特性,从应用角度来说,多态是构建高灵活性低耦合度的现代应用程序架构所不可忽缺的能力。从概念的角度来说,多态使得程序员可以不必关心某个对象的具体类型,就可以使用这个对象的“某一部分”功能。这个“某一部分”功能可以用基类来呈现,也可以用接口来呈现。后者显得更为重要——接口是使程序具有可扩展性的重要特性,而接口的实现依赖于语言对多态的实现,或者干脆就象征着语言对多态的实现。
本文并不大算赘述多态的应用,因为其应用实在俯拾皆是,其概念理论也早已完善。这里,我们打算从实现的角度来看一看一门语言在其多态特性的背后做了些什么——知其所以然,使用时方能游刃有余。
或许你在学习一门语言的时候,曾经对多态的特性很迷惑,虽然教科书上所讲的非常简单,也非常明了——正如它的原本理念一样,但是你也想知道语言(编译器)在背后都干了些什么,为什么一个派生类对象就可以被当作其基类对象来使用?用指向派生类对象的基类指针调用虚函数时凭什么能够精确的到达正确的函数?类的内部是如何布局的?
我们这样考虑:假设语言不支持多态,而我们又必须实现多态,我们可以怎么做?
多态的雏形:
class B
{
public:
int flag; //为表示简洁,0代表基类,1代表派生类
void f(){cout<<”in B::f()”;} //非虚函数
};
class D:public B
{
public:
void f(){cout<<”in D::f()”;} //非虚函数
};
void call_virtual(B* pb)
{
if(pb->flag==0) //如果是基类,则直接调用f
pb->f(); //调用的是基类的f
else //如果是派生类,则强制转化为派生类指针再调用f
(D*)pb->f(); //调用的是派生类的f
}
这样,可以正好符合“根据具体的对象类型调用相应的函数”的理念。但是这个原始方案有一些缺点:;例如,分发“虚函数”的代码要自己书写,不够优雅,不具有可扩展性(当继承体系扩大时,这堆代码将变得臃肿无比),不具有封闭性(如果加入了一个新的派生类,则“虚函数”调用的代码必须作改动,然而如果恰巧这个调用是无法改动的(例如,库函数),则意味着,一个用户加入的派生类将无法兼容于那个库函数)等等。结果就是——这个方案不具有通用性。
但是,这个方案能够说明一些本质性的问题:flag数据成员用于标识对象所属的具体类型,从而调用者可以根据它来确定到底调用哪个函数。但是,可不可以不必“知道”对象的具体类型就能够调用正确的函数呢?可以,改进的方案如下:
class B
{
public:
void (*f)(); //函数指针,派生类对象可以通过给它重新赋值来改变对象的行为
};
class D:public B
{};
void call_virtual(B* pb)
{
(*(pb->f))(); //间接调用f所指的函数
}
void B_Mem()
{
cout<<”I am B”;
}
void D_Mem()
{
cout<<”I am D”;
}
int main()
{
B b;
b.f=&B_Mem; //B_Mem代表B的“虚函数”
D d;
d.f=&D_Mem; //以D_Mem来覆盖(override)B的虚函数
call_virtual(&b); //输出“I am B”
call_virtual(&d); //输出“I am D”
}
在这个改进的例子中,派生类对象可以通过修改函数指针f的指向,从而获得特定的行为,这里重要的是,call_virtual函数不再需要通过丑陋的if-else语句来判断对象的具体类型,而只是简单的通过一个指针来调用“虚函数”——这时候,如果派生类需要改变具体的行为,则可以将相应的函数指针指向它自己的函数即可,这招“偷梁换柱”通过增加一个间接层的办法“神不知鬼不觉”地将“虚函数”替换(Override)掉了。
然而,这招仍然还有缺点——要用户手动实现,可扩展性差,透明性差等等。然而,它的思想已经接近现代编译器对多态机制的实现手法了。
通过将上面的例子中的函数指针扩展为一个隐含的指针数组——虚函数表(vtbl)——C++拥有了我们现在所看到的多态能力。在虚函数表中,每一个虚函数指针占有一个表项,如果派生类覆盖(override)了相应的虚函数,则对应表项就改成指向派生类的那个虚函数的——这些工作由编译器完成——从而,如上例所示,用户不必知晓对象的确切类型,就能够触发其特定的行为(也就是说,调用“取决于对象具体类型”的成员函数),虚函数表对用户是完全透明的,用户只需要使用一个virtual关键字就能够轻松拥有强大的多态能力。
如果一个C++类中有虚函数,则该类将会拥有一个虚函数表(vtbl),并且,该类的对象中(一般在头部)有一个隐含的指向虚函数表的指针(vptr),下面是示范虚函数实现的示意图:
如下图:
<shapetype id="_x0000_t75" coordsize="21600,21600" o:spt="75" o:preferrelative="t" path="m@4@5l@4@11@9@11@9@5xe" filled="f" stroked="f"><stroke joinstyle="miter"></stroke><formulas><f eqn="if lineDrawn pixelLineWidth 0"></f><f eqn="sum @0 1 0"></f><f eqn="sum 0 0 @1"></f><f eqn="prod @2 1 2"></f><f eqn="prod @3 21600 pixelWidth"></f><f eqn="prod @3 21600 pixelHeight"></f><f eqn="sum @0 0 1"></f><f eqn="prod @6 1 2"></f><f eqn="prod @7 21600 pixelWidth"></f><f eqn="sum @8 21600 0"></f><f eqn="prod @7 21600 pixelHeight"></f><f eqn="sum @10 21600 0"></f></formulas><path o:extrusionok="f" gradientshapeok="t" o:connecttype="rect"></path><lock v:ext="edit" aspectratio="t"></lock></shapetype>
现在假设有如下代码:
void f(B* pb)
{
pb->f1();
}
则编译器为该函数生成的代码如下(以伪代码表示,以示明了):
void f(B* pb)
{
DWORD* __vptr=((DWORD*)pb)[0]; //获得虚函数表指针
void (B::*midd_pf)()=__vptr[offsetof_virtual_pf1];
//从表中获得相应虚函数指针
(pb->*midd_pf)(); //调用虚函数
}
这样一来,如果pb指向的是D对象,则获得的是指向D::f1的函数指针(参考上面的第二幅图),如果pb确实指向B对象,根据B对象内的vptr所指的虚函数表,获得的是指向B::f1的函数指针。
现在,关于C++的多态机制基本已经明了。剩下的就是多重继承下的虚函数表格局,大同小异,就不多说了。只不过,其中还是有一些微妙的细节的,可以参见《Inside C++ Object Model》(Lippman著)(中文名《深入C++对象模型》——侯捷译)。
关于C++虚函数调用机制还有一个细节——在构造函数中调用虚函数要千万小心,因为“在构造函数中”意味着“对象还没有构造完毕”,这时候虚函数调用机制很可能还没有启动,例如:
class B
{
B(){this->vf();} //调用B::vf
virtual void vf(){cout<<”in B::vf()/n”;
};
现在,不管B身为哪个类的基类,B的构造函数中调用的都是B::vf。细心的读者会发现:这是由于对象构造顺序的关系——C++明确规定,对象的“大厦”是“自底向上”构建的,也就是说,从最底层的基类开始构造,所以,在B中调用this->vf时,虽然this所指的对象确实(即将)是派生类对象,但是派生类对象的构建行为还没有开始,所以这次调用不可能跑到派生类的vf函数去,就好像第二层楼还没有建好,一层楼的人是无法跑到二楼去的一样。
说得更深一些,虚函数的调用是要经过虚函数指针和虚函数表来间接推导的,在B的构造函数中,编译器会插入一些代码,将对象头部的vptr设置为指向B的虚函数表的指针,于是this->vf的推导使用的是B的虚函数表,当然只能跑到B的vf那儿去。而后来,当B构建完毕,轮到派生类对象部分构造时,派生类的构造函数会将对象头部的vptr改成指向派生类的虚函数表的指针,这时候虚函数调用机制才算是Enable了,以后的this->vf将使用派生类虚函数表来推导,从而到达正确的函数。
.NET 对象模型
C++对象模型与.NET(或Java)有个主要的区别——C++支持多重继承,不支持接口,而.NET(或Java)支持接口,不支持多重继承。
而.NET的虚函数调用机制与C++也比较相似,只不过由于接口和JIT(即时编译)的介入而有一些不同。
在.NET中,每一个类都有一个对应的函数指针表(事实上,这个“表”是个数据结构,里面还有其它信息),与C++不同的是,该类的每个函数(不管是不是虚函数)都在其中对应一个表项。这是由于JIT(即时编译)的需要——对每个函数的调用都是间接的,都会经过该表推导一次,获得函数代码的地址。注意,第一次调用的时候,函数代码还是中间代码(.NET的中间语言MISL的代码),所以将会跳至即时编译器,编译这些代码并放到内存中,然后将表中的对应表项指向编译后的native code,以后的每次调用都会直接跳到编译后的代码。
以上只是想让你对.NET的“虚函数表”有个大体的认识。下面就来详细剖析。
如果没有接口,.NET的虚函数调用机制将是很单纯的——几乎与C++一样。只不过,接口加入以后就不同了——可以将对象引用转化为接口引用,然后再调用接口中的虚函数。所以,势必要对“虚函数表”作某种改动,例如,对于下面的继承结构:
public interface IFirst
{
void f1();
void f2();
}
public interface ISecond
{
void s1();
}
public class C:IFirst,Isecond
{
public override void f1(){}
public override void f2(){}
public override void s1(){}
public virtual void c1(){}
}
类型C的内存布局大体是这样的(由于.NET是单根的继承结构,每个类都隐式的继承自Object,所以,类型C的“虚函数表”中包含Object的所有成员函数,不过在图中我略去了相关部分):
在上图中可以看到,ObjRef指向一个对象,在对象顶部(除了用于同步的sync#块之外)是hType(可以看成对应于C++对象顶部的虚函数表指针),它所指的结构(CORINFO_CLASS_STRUCT,可以暂时将它看成虚函数表,尽管其中包含的信息不仅仅是虚函数指针)包含在C++中相当于虚函数表的部分,以及用于对象的运行时识别的信息。不同的是,在基于接口的.NET继承风格中,对接口的虚函数的分派是基于一个IOT(Interface Offset Table,即接口偏移表),图中的pIOT就是指向这样一个表,其中每一项都是一个偏移量,反指向该接口中的虚函数指针数组在CORINFO_CLASS_STRUCT中的位置。
这样,当基于接口的引用调用虚函数时,其背后的机制是:先根据接口引用取得该类所对应的CORINFO_CLASS_STRUCT结构的地址,然后在pIOT所指的接口偏移表中索引相应的虚函数指针数组的偏移量,最后经过指针间接调用虚函数。
可以看出,基于接口引用调用虚函数时要经过两个间接层,第一,在IOT中索引对应接口的虚函数指针数组的偏移量,第二,在虚函数指针数组中索引相应的虚函数指针,最后才是调用。但是,当基于对象引用调用虚函数时,只要经过一个间接层——就像在C++中一样——直接在虚函数表中索引对应虚函数指针,接着调用。
关于基于接口的引用调用虚函数,还有一个细节就是,IOT里为每一个接口都准备了一个表项(包括该类并没有实现的接口),原因是效率——.NET需要每个接口在IOT里都有一个固定的(或者说,编译期确定的)偏移量,这样,在为虚函数调用生成代码的时候才能够通过这个固定的偏移去查找某个接口的虚函数指针数组的所在。
另一方面,如果某个类的IOT仅仅包含它实现的接口,则经由接口引用去调用虚函数时,必须先知道该接口在IOT中的相应偏移,而这一信息必须通过运行期的动态查询才能够知道(因为编译器在手头只有一个接口引用的情况下不可能知道它指向的是哪个类对象,从而也就不知道该类到底实现了哪些接口,所以要求助于运行期的动态查询,而在前面所说的方式(也就是.NET所用的方式)下,编译器不用知道接口引用到底指向哪个类对象,因为在每个类的CORINFO_CLASS_STRUCT中的固定位置都有一个pIOT,指向一个IOT,其中每个接口都对应一个固定的(编译器知道的)表项)——显然,在每次调用虚函数之前都进行一次动态查询是不可容忍的效率损伤,所以.NET宁可让IOT多一些表项,以空间换时间。
或许你认为这过于复杂,但是这是必须的,.NET中的基于接口的继承对应于C++中的多重继承,后者的实现也有类似的复杂性——或许更复杂一些。
最后,要说明的是,本文对于一个纯粹的实用者或许显得多余,但是对于想把一门语言使用得更好的人却是有用的。知其然而知其所以然,才能够游刃有余。而其实现机理在实际运用中能起到抛砖引玉的作用也未可知。