前文回顧:《用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 ) // .z\V.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 } }