我们知道CLR会为每个类型创建一个”方法表“。方法表的详细布局方式微软没有公开发布,因此只能依据自己的理解给出一个大致的方法表, 并不一定符合CLR实情。
类型方法表的前面是一些CLR用于管理类型的控制信息,紧接着是一个表明此类型的方法总数的表项(即图中的方法槽个数),类型用的方法被分为几部分依次排列,一般来说,虚方法都集中在表的前部,因为这些方法可能会以多态方式被调用。表的中部是类型定义的”普通实例方法“,后部是类型定义的静态成员(包括静态方法与静态字段)。
方法表的最后一部分是”接口偏移表“,这里面存放了本类型所实现的所有接口的清单列表。由于每个接口通常都会定义几个方法,因此接口偏移表中的表项有两部分组成:
第一部分是接口的方法表指针,它引用接口的方法表,所以利用这一指针CLR可以获取接口所定义的方法清单。
第二部分是一个索引。对于一个实现了特定接口的类型,它一定定义了对应的实例方法。这些方法被”捆“在一起,集中的放到类型方法表的特定区域中,此区域第一个方法在类型方法表中的索引被”记录“了下来,保存在接口偏移表与此接口相对应的表项中。
构成继承关系的方法表
class Parent
{
public void p() { }
}
class Child : Parent
{
public void c() { }
}
对应的内存布局如图
调用示例代码
Child c = new Child();
c.c(); //调用自身定义的方法
c.p(); //调用基类定义的方法
上述代码对应的IL指令如下:
可以看到编译时三条c#语句分别被绑定到了相应方法表的特定方法上。当CLR执行这三条IL指令时,将会根据要调用方法的标识到相应的方法表中去查找对应的方法。
当子类隐藏了父类的同名方法时,(比如上例中Child类用new关键字隐藏了父类的p方法),内存布局大同小异,只不过现在Child的方法表中多了一个p方法罢了。
测试代码如下:
Child c = new Child();
c.p(); //调用隐藏了基类定义的方法
(c as Parent).p(); //调用基类定义的方法
上述代码对应的IL指令如下:
可以看到c#编译器能够正确地判断出应该调用哪个方法,并生成了相应的callvirt指令。
使用继承的虚方法调用原理
调用虚方法时具体调用哪个方法不是在编译时确定的,而是在运行时根据对象的真实类型而定的。因此CLR对于虚方法调用采用了动态分派的方法。
如下测试代码:
class Parent
{
public virtual void virtualMtd() { }
}
class Child : Parent
{
public override void virtualMtd() { }
}
Parent p = new Parent();
p.virtualMtd(); //通过基类变量调用虚方法
Child c = new Child();
c.virtualMtd(); //通过子类变量调用虚方法
p = c;
p.virtualMtd(); //再次通过基类变量调用虚方法
编译生成程序集,使用ildasm工具查看生成的IL指令,可以看到:
可以看到虽然c#源程序中的三局代码不一样, 但是生成IL的指令语句都是一样的,这就是多态特性的关键所在。
通过往计算堆栈中压入不同的对象引用,三条一样的callvirt指令将调用不同类型的同名方法,这是虚方法调用的实质。
查看基类virtualMtd方法的元数据(在ildasm的视图->元数据->显示)
可以看到c#编译器为定义为virtual的方法添加了一个引人注目的”【NewSlot】“标记,这就意味着将在Parent类的方法表中新加一行。再看子类的virtualMtd方法的元数据
可以看到一个醒目的”【ReuseSlot】“标记,这说明子类类型的方法表中也有一个virtualMtd方法,而且它在方法表中的位置与基类中的virtualMtd方法在基类方法表中的位置一样。如下图
如图中所示,不管是基类还是子类,virtualMtd方法在方法表中的索引值都是5(Object类的ToString等方法占用方法表的前四行,构造函数又占用一行),从示例代码生成的IL指令可知虚方法调用时三条语句都生成以下IL指令:
IL_0008: callvirt instance void OnlyYou6.Parent::virtualMtd()
CLR在执行callvirt指令前,会把要调用的对象引用压入计算堆栈中,这一对象所关联的类型方法表将被用于查找真正调用的方法。
CLR执行callvirt指令时,它在Parent类型表查找virtualMtd方法,得到一个索引值5,然后它根据执行此方法的对象(前面压入计算堆栈的那个对象引用)的真实类型查对应的方法表(若当前对象为Child类型,则查找Child方法表),用前面得到的索引值5到方法表中找到应该调用的方法。(即Child::virtualMtd方法)
基于接口的虚方法调用
前面介绍的基于继承的虚方法调用原理也适用于接口多态的情况。c#中一个类只能有一个基类,但却可以实现多个接口,正是这个原因,使得基于接口的虚方法调用比基于继承的复杂,要用到接口的方法表和类的方法表。
示例代码如下:
namespace InterfaceVirutalMethodInvoke
{
interface IOne
{
void f1();
void f2();
}
interface IOther
{
void g();
}
class MyClass1 : IOne, IOther
{
void IOne.f1()
{
}
void IOne.f2()
{
}
void IOther.g()
{
}
//自己定义的其他方法
public void MyOtherMethod()
{
}
}
class MyClass2 : IOne, IOther
{
void IOne.f1()
{
}
void IOne.f2()
{
}
void IOther.g()
{
}
public void DoSomething()
{
}
}
class Program
{
static void Main(string[] args)
{
InvokeIOtherMethod(new MyClass1());
InvokeIOtherMethod(new MyClass2());
}
//基于IOther接口调用虚拟方法
static void InvokeIOtherMethod(IOther obj)
{
obj.g();
}
}
}
编译生成程序集,利用ildasm工具查看生成的IL指令
对比IL_0006和IL_0011两句,不难发现c#编译器生成的方法调用指令都是一样的,只是作为参数传入的对象类型不一样,第一个是MyClass1,第二个是MyClass2.
再看下InvokeIOtherMethod方法的IL指令代码
这意味着InvokeIOtherMethod方法的两次调用将会调用不同对象的g方法。
再查下IOther接口g方法的元数据:
可以看到接口中的方法都是公有的虚方法,而且必须在类型方法表的虚方法区域增加一行(NewSlot)。
对比”显示“实现IOther接口的MyClass1的g方法元数据:
可以看到原来接口中的公有方法变成了私有的虚方法,而且不允许继承,另外,同样有一个NewSlot标记
为了实现基于接口的虚方法调用,CLR采用了一种复杂的接口方法调用机制,以上文中的MyClass1为例
MyClass1实现了接口IOne, IOther,因此它的接口偏移表中有两个对应的表项,并且其索引值指明了实现两个接口的第一个实例方法的索引。比如MyClass1实现的IOne接口在方法表中的索引值是4,IOther为6。
当CLR执行InvokeIOtherMethod方法内的以下虚方法调用指令时:
IL_0002: callvirt instance void InterfaceVirutalMethodInvoke.IOther::g()
CLR先查IOther方法表,知道其中g方法的方法表索引值为0,再查调用此方法的对象MyClass1所对应的接口偏移表,知道IOther接口的对应方法在MyClass1方法表中的偏移为6,两者相加(6+0 = 6),CLR就知道MyClass1表的第7行(索引值从0开始)是真正需要调用的方法。
这个过程看上去相当复杂,所以相同的功能分别采用继承和接口多态两种方式实现,估计基于接口的多态代码会比基于继承的代码执行起来慢一些。