用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
    }
}
相关文章
相关标签/搜索