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方法。
运行结果如下:
现在使用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异常:
关于对象判等
修改代码如下:
可以看到,对“dog1==dog2”使用的是call。因为运算符重载是static方法,所以使用call是理所当然的。但这会出现一个容易被大家忽略的问题:call使用的是声明类型,即前期绑定。所以对于相同的两个对象,如果引用它们的变量声明类型不同,如上Dog、Animal和Object,那么将分别调用Dog、Animal和Object定义的“==”操作,所以才出现以上结果。
由此可见,在开发中,若在实例上执行“==”操作需要确定是否使用了正确的声明类型。