IL中call与callvirt的区别及其对Equal操作的影响

《CLR via C#》中是这样描述它们的:

编译器在生成方法时会在方法定义表中写入该方法的记录项,每个记录项中有一组标志指令方法是静态方法、实例方法还是虚方法,如下图:


生成IL代码时,编译器会根据这些标志,判断应如何生成IL代码(是使用call还是callvirt)。

call (静态/前期绑定)

该IL指令可调用静态方法、实例方法和虚方法。用call指令调用静态方法,必须指定方法的定义类型。用call指令调用实例方法或虚方法,必须指定引用了对象的变量。call指令假定该变量不为null(在用call指令调用实例和虚方法时,JIT会假定变量不为null,不会生成代码来验证该变量的值是否为null)。换言之,变量本身的类型指明了方法的定义类型。如果变量的类型没有定义该方法,就检查基类型来查找匹配方法。call指令经常用于以非虚方式调用虚方法(意思是如果子类重写的基类的方法,而变量是基类,那么调用的便是基类中该方法的实现,而不是子类中该方法的实现)。请看下面:

首先实现了两个类Animal,Dog继承自Animal并重写了GetName方法。


编译器生成如下IL代码:

运行结果如下:


现在使用ILSpy手动将callvirt修改为call:


运行结果如下:


可以看到call指令直接使用的变量声明时的类型Animal的GetName方法;而callvirt则会查找变量的实际类型Dog,执行重写后的GetName方法。这就是“以非虚方式调用虚方法”的含义。

callvirt(动态/后期绑定)


该IL指令可调用实例方法和虚方法,不能调用静态方法。用callvirt指令调用实例方法或虚方法,必须指定引用了对象的变量。用callvirt指令调用非虚方法,变量的类型指明了方法的定义类型(所以CLR不会去查找变量的实际类型,因为非虚方法无法被重写,所以只能在其定义的类型中,即变量的类型)。用callvirt指令调用虚方法时,CLR会查找变量引用的对象的实际类型,然后以多台方式调用方法。为了确定实际类型,发出调用的变量绝不能为null(如果为null,CLR就无法知道变量引用的对象的实际类型,因为无对象可引用)。所以,JIT编译器会生成代码来验证变量的值是否为null,如果是,会导致CLR抛出NullReferenceException异常。正是由于要进行这种额外的检查,callvirt指令的执行速度比call指令稍慢。注意,即使callvirt指令调用的是非虚方法,也要执行这种null检查

C#对非虚方法和虚方法的调用都是使用callvirt:


为什么对非虚方法的调用不使用call而使用callvirt呢?

答案是C#团队认为,JIT编译器应生成代码来验证调用的对象不为null。这意味着对非虚方法的调用要稍慢一点。这也意味着可能会抛出NullReferenceException异常。注意,这只是C#团队在生成IL时使用的callvirt指令,并不意味着其他IL生成工具也这样。

总结

可以看到call和callvirt都可以调用非虚方法和虚方法,两者的区别在于:

  • call指令假定变量引用的对象不为null,并且使用变量声明时的类型的方法定义(可能在基类中),而不会查找变量的实际类型,看看是否重写基类的虚方法。
  • callvirt指令始终要检查变量引用的对象是否为null,可能会抛出NullReferenceException异常。并且,在调用虚方法时,会查找变量引用的对象的实际类型,正确调用重写后的方法。
  •   call callvirt
    static 使用声明类型 未定义
    instance 假定不为null,使用声明类型 null检查,使用声明类型
    virtual instance 假定不为null,使用声明类型 null检查,使用实际类型

base.xxx


来看看当在重写的虚方法中调用基类同个的方法时会发生什么?

编译器生成的是call而不是callvirt。原因是如果虚方法使用callvirt会去查找实际的类型,所以base的实际类型是Dog,然后调用Dog的GetName方法,而Dog中的GetName又会去查找base的实际类型。这就陷入无限循环当中,导致栈溢出。所以只能使用call指令。


补充

以下使用call调用非虚方法和虚方法,变量设置为null,因为call假设对象不为null,且方法内部未使用this,所以不会出错。

animal和animalNull都设置为null:


将callvirt修改为call:


执行结果,没有抛出NullReferenceException异常:


关于对象判等

修改代码如下:

对同一对象的不同声明类型执行判等:
下面是生成的IL代码:

可以看到,对“dog1==dog2使用的是call。因为运算符重载是static方法,所以使用call是理所当然的。但这会出现一个容易被大家忽略的问题:call使用的是声明类型,即前期绑定。所以对于相同的两个对象,如果引用它们的变量声明类型不同,如上Dog、Animal和Object,那么将分别调用Dog、Animal和Object定义的“==”操作,所以才出现以上结果。

由此可见,在开发中,若在实例上执行“==”操作需要确定是否使用了正确的声明类型




猜你喜欢

转载自blog.csdn.net/paxhujing/article/details/79716000
今日推荐