用CIL寫程序:從「call vs callvirt」看方法調用

前文回顧:《用CIL寫程序系列》html

前言:

最近的時間都奉獻給了加班,距離上一篇文章也有半個多月了。不過在上一篇文章《用CIL寫程序:定義一個叫「慕容小匹夫」的類》中,匹夫和各位看官一塊兒用CIL語言定義了一個類,而且在實例化以後給各位拜了大年。可是那篇文章中,匹夫仍是留下了一個小坑,那就是關於調用方法時,CIL究竟應該使用call呢仍是應該使用callvirt呢?看上去是一個很膚淺的問題,哪一個能讓程序跑起來哪一個就是好的嘛。不是有一句話:白貓黑貓,抓到耗子就是好貓嘛。不過其實這並非一個很表面的問題,若是深刻挖掘的確會有一些額外的收穫,凡事都有因有果。那麼匹夫就和各位一塊兒去分析下這個話題背後的故事吧~~c#

一段「本應報錯」的代碼

雖然題目叫所謂的的用CIL寫程序,但匹夫的目的其實並不是是寫CIL代碼,而是經過寫CIL代碼來使各位對CIL的認識更加清晰,一個好腦瓜抵不過一個爛筆頭嘛。因此寫的都是.il做爲後綴的文件,而沒有寫過.cs做爲後綴的文件。不過爲了響應上一篇文章中有園友建議加入ILGenerator的部分,匹夫決定就從本篇開篇引入一段使用了ILGenerator的代碼。ide

//
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怎麼可能會調用的到它?post

call究竟是個什麼鬼?爲何不檢測實例究竟是否爲null就能直接調用方法呢?this

下面讓咱們帶着上文的疑問,再去看一段也頗有趣的代碼,同時收穫新的的困惑。url

虛函數的奇怪事

各位園友、看官想必對C#的虛函數是什麼都十分熟悉,做爲面向對象的語言,虛函數這個概念的存在是必要的,匹夫在此也就再也不過多介紹了。spa

既然各位都熟悉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
    }
}
相關文章
相關標籤/搜索