前文回顾:《用CIL写程序系列》
前言:
最近的时间都奉献给了加班,距离上一篇文章也有半个多月了。不过在上一篇文章《用CIL写程序:定义一个叫“慕容小匹夫”的类》中,匹夫和各位看官一起用CIL语言定义了一个类,并且在实例化之后给各位拜了大年。但是那篇文章中,匹夫还是留下了一个小坑,那就是关于调用方法时,CIL究竟应该使用call呢还是应该使用callvirt呢?看上去是一个很肤浅的问题,哪个能让程序跑起来哪个就是好的嘛。不是有一句话:白猫黑猫,抓到耗子就是好猫嘛。不过其实这并不是一个很表面的问题,如果深入挖掘的确会有一些额外的收获,凡事都有因有果。那么匹夫就和各位一起去分析下这个话题背后的故事吧~~
一段“本应报错”的代码
虽然题目叫所谓的的用CIL写程序,但匹夫的目的其实并非是写CIL代码,而是通过写CIL代码来使各位对CIL的认识更加清晰,一个好脑瓜抵不过一个烂笔头嘛。所以写的都是.il作为后缀的文件,而没有写过.cs作为后缀的文件。不过为了响应上一篇文章中有园友建议加入ILGenerator的部分,匹夫决定就从本篇开篇引入一段使用了ILGenerator的代码。
// using System; using System.Reflection; using System.Reflection.Emit; public class Test1 { delegate void HelloDelegate(Murong murong); public static void Main(string[] args) { Murong murong = null;//注意murong是null哦~ Type[] helloArgs = {typeof(Murong)}; var hello = new DynamicMethod("Hello", typeof(void), helloArgs, typeof(Murong).Module); ILGenerator il = hello.GetILGenerator(256); il.Emit(OpCodes.Ldarg_0); var foo = typeof(Murong).GetMethod("Foo"); il.Emit(OpCodes.Call, foo); il.Emit(OpCodes.Ret); var print = (HelloDelegate)hello.CreateDelegate(typeof(HelloDelegate)); print(murong); } internal class Murong { //注意Foo不是静态方法额~ public void Foo() { Console.WriteLine("this == null is " + (this == null)); } } }
如果按照“理性的分析”,你要调用一个类中不是静态的方法,那你肯定要先拿到它的实例引用吧。也就是murong不能是null吧?否则就成了null.Foo(),按理说会报空指针的错误(NullReferenceException
)。可是呢?我们编译并且运行一下看看。
答案竟然是没有报错。而且的确调用到了Foo方法并且打印出了“this == null is True”。而且this的确是null,Murong这个类并没有被实例化。可Foo这个方法可是一个实例方法啊。实例是null怎么可能会调用的到它?
call到底是个什么鬼?为什么不检测实例到底是否为null就能直接调用方法呢?
下面让我们带着上文的疑问,再去看一段也很有趣的代码,同时收获新的的困惑。
虚函数的奇怪事
各位园友、看官想必对C#的虚函数是什么都十分熟悉,作为面向对象的语言,虚函数这个概念的存在是必要的,匹夫在此也就不再过多介绍了。
既然各位都熟悉C#的虚函数,那小匹夫在此直接使用CIL实现虚函数,想必各位也会十分快速的理解。那么好,在此匹夫会定义一个叫People的类作为基类,其中有一个介绍自己的虚方法。同时分别从People派生了两个类Murong和ChenJD,而且对其中介绍自己的方法做了如代码中的处理,一个使用在CIL的层面上未做处理(其实是省略了.override),另一个方法匹夫为它增加了newslot属性。
//如何用CIL声明一个类,请看小匹夫的上一篇文章《用CIL写程序:定义一个叫“慕容小匹夫”的类》 .class People { .method public void .ctor() { .maxstack 1 ldarg.0 //1.将实例的引用压栈 call instance void [mscorlib]System.Object::.ctor() //2.调用基类的构造函数 ret } .method public virtual void Introduce() { .maxstack 1 ldstr "我是People" call void [mscorlib]System.Console::WriteLine(string) ret } } .class Murong extends People { .method public void .ctor() { .maxstack 1 ldarg.0 //1.将实例的引用压栈 call instance void [mscorlib]System.Object::.ctor() //2.调用基类的构造函数 ret } .method public virtual void Introduce() { .maxstack 1 ldstr "我是慕容小匹夫" call void [mscorlib]System.Console::WriteLine(string) ret } } .class ChenJD extends People { .method public void .ctor() { .maxstack 1 ldarg.0 //1.将实例的引用压栈 call instance void [mscorlib]System.Object::.ctor() //2.调用基类的构造函数 ret } //此处使用newslot属性或者说标签,标识脱离了基类虚函数的那一套链,等同C#中的new .method public newslot virtual void Introduce() { .maxstack 1 ldstr "我是陈嘉栋" call void [mscorlib]System.Console::WriteLine(string) ret } }
在进行下文之前,匹夫还要先抛出一个概念,哦不,应该是2个概念。
编译时类型和运行时类型
为何要在此提出这2个概念呢?因为这和我们的方法调用息息相关。
举个c#的例子来说明这个问题:
public abstract class Singer { } public class Alin : Singer { } //刚看完我是歌手,喜欢alin... class Class1 { public static void Main(string[] args) { Singer a = new Alin(); } }
对编译器来说,变量的类型就是你声明它时的类型。在此,变量a的类型被定义为Singer。也就是说a的编译时类型是Singer。
但是别急,我们之后又实例化了一个Alin类型的实例,并且将这个实例的引用赋值给了变量a。这就是说,在这段程序运行的时候,编译阶段被定义为Singer类型的变量a所指向的是一块存储了类型Alin的实例的内存。换言之,此时的a的运行时类型是Alin。
那么编译时类型和运行时类型又和我们上面的CIL代码有什么关系呢?下面进入我们的PK阶段~
call vs callvirt
好了,到了这里,我们还是使用CIL代码来实现这个对比。
首先我们自然要声明3个局部变量来分别存储三个类的实例。
其次分别使用call和callvirt来调用方法。不过此处要先和各位看官说明一下,以防一会看的困惑。这里匹夫使用的CIL代码在做目的性很强的演示,所以不要使用日常写C#代码的思路来看下面的对比。此处匹夫首先会实例化3个变量,不过此时这3个变量是作为运行时类型存在的,之后匹夫会手动的使用call或callvirt来调用各个类的方法,所以此处匹夫手动调用的类的类型充当的是编译时类型。
.method static void Fanyou() { .entrypoint .maxstack 10 .locals init ( class People people, class Murong murong, class ChenJD chenjd) newobj instance void People::.ctor() stloc people newobj instance void Murong::.ctor() stloc murong newobj instance void ChenJD::.ctor() stloc chenjd //Peple //编译类型为People,运行时类型为People ldloc people call instance void People::Introduce() //Murong //编译类型为Murong,运行时类型为Murong,使用call ldloc murong call instance void Murong::Introduce() //编译类型为People,运行时类型为Murong,使用call ldloc murong call instance void People::Introduce() //编译类型为People,运行时类型为Murong,使用callvirt ldloc murong callvirt instance void People::Introduce() //ChenJD //编译类型为ChenJD,运行时类型为ChenJD,使用call ldloc chenjd callvirt instance void ChenJD::Introduce() //编译类型为People,运行时类型为ChenJD,使用call ldloc chenjd call instance void People::Introduce() //编译类型为People,运行时类型为ChenJD,使用callvirt ldloc chenjd callvirt instance void People::Introduce() ret }
好了,我们PK的擂台已经搭好了。如果有兴趣的话,各位此时就可以对照各个方法来猜一下输出的结果了。
不过在正式揭晓结局之前,匹夫还是先总结一下这个过程:People类作为基类,有一个虚函数Introduce用来介绍自己。然后Murong类派生自People,同时Murong类也有一个同名的虚函数Introduce,此时可以认为它重载了基类的同名方法。当然好事的匹夫为了对比的更加有趣,又定义了一个派生自People的ChenJD类,同样它也有一个同名的虚函数Introduce,唯一的不同是此时使用了newslot属性。
好啦,此时有了3个分别定义在3个类中的方法。那么问题就来了,我如何正确的让运行时知道我调用的是哪个方法呢?比如编译时类型是People,但是运行时类型却变成了Murong又或者编译时类型是People,但是运行时类型又变成了ChenJD,等等。显然,我想让People的实例去调用定义在People类中的方法,也就是People::Introduce();想让Murong的实例去调用定义在Murong类中的方法,也就是Murong::Introduce();想让ChenJD的实例去调用定义在ChenJD类中方法,也就是ChenJD::Introduce()。
带着这个问题,我们来揭晓上面那场PK的结果。
首先编译,之后运行,最后截图如下:
我们将代码和结果一一对应,可以发现凡是使用call调用方法的:
- call instance void People::Introduce() 输出:我是People,都调用了People中定义的Introduce方法
- call instance void Murong::Introduce() 输出:我是慕容小匹夫,都调用了Murong中定义的Introduce方法
而使用了callvirt来调用方法的:
- callvirt instance void People::Introduce() 输出:我是慕容小匹夫,调用了Murong中重载的Introduce版本。(murong)
- callvirt instance void People::Introduce() 输出:我是People,调用了基类People中原始定义的Introduce。(chenjd)
- callvirt instance void ChenJD::Introduce() 输出:我是陈嘉栋,调用了ChenJD中定义的Introduce。(chenjd)
不知道最后的结果是否和各位之前猜的一致呢?到此,其实我们已经可以得出一些有趣的结论了。那么匹夫就解释一下这个结果吧。
首先,我们聊聊call在这场PK中的表现。
在匹夫的代码中,首先使用call的是
//编译类型为People,运行时类型为People ldloc people call instance void People::Introduce()
此时,变量people的引用指向的是一个People的实例,所以调用People的Introduce方法自然而然的输出是“我是People”。
第二处使用call的是
ldloc murong call instance void Murong::Introduce() //编译类型为People,运行时类型为Murong,使用call ldloc murong call instance void People::Introduce()
这两处,变量murong都是Murong类的引用,首先使用call调用Murong::Introduce()方法,输出的是“我是慕容小匹夫”这点自然很好理解。但是之后使用call调用People::Introduce(),输出的却是“我是People”,要注意此时压入栈的变量murong可是一个Murong实例的引用啊。
第三处,也很雷同,变量的运行时类型是ChenJD,编译时类型是People,但是在程序运行时使用call,调用的仍然是编译时类型定义的方法。
可以看出,call对变量的运行时类型根本不感兴趣,而只对编译时类型的方法感兴趣。(当然上一篇文章中匹夫也说过,call还对静态方法感兴趣)。所以此处call只会调用变量编译时类型中定义的方法。
之后,我们再来看看callvirt的表现。
第一处使用callvirt的是
//编译类型为People,运行时类型为Murong,使用callvirt ldloc murong callvirt instance void People::Introduce()
此处使用callvirt去调用People::Introduce()方法,但是由于此处变量是murong,它指向的是一个Murong类的实例,因此最后的执行的是Murong类中的重载版本,输出的是“我是慕容小匹夫”。
第二处使用callvirt的是
//编译类型为ChenJD,运行时类型为ChenJD,使用call ldloc chenjd callvirt instance void ChenJD::Introduce() //编译类型为People,运行时类型为ChenJD,使用callvirt ldloc chenjd callvirt instance void People::Introduce()
由于ChenJD类中的同名方法使用了newslot属性,所以此处可以看到很明显的对比。使用callvirt去调用People::Introduce()时,执行的并非ChenJD中的Introduce版本,而是基类People中定义的原始Introduce方法。而使用callvirt再去调用ChenJD中的Introduce方法时,执行的自然就是ChenJD中定义的版本了。
这个其实涉及到了虚函数的设计,简单来说可以想象同一系列的虚函数(使用override关键字)存放在一个槽中(slot),在运行时会将没有使用newslot属性的虚函数放入这个槽中,在运行时需要调用虚函数时去这个槽中寻找到符合条件的虚函数执行,而这个槽是谁定义的呢或者说应该如何去定位正确的槽呢?不错,就是通过基类。
如果有兴趣,各位可以虚函数部分的C#代码编译成CIL代码,可以看到调用派生类重载的虚函数,在CIL中其实都是使用callvirt instance xxx baseclass::func 来实现的。
所以,使用了newslot属性的方法并没有放入基类定义的那个槽中,而是自己重新定义了一个新的槽,所以最后callvirt instance void People::Introduce()只能调用基类的原始版本了。
当然,如果有必要匹夫会更具体的写写虚函数的部分,不过现在有点晚了,为了节约时间还是只讨论call和callvirt。
因此,使用callvirt时,它关心的并不是变量定义时的类型是什么,而是变量最后是什么类的引用。也就是说callvirt关心的是变量的运行时类型,是变量真正指向的类型。
假如只有静态函数
看到此时,可能有的看官要抱怨了:匹夫,你说了这么半天怎么好像没有一点关于开篇提到那个本该报错的代码呢?
其实此言差矣,通过分析虚函数,我们发现了call原来只关心变量的编译时类型中定义的函数以及静态函数。如果我们更近一步,就会发现call其实是直接奔着它要调用的那个函数的代码就去了。
直接去执行目标函数中的代码,这样听上去是不是就和类型没有什么关系了呢?
如果,没有所谓的实例函数,只有静态函数,本文开头的问题是不是就有答案了呢?哎,真相也许就是这么简单。
假如所谓的实例函数仅仅是静态函数中传入了一个隐藏的参数“this”,是不是只用静态函数就能实现实例函数了呢?也就是说,当某种(此处我们假设是实例方法)方法把“this”作为参数,但是仍然是一个静态函数,此时使用call去调用它,但是它的参数“this”很不幸的是null,那么这种情况的确没有理由触发NullReferenceException
。
//注意Foo不是静态方法额~ public void Foo() { Console.WriteLine("this == null is " + (this == null)); } //如果它真的是静态函数。。。 public static void Foo(Murong _this) { this = _this; Console.WriteLine("this == null is " + (this == null)); }
到此,我们通过分析call 和 callvirt得出的最后一个有趣的结论:实例方法只不过是一个将“this”作为不可见参数的静态方法。
附录:
老规矩,本文的CIL代码如下:
.assembly extern mscorlib { .ver 4:0:0:0 .publickeytoken = (B7 7A 5C 56 19 34 E0 89 ) // .zV.4.. } .assembly 'HelloWorld' { } .method static void Fanyou() { .entrypoint .maxstack 10 .locals init ( class People people, class Murong murong, class ChenJD chenjd) newobj instance void People::.ctor() stloc people newobj instance void Murong::.ctor() stloc murong newobj instance void ChenJD::.ctor() stloc chenjd //编译类型为People,运行时类型为People ldloc people call instance void People::Introduce() //编译类型为Murong,运行时类型为Murong,使用call ldloc murong call instance void Murong::Introduce() //编译类型为People,运行时类型为Murong,使用call ldloc murong call instance void People::Introduce() //编译类型为People,运行时类型为Murong,使用callvirt ldloc murong callvirt instance void People::Introduce() //编译类型为ChenJD,运行时类型为ChenJD,使用call ldloc chenjd callvirt instance void ChenJD::Introduce() //编译类型为People,运行时类型为ChenJD,使用call ldloc chenjd call instance void People::Introduce() //编译类型为People,运行时类型为ChenJD,使用callvirt ldloc chenjd callvirt instance void People::Introduce() ret } //如何用CIL声明一个类,请看小匹夫的上一篇文章《用CIL写程序:定义一个叫“慕容小匹夫”的类》 .class People { .method public void .ctor() { .maxstack 1 ldarg.0 //1.将实例的引用压栈 call instance void [mscorlib]System.Object::.ctor() //2.调用基类的构造函数 ret } .method public virtual void Introduce() { .maxstack 1 ldstr "我是People" call void [mscorlib]System.Console::WriteLine(string) ret } } .class Murong extends People { .method public void .ctor() { .maxstack 1 ldarg.0 //1.将实例的引用压栈 call instance void [mscorlib]System.Object::.ctor() //2.调用基类的构造函数 ret } .method public virtual void Introduce() { .maxstack 1 ldstr "我是慕容小匹夫" call void [mscorlib]System.Console::WriteLine(string) ret } } .class ChenJD extends People { .method public void .ctor() { .maxstack 1 ldarg.0 //1.将实例的引用压栈 call instance void [mscorlib]System.Object::.ctor() //2.调用基类的构造函数 ret } //此处使用newslot属性或者说标签,标识脱离了基类虚函数的那一套链接,等同C#中的new .method public newslot virtual void Introduce() { .maxstack 1 ldstr "我是陈嘉栋" call void [mscorlib]System.Console::WriteLine(string) ret } }