为什么C#编译器会在IL中产生方法调用来调用BaseClass方法

12

假设我们有以下C#示例代码:

class BaseClass
  {
    public virtual void HelloWorld()
    {
      Console.WriteLine("Hello Tarik");
    }
  }

  class DerivedClass : BaseClass
  {
    public override void HelloWorld()
    {
      base.HelloWorld();
    }
  }

  class Program
  {
    static void Main(string[] args)
    {
      DerivedClass derived = new DerivedClass();
      derived.HelloWorld();
    }
  }

当我运行以下代码:

.method private hidebysig static void  Main(string[] args) cil managed
{
  .entrypoint
  // Code size       15 (0xf)
  .maxstack  1
  .locals init ([0] class EnumReflection.DerivedClass derived)
  IL_0000:  nop
  IL_0001:  newobj     instance void EnumReflection.DerivedClass::.ctor()
  IL_0006:  stloc.0
  IL_0007:  ldloc.0
  IL_0008:  callvirt   instance void EnumReflection.BaseClass::HelloWorld()
  IL_000d:  nop
  IL_000e:  ret
} // end of method Program::Main
然而,csc.exe将 derived.HelloWorld(); 转换为 callvirt instance void EnumReflection.BaseClass::HelloWorld()。为什么会这样呢?在 Main 方法中没有提到 BaseClass。
而且如果它调用的是 BaseClass::HelloWorld(),我期望看到的是 call 而不是 callvirt,因为它看起来是直接调用 BaseClass::HelloWorld() 方法。

2
只是随便提出这个想法,但我不确定 - 这可能是编译器优化,因为您的方法只是调用基本实现。 - payo
@payo 当然不是所有语言都一样。问题是关于C#的,所以我在这个背景下回答它。 - phoog
@phoog 只是想指出这不是一般的多态规则,因为有些读者可能会认为这是通用的。我认为你的答案应该明确指出这是特定于C#的。例如,C++对象的每个vtable都会初始化一个指针(如果是mfst)或跳转(如果是gcc)用于每个虚拟方法 - 你根本不需要从基类调用。 - payo
2
@payo,我加了“在C#中”的内容,所以现在回答是“在C#中虚拟分派的工作方式……”。感谢您指出的歧义。 - phoog
1
@phoog 干得好 :) 我为此点赞了你的回答。 - payo
显示剩余2条评论
3个回答

20

因为BaseClass是定义此方法的类,所以该调用转到BaseClass::HelloWorld。在C#中,虚拟分派的工作方式是在基类上调用方法,虚拟分派系统负责确保调用方法的最派生重写版。

Eric Lippert的回答非常详尽:https://dev59.com/DFXTa4cB1Zd3GeqPzjlb#5308369

他关于此主题的博客系列也非常有价值:http://blogs.msdn.com/b/ericlippert/archive/tags/virtual+dispatch/

你有任何想法,为什么要这样实现吗?如果直接调用派生类的ToString方法会发生什么?刚开始看,这种方式对我来说没有太多意义...

之所以要这样实现,是因为编译器只跟踪对象的编译时类型而不是运行时类型。对于您发布的代码,很容易看出调用将转到DerivedClass方法的实现。但是假设derived变量是这样初始化的:

Derived derived = GetDerived();

GetDerived()可能返回StillMoreDerived的实例。如果StillMoreDerived(或继承链中DerivedStillMoreDerived之间的任何类)覆盖该方法,则调用Derived方法的实现将不正确。

通过静态分析找到变量可能持有的所有值是解决停机问题的。对于.NET程序集来说,情况甚至更糟,因为程序集可能不是一个完整的程序。因此,编译器能够合理证明derived不持有更高级别对象(或空引用)的情况的数量会很少。

要添加这个逻辑,以便它可以发出call指令而不是callvirt指令,需要花费多少钱?毫无疑问,成本会比获得的小利益高得多。


这是否意味着,换句话说,他真正看到的是多态性在起作用?IL 是以这种方式处理的,以便在运行时调用适当的重写方法? - Brad Rem
@BradRem 这正是它的意思。 - phoog
@BradRem 这正是C#处理多态性的含义(我现在也学到了)。 - payo
你有任何想法为什么要这样实现吗?如果直接调用派生类的ToString方法会发生什么?这种方式乍一看对我来说没有太多意义... - Tarik

9
这个问题的思路是,虚方法定义了可以在运行时放置方法的“插槽”。当我们发出 callvirt 指令时,就是在说:“在运行时查看这个插槽中有什么并调用它”。
该插槽的标识符是声明虚方法的类型的方法信息,而不是覆盖它的类型。
向派生方法发出 callvirt 是完全合法的;运行时会意识到派生方法与基方法是同一个插槽,结果完全相同。但永远没有任何理由这样做。如果通过识别声明该插槽的类型来标识插槽,那么更清晰。

1
请注意,即使您将DerivedClass声明为sealed,也会发生这种情况。
C#使用callvirt运算符调用任何实例方法(无论是否为virtual),以自动获取对象引用的空检查-在调用方法的点上引发NullReferenceException。否则,NullReferenceException仅在方法内部第一次实际使用类的任何实例成员时引发,这可能会令人惊讶。如果未使用任何实例成员,则该方法实际上可以成功完成而从未引发异常。
您还应记住,IL不会直接执行。它首先由JIT编译器编译为本机指令-并且根据您是否正在调试进程执行了许多优化。我发现CLR 2.0的x86 JIT内联了非虚拟方法但调用了虚拟方法-它还内联了Console.WriteLine

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接