.NET虚方法与callvirt

前言

虚方法与多态,是学习.NET面向对象编程的基础知识,稍有基础的同学都已经熟练掌握,似乎没什么好讲。

不过,本文的重点也不在于讲解如何得到正确结果,而是希望借此话题将CIL层的call和callvirt指令说清楚。

试验代码

下面是本文使用的C#代码,使用.NET8.0框架,控制台程序,Debug编译模式。

namespace BasicGrammar
{
    class Program
    {
        static void Main()
        {
            Father father = new Child();
            father.SayHello("Hello ");
            father.NonVirtual();
        }
    }
    public class GrandFather
    {
        public void NonVirtual()
        {
            Console.WriteLine("Grand Father said: \"I'm not a virtual method.\"");
        }
        public virtual void SayHello(string msg)
        {
            Console.WriteLine(msg + $"from grand father.");
        }
    }


    public class Father : GrandFather
    {
        public void NonVirtual()
        {
            Console.WriteLine("Father said: \"I'm not a virtual method.\"");
        }
        public override void SayHello(string msg)
        {
            Console.WriteLine(msg + "from father.");
        }
    }

    public class Child : Father
    {
        public void NonVirtual()
        {
            Console.WriteLine("Child said: \"I'm not a virtual method.\"");
        }
        public override void SayHello(string msg)
        {
            base.SayHello(msg);
            Console.WriteLine(msg + "from child.");
        }
    }
}

这个示例程序首先定义了一个基类GrandFather,然后又定义了一个Father类继承自GrandFather,最后又定义了一个Child类继承自Father,也就是总计有2级继承。

GrandFather中定义了一个普通方法(非虚方法)NonVirtual(),又定义了一个虚方法virtual SayHello(string msg)。

在Father和Child中又分别定义了相同签名普通方法NonVirtual()并分别重写了虚方法SayHello(),以区分消息到底来自于哪个方法。

在Main()中,new了一个Child实例,并将该实例赋值给了Father型变量father,接下来调用虚方法father.SayHello()和普通方法father.NonVirtual()。毫无疑问,此题的关键就在于实例是Child,但赋值给了Father型变量,那么程序到底会执行哪个类中的定义。

为了巩固基础知识,还是建议读者先自己给出自己的答案,然后再和我马上要说的解题思路进行对比。

一、传统解释

刚学C#时,老师或者书籍大多这样介绍方法调用规则

调用普通实例方法(非虚方法)时,实际调用的是编译器类型中定义的方法;
调用虚方法时,实际调用的是运行时类型定义的方法。

什么是编译器类型?什么是运行时类型?下面先通过Main()注释进行解释,接下来再给出定义:

        static void Main()
        {
            Father father = new Child(); //初始化一个孙类型成员,赋值给其父类型变量
            father.SayHello("Hello ");   //使用父类型变量调用虚方法SayHello()
            father.NonVirtual();         //调用普通实例方法
        }

编译器类型
所谓编译器类型,这里指的是C#编译器,而非CLR的即时编译器Jitter,也就是csc.exe。与绝大多数编译器一致,csc是无表达式记忆编译器,也就是说,编译器是一个语句一个语句进行编译的,编译过程中只关心当前语句,不会记忆上一条语句的语义。比如编译Father father = new Child()这条语句时,编译器会先emit定义变量定义语句Father father,然后emit调用构造方法的指令new Child(),最后再将new Child的返回值赋值给father变量。很明显,我们程序员此时已经知道,虽然father是Father类型变量,但其引用的对象实际是Child型的。不过,当csc编译father.SayHello("Hello ")时,它已经忘记了上一条语句给father赋值了Child,它此时只知道father是Father类型。所以,所谓编译器类型,在我们的示例中,就是变量定义的类型。

运行时类型
当程序实际运行的时候,father变量指向的对象实际装的是Child,这个Child就是运行时类型。

简言之,Father father = new Child() 这条语句对应的编译时类型是Father,运行时类型是Child。

两种类型的概念清楚了以后,根据上述方法调用规则,自然可以给出示例问题的正确答案:

Hello from father.
Hello from child.
Father said: “I’m not a virtual method.”

这一节算是普通C#知识的复习,接下来开始新课:剖析CIL层面的方法调用。

二、Main方法的CIL表示

本节是为无CIL语言基础的同学准备的,详细说明了Main()的CIL表示的每一个出现的单词。如果有CIL语言基础,只需看一眼代码,然后就直接跳到下一节。

我们用反编译工具ILSPy来观察以上代码中Main方法的CIL表示,如下图所示:
Maim方法对应的CIL代码

下面我们对这段代码做详细说明。

1.1.1 方法头

CIL的方法定义与C#类似,也分为方法头和方法体。其语法格式为:

<method_def> ::=
.method <call_conv> <ret_type> (<arg_list>) < impl> {
<method_body> }

对应我们的示例,方法头就是这个部分:

.method private hidebysig static void Main () cil managed

.method private hidebysig static
void Main () cil managed { // 方法体 … }上面贴图整段代码就定义了一个Main方法。CIL方法定义的语法如下

  1. 所有方法定义都必须以 .method作为起始;
  2. private, hidebysig对应着语法的flags部分,定义了方法的属性信息;
  3. 语法中的call_conv是调用约定。CIL规定,如果call_conv被省略,则使用缺省值instance。本示例中调用约定是static,也就是说Main方法需要使用类型方法模式调用;
  4. 语法中的ret_type是返回类型。我们示例中Main无返回值,所以是void型(注意返回类型不可省略,void型也是一种类型);
  5. 与法中的name是方法名字,在我们的示例中自然就是Main;
  6. 语法中括号内的arg_list是指参数列表,因为我们的Main方法定义时未提供形参,所以这里arg_list也是空;
  7. 语法中impl是implement(实现)的简写,本示例中是cil managed,说明这个方法是CIL表示的托管代码(运行时需要CLR托管)。

注解1: 之所以说instance或static是call_conv而不是flag,是因为instance方法调用需要额外传一个参数(this指针),而static方法则不需要。对C语言熟悉的同学一定清楚常见的_cdecl, _stdcall, _fastcall等调用约定。

注解2: 以上关键字中,对CIL不了解的同学会对hidebysig比较陌生。这个关键字的含义是:本方法将隐藏祖先类型中的相同签名的方法。我未查到hidebysig是哪几个词的简写,但猜想大致应当是hide ancestor’s methods by same signature。其实就相当于在C#中使用new来定义一个全新方法,如果new出来的新方法与祖先方法存在名称及签名冲突,那么新方法将隐藏祖先方法。

1.1.2方法体

大括号括起来的部分就是CIL的方法体。首先映入眼帘的是三条注释(CIL注释语法与C语言语法相同):

// Method begins at RVA 0x2050
// Header size: 12
// Code size: 27 (0x1b)

第一条是说Main方法开始于相对虚拟地址(RVA = Relative Virtual Address)0x2050处。理解RVA需要读有关PE/COFF文件格式相关资料,这里不做进一步讲解,但可以简单理解为内存首地址。

第二条Header size: 12是说方法头占用了12个字节。
.NET程序中,每个方法都会自动包含一个方法头。方法头的数据结构有两种,一种称为瘦方法头(Tiny Header),另一种称为胖方法头(Fat Header)。

瘦方法头只有一个字节,该字节最低两位固定为二进制10,高6位代表IL代码占用的总字节数。当一个方法满足以下条件时,就会被编译成瘦方法头:

  1. 无局部变量;
  2. 无异常(Exception)处理代码;
  3. maxstack不超过8;
  4. IL代码占用总字节数小于64。

只要有任何一项要求不能满足,则必须使用胖方法头。
胖方法头固定为12字节。胖方法头的功能与瘦方法头类似,不过数据结构复杂了一些,这里不做进一步说明。

在本示例中,Header size是12,所以是胖方法头,原因也在于本方法(Main)中有局部变量father。

另外,第一条注释说Main方法开始于RVA为2050的地址,但实际第一条CIL代码(nop)开始于205C,其中的差2050~205B恰好就是12个字节的胖方法头(本文后面PE文件截图中可以看到该方法头)。

第三条注释Code size 27是说CIL代码总长度是27个字节。

注意:这里说的方法头是指被编译后的dll文件中的数据结构,与1.1.1节介绍的CIL方法头不是一个概念。

接下来是三个directive(所有使用半角句点开始的指示,包括前面介绍的.method,都是供CIL编译器ILasm.exe使用的directive,而不是CPU要执行的指令instruction。不清楚directive和instruction区别的,请看我写的上一篇文章)。

.maxstack 2
.entrypoint
.locals init ( [0] class BasicGrammar.Father father )

.maxstack 2是告诉ILasm为该方法预留两个槽的栈空间。注意,CIL的栈是以槽(slot)为单位的,既不是字节也不是字或双字。每个槽保存一个引用,针对64位应用程序是8字节,针对32位应用程序是4字节。

.entrypoint 表明这个方法是本程序集的入口方法。毫无疑问,C#中的Main方法自然就是入口方法。不过CIL并未规定入口方法名称必须是Main,也未规定方法必须放到类中,CIL可以存在全局方法的,但C#则不可以。

.locals init ( [0] class BasicGrammar.Father father ) 这个directive告诉ILasm编译器定义一个Father类型内部变量father并用默认值(null)对其进行初始化。[0]代表这个变量在后续程序中可以使用loc.0来表示该变量。

至此,directive介绍完毕。接下来是和程序执行相关的指令(instuction),最后都会被CLR的Jitter翻译成CPU指令:

/* 0x0000205C 00 / IL_0000: nop
// Father father = new Child();
/
0x0000205D 730B000006 / IL_0001: newobj instance void BasicGrammar.Child::.ctor()
/
0x00002062 0A */ IL_0006: stloc.0

上面列出了三条CIL语句, 每条语句都包含了注释(可以设置ILSpy是否生成注释和地址偏移)。用//扩起来的注释包括两部分,前面是RVA,后面是CIL指令的二进制字节码。

注释后面紧跟的是形如IL_xxxx格式的行标号,也是ILSpy给加上去的。标号并非必须,除非被跳转语句引用,否则可以删除。

第一条CIL语句是nop,其二进制字节码是00。nop是空操作,无实际意义,可以删除,一般用于字节对齐或未来预留占位。

// Father father = new Child()。以双斜杠开始的注释是C#源程序语句,也是ILSpy自动为我们添加的,也可以设置是否显示该注释。这条注释说明,下面的两个CIL语句对应了这条C#语句。你一定奇怪ILSpy是如何知道我们的源代码的。原因就在于我们是以调试(Debug)模式编译的C#程序,Debug模式编译时,默认会生成调试符号文件*.pdb,而该文件的绝对路径又被保存到了dll文件中。

newobj instance void BasicGrammar.Child::.ctor() newobj是一条创建新实例的CIL指令,相当于C#的new,其后面需要跟一个类型构造方法,这里是BasicGramm.Child类的构造方法.ctor(有关.ctor请参考我前一篇博客)。该构造方法会在堆区生成一个Child对象,并将该对象的引用存放到求值栈(Evaluation Stack)上。

stloc.0前面说过,loc.0代表第0个局部变量,也就是.locals init ( [0] class BasicGrammar.Father father ),其前面的st是store的简写,含义就是store loc.o,也就是将求值栈顶的Child类实例的引用Pop到0号局部变量中。

接下来的father.SayHello("Hello ")对应了3条CIL语句:

// father.SayHello("Hello ");
/* 0x00002063 06 / IL_0007: ldloc.0
/
0x00002064 7201000070 / IL_0008: ldstr "Hello "
/
0x00002069 6F04000006 */ IL_000d: callvirt instance void BasicGrammar.GrandFather::SayHello(string)

ldloc.0与stloc.0相反,其中ld是load的简写,ldloc.0就是将loc.0变量push到求值栈上,作为后续子程序调用的第一个参数(this指针)。

**ldstr “hello”**是将Hello字符串保存到堆栈上(实际情况要更复杂些,在dll中有一个数据结构称为UserString Heap,其中保存了所有用户代码定义的字符串,且每个字符串有一个token,ldstr实际上是将字符串token作为参数提供,比如我们的示例中,70000001就是Hello字符串的token)。

callvirt instance void BasicGrammar.GrandFather::SayHello(string) 这条语句使用callvirt来调用GrandFather类的SayHello方法,而前面两次压栈的数据都是SayHello方法的调用参数,也就是说father.SayHello(“Hello”)语法,翻译成CIL时会将father转换为方法的第一个参数。

/* 0x0000206E 00 / IL_0012: nop
/
0x0000206F 06 / IL_0013: ldloc.0
/
0x00002070 6F06000006 */ IL_0014: callvirt instance void BasicGrammar.Father::NonVirtual()

这三行代码对应的是C#指令father.NonVirtual();,也就是调用father的非虚实例方法。第一条是nop,第二条是提供this指针作为方法第一个参数,最后使用CIL的callvirt对NonVirtual方法进行调用。

最后两行代码是Main执行完毕,返回操作系统进程。唯一需要注意的就是ret之前,栈stack除了返回值以外必须是空的。

/* 0x00002075 00 / IL_0019: nop
/
0x00002076 2A */ IL_001a: ret

这一节,我们详细讲解了Main方法的CIL表示,目的是为了让不熟悉CIL的同学丢掉恐惧,树立起学会CIL的信心。

接下来我们提供一道加餐,先说一说类与PE文件格式。

三、PE文件一瞥

编程不仅仅是一门学问,它同时也是一门艺术。你平时编写C#代码所看到的类,其实经过了微软大师的艺术加工,真实的类隐藏在BasicGrammar.dll里,其实是一大片看了让人发疯的二进制编码。下面的截图,是我用一款称为010Editor的十六进制编辑软件打开我们的示例程序dll的样子:
BasicGrammar.dll
上面的截图中,从地址025C开始的27个字节,与我们曾经介绍的Main方法CIL字节码数据完全一致,为了对比,我将CIL截图也重新贴一下,大家比较一下看看:

00 73 0B 00 00 06 0A 06 72 01 00 00 70 6F 04 00 00 06 00 06 6F 06 00 00 06 00 2A

Main in CIL
由此我们可以直观感受到,CIL代码就在dll文件中。另外请注意,前面我们提到过12位的胖方法头,对应010 Editor截图中就是0250到025B这12个字节。

.NET的PE文件中除了有CIL代码以外,还包含metadata,也就是数据的数据。

比如下面这条语句,为什么其指令码是6F04000006?

/* 0x00002069 6F04000006 */ IL_000d: callvirt instance void BasicGrammar.GrandFather::SayHello(string)

学过汇编的都知道,汇编指令助记符一定对应一个唯一的CPU指令码。与此此类,callvirt指令也一定会对应一条CIL字节码。您猜对了,上面的字节码第一个字节6F就是callvirt指令,可参考微软官网
callvirt
其后面的4个字节04 00 00 06则对应着void BasicGrammar.GrandFather::SayHello(string)方法,具体可以使用010 Editor查,也可以用ILSpy查,如下图所示:
Method Table
从上图可以看出,SayHello方法的Token是06000004,恰恰是04 00 00 06逆序结果。之所以逆序,是因为Intel CPU是内存小端序架构,低位字节存储在低位内存地址。

另外,上图中Attributes其实是掩码,针对的是方法定义语法中的flags和call_conv,比如上面SayHello方法的000001C6就是四个掩码的和:

  • 0x0006代表public
  • 0x0100代表newslot,也就表明这个SayHello是本虚方法的根节点;
  • 0x0040代表虚方法
  • 0x0080代表同HideBySig。

将这四个掩码相加,就得到了000001C6。

上面介绍的就是metadata的一部分,称为MethodDef,也就是方法定义元数据。除了MethodDef,.NET PE文件的metadata还包括Module, TypeDef, TypeRef, Field, Param, MemberRef, Property, Manifest等metadata。下面再举一TypeDef的例子:

在这里插入图片描述
在这份截图中,Name栏列出了本程序集中所有自己定义的Type类型,且后面MethodList中列出了每个Type包含的Method的起始token。举例来说,从上表第3行GrandFather类中我们看到,其MethodList从06000003号token开始,到06000005结束(因为从06000006开始是Father类的方法了)。针对我们上面说的06000004,可以确定它是GrandFather类的SayHello方法,而不是其他类的SayHello。

PE文件格式的设计十分精妙,深刻理解了该文件结构以后,不仅更容易理解C#编译器,而且也有助于理解CLR,因为该Image文件恰恰是承上启下的中介。当然限于篇幅,本文只能抛砖引玉稍作介绍,更多细节还需要读者自己去学习了。推荐一本权威资料,就是ECMA-335标准。

四、 call与callvirt的困惑

在CIL中,调用其他方法的指令有三个,其中call和callvirt最常见。在Main方法中,我们只见到了callvirt。不过如果用ILSpy查看一下Child类型定义,就会发现无论是NonVirtual()、SayHell()还是构造方法.ctor(),都使用了call指令:

.class public auto ansi beforefieldinit BasicGrammar.Child
	extends BasicGrammar.Father
{
	// Methods
	.method public hidebysig 
		instance void NonVirtual () cil managed 
	{
		// Method begins at RVA 0x20d6
		// Header size: 1
		// Code size: 13 (0xd)
		.maxstack 8

		// {
		/* 0x000020D7 00                 */ IL_0000: nop
		// Console.WriteLine("Child said: \"I'm not a virtual method.\"");
		/* 0x000020D8 72FF000070         */ IL_0001: ldstr "Child said: \"I'm not a virtual method.\""
		/* 0x000020DD 280E00000A         */ IL_0006: call void [System.Console]System.Console::WriteLine(string)
		// }
		/* 0x000020E2 00                 */ IL_000b: nop
		/* 0x000020E3 2A                 */ IL_000c: ret
	} // end of method Child::NonVirtual

	.method public hidebysig virtual 
		instance void SayHello (
			string msg
		) cil managed 
	{
		.custom instance void [System.Runtime]System.Runtime.CompilerServices.NullableContextAttribute::.ctor(uint8) = (
			01 00 01 00 00
		)
		// Method begins at RVA 0x20e4
		// Header size: 1
		// Code size: 27 (0x1b)
		.maxstack 8

		// {
		/* 0x000020E5 00                 */ IL_0000: nop
		// base.SayHello(msg);
		/* 0x000020E6 02                 */ IL_0001: ldarg.0
		/* 0x000020E7 03                 */ IL_0002: ldarg.1
		/* 0x000020E8 2807000006         */ IL_0003: call instance void BasicGrammar.Father::SayHello(string)
		// Console.WriteLine(msg + "from child.");
		/* 0x000020ED 00                 */ IL_0008: nop
		/* 0x000020EE 03                 */ IL_0009: ldarg.1
		/* 0x000020EF 724F010070         */ IL_000a: ldstr "from child."
		/* 0x000020F4 280F00000A         */ IL_000f: call string [System.Runtime]System.String::Concat(string, string)
		/* 0x000020F9 280E00000A         */ IL_0014: call void [System.Console]System.Console::WriteLine(string)
		// }
		/* 0x000020FE 00                 */ IL_0019: nop
		/* 0x000020FF 2A                 */ IL_001a: ret
	} // end of method Child::SayHello

	.method public hidebysig specialname rtspecialname 
		instance void .ctor () cil managed 
	{
		// Method begins at RVA 0x2100
		// Header size: 1
		// Code size: 8 (0x8)
		.maxstack 8

		// {
		/* 0x00002101 02                 */ IL_0000: ldarg.0
		// (no C# code)
		/* 0x00002102 2808000006         */ IL_0001: call instance void BasicGrammar.Father::.ctor()
		// }
		/* 0x00002107 00                 */ IL_0006: nop
		/* 0x00002108 2A                 */ IL_0007: ret
	} // end of method Child::.ctor

} // end of class BasicGrammar.Child

call和callvirt到底有什么差异呢?

在ECMA-335标准的12.4.1.2 Calling Instructions 部分有如下描述:

The CIL has three call instructions that are used to transfer new argument values to a destination method. Under normal circumstances, the called method will terminate and return control to the calling method.

  • call is designed to be used when the destination address is fixed at the time the CIL is linked. In this case, a method reference is placed directly in the instruction. This is comparable to a direct call to a static function in C. It may be used to call static or instance methods or the (statically known) superclass method within an instance method body.
  • calli is designed for use when the destination address is calculated at run time. A method pointer is passed on the stack and the instruction contains only the call site description.
  • callvirt, part of the CIL common type system instruction set, uses the class of an object (known only at runtime) to determine the method to be called. The instruction includes a method reference, but the particular method isn’t computed until the call actually occurs. This allows an instance of a subclass to be supplied and the method appropriate for that subclass to be invoked. The callvirt instruction is used both for instance methods and methods on interfaces.

CIL主要有三种call指令,其中第二种calli一般用于和非托管代码打交道,也就是用于函数指针,这里不做展开。对普通C#程序员来说,最难于把握的,还是call和callvirt的区别。

虽然ECMA标准的用词非常严密,不过作为标准不可能做详细举例,读完以后,依旧会一头雾水。比如按上述英文描述,似乎callvirt更适合用于虚方法调用,而call更适合用于非虚方法的调用。不过我们这次提供的示例打破了这个认知,因为主方法Main调用NonVirtual方法时,也是使用的callvirt,而Child类SayHello方法中调用Father类虚方法SayHello时反而使用了call指令。

另外,不知大家是否注意到,Main方法中调用SayHello时,CIL代码是callvirt instance void BasicGrammer.GrandFather::SayHello(string),而不是callvirt instance void BasicGrammer.Father::SayHello(string)或callvirt instance void BasicGrammer.Child::SayHello(string)。这看起来是非常奇怪的,因为C#中是代码是 father.SayHello(),father变量定义是Father类型,而其引用的是Child实例,无论如何都不该在CIL中出现GrandFather类型。

本节只提出问题,目的是引起读者对问题的关注。接下来正式开始知识讲解。

五、揭示谜底

先解释一个问题:为什么C/C++和汇编语言都只有call,而没有callvirt,偏偏CIL出现了一个callvirt?是软件理论的进步吗?

答案其实是否定的。CIL之所以出现了callvirt,是和CIL运行时还需要被即时编译器Jitter再编译一遍才能得到CPU代码。由于虚方法与普通方法的调用机制是不一样的,所以作为中间语言的CIL必须为Jitter提供区别。与此不同的是,C/C++及汇编语言都只需一步编译就可以将源程序转换成机器语言指令,所以编译期间就可以确定被调用方法的相对地址。

另外需要澄清一点,当我们需要实例化一个含有方法的类型(比如Child类)时,无论我们new了多少个实例,其实每一次new出来的实例中都不包含方法代码,所有方法都是绑定到类型上的,一个类型只有一套内存代码存在,所有的实例都是通过实例中Type Link(指向Method Table数据结构)共享来引用各种方法的。在操作系统加载器加载完dll文件并将控制权交给clr以后,clr会在初始化期间在内存中为每个类生成一份方法表和虚方法表。

call指令

call指令比较简单:比如下面的两条语句,其中loc.0保存着前文所述的Father型的变量father,而father变量引用的真实类型是Child。

ldloc.0
call instance void BasicGrammar.Father::NonVirtual()

由于C#会直接使用Father类的NonVirtual方法token生成CIL二进制字节码,所以CLR会到loc.0参数的Method table中直接查找instance void BasicGrammar.Father::NonVirtual()方法。由于该方法是在Father类中定义的,所以直接查询一定找不到,因此CLR会进一步查找其父类Father,结果找到了该方法并获得了运行时地址,于是会使用硬编码将该call指令翻译成对应的机器指令并执行。

callvirt指令

针对callvirt指令,比如:

ldloc.0
ldstr "Hello "
callvirt instance void BasicGrammar.GrandFather::SayHello()

当CLR遇到callvirt指令时:

  1. 生成一段代码检查this指针是否为null,如果为null会抛出异常;
  2. 检查MethodDef表的Attributes字段,判断instance void BasicGrammar.GrandFather::SayHello()是不是virtual方法,如果不是,则按call指令方法处理,直接硬编码被调用地址;如果是virtual方法,则到第一条压栈指令ldloc.0压入的实例中的Type Link索引的V-Table中去查有没有instance void BasicGrammar.GrandFather::SayHello()这个方法。因为实例中V-Table首先会复制父类的V-Table,然后如果遇到override才会将对应的V-Table地址修改为override之后的地址,所以一定可以在loc.0对应的Child实例中找到该方法,因此就会直接运行Child类中override的方法。另外由于在Child的SayHello方法中调用了父类的SayHello,所以最终输出是:

Hello from father.
Hello from child.

六、最后的答疑

上一节基本说清了call和callvirt的区别,但还有最后3个问题需要澄清。

**问题1:**为什么C#不用call而是用callvirt调用普通实例方法(如NonVirtual)? 答案是:callvirt比call相对来说安全些,因为callvirt会检查传入的this指针是否为null,而call不会检查。所以如果使用call调用,且方法中又使用了this指针,则可能引发严重的问题。因此C#团队最终决定使用callvirt来调用所有实例方法,除非有特殊情况。不过callvirt因为涉及到检查this指针,所以效率上会比call稍差些。所以C#编译器选择使用callvirt未必就一定是最好的,只能说是一种取舍。而且,其他语言也未必采用同样策略。

问题2: 为什么Child中调用父类的SayHello时使用了call去调用虚方法?如果您看懂了我上面对CLR处理callvirt流程的描述,就应该知道,在子类中如果callvirt父类的虚方法,那么传入的this指针一定是子类的,于是会自动调用子类的SayHello,如此就导致了循环调用,线程栈很快就会被塞满。这就是问题1中提到的“特殊情况”的一种。所以在这种情况下,C#就直接生成了call指令,而不是callvirt指令。

问题3: 为什么调用SayHello时使用的是GrandFather类?这是因为C#编译器编译代码时,知道SayHello这个虚方法是从GrandFather类开始定义的,其虚方法槽对应的就是GrandFather类的SayHello。后续子类无论经历了多少次override,其实都是override了GrandFather的SayHello。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值