次调用的时候,函数代码还是中间代码(.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++中的多重继承,后者的实现也有类似的复杂性——或许更复杂一些。
最后,要说明的是,本文对于一个纯粹的实用者或许显得多余,但是对于想