为什么 callvirt 用于在泛型类型的只读字段上调用方法

Why is callvirt used to call a method on a readonly field of generic type

提问人:Bogey 提问时间:3/29/2022 更新时间:3/29/2022 访问量:238

问:

请考虑以下几点:

interface ISomething
{
    void Call(string arg);
}

sealed class A : ISomething
{
    public void Call(string arg) => Console.WriteLine($"A, {arg}");
}

sealed class Caller<T> where T : ISomething
{
    private readonly T _something;
    public Caller(T something) => _something = something;
    public void Call() => _something.Call("test");
}

new Caller<A>(new A()).Call();

对 Caller<A> 的调用。Call,以及它对 A.Call 的嵌套 tcall 是通过 callvirt 指令提交的。

但是为什么?这两种类型都是完全已知的。除非我误解了什么,否则这里不应该使用 call 而不是 callvirt 吗?

如果是这样 - 为什么不这样做?这仅仅是编译器没有完成的优化,还是背后有什么具体原因?

C# CIL 中间语言

评论

0赞 freakish 3/29/2022
嵌套调用不能更改为 non-virt。该类不知道 的确切类型是什么,并且必须在编译时生成 IL 以达到所有可能的 .不过,顶级调用可以去虚拟化。我不知道为什么没有这种优化。JIT有可能做到这一点,但是,在IL级别上已经是可能的。此外,对于非 virt 调用,内联是可能的,这也允许嵌套调用的去虚拟化。但是 C# 也没有在 IL 级别内联内容,由于某种原因,大多数优化都转移到了 JIT。CallerTT
0赞 Bogey 3/29/2022
我不遵循第一部分。调用者<A>应该通过泛型类型参数知道确切的类型 - 在这种情况下,它恰好是密封的类型 A,对吧?我同意必须在编译时显式创建 Caller<A> 类型 - 但为什么不能在那时进行这种优化呢?
2赞 Damien_The_Unbeliever 3/29/2022
“必须在编译时显式创建 Caller<A> 类型” - 不,.NET 的泛型不是 C++ 模板。构造的封闭泛型类型在运行时之前不存在。
1赞 shingo 3/29/2022
大多数优化是由 jit 完成的,而不是 il 编译器。
1赞 freakish 3/29/2022
@Bogey不是在编译时创建的,只是创建。C# 泛型与 C++ 模板不同。此外,据我所知,C# 编译器没有内联内容(但 JIT 有!因此,它无法在 IL 级别进行此优化。Caller<A>Caller<T>

答:

5赞 canton7 3/29/2022 #1

你错过了两件事。

第一种是对接收器进行空检查,而没有。这意味着在接收器上使用将引发一个 ,而会愉快地调用该方法并作为第一个参数传递,这意味着该方法将获得一个参数,即 。callvirtcallcallvirtnullNullReferenceExceptioncallnullthisnull

听起来令人惊讶?是的。在非常早期的 .NET 版本中,IIRC 是按照您建议的方式使用的人们对如何在方法内部感到非常困惑。编译器切换到强制运行时预先执行 null 检查。callthisnullcallvirt

只有少数几个地方编译器会发出:call

  1. 静态方法。
  2. 非虚拟结构方法。
  3. 调用基方法或基构造函数(我们知道接收方不是,并且我们也明确不想进行虚拟调用)。null
  4. 当编译器确定接收器不为空时,例如 其中是非虚拟的。foo?.Method()Method

最后一点特别意味着制定方法是一个二进制破坏性的变化。virtual

只是为了好玩,请参阅此检查中此 == nullString.Equals


第二件事是,这不是一个虚拟呼叫,而是一个受约束的虚拟呼叫。在它之前会出现一个受约束的操作码_something.Call("test");

受约束的虚拟调用是用泛型引入的。问题是对类和结构的方法调用有点不同:

  1. 对于类,您可以加载类引用(例如,使用 ),然后使用 / 。ldloccallcallvirt
  2. 对于结构,您可以加载结构的地址(例如,使用 ),然后使用 .ldloc.acall
  3. 要调用结构上的接口方法,或在 上定义的方法,您需要加载结构值(例如,用 ),装箱,然后使用 / 。objectldloccallcallvirt

如果泛型类型不受约束(即它可以是类或结构),编译器不知道该怎么做:它应该使用 or ?它应该装箱还是不装箱? 或?ldlocldloc.acallcallvirt

受约束的虚拟调用将此责任转移到运行时。引用上面的文档:

当一条指令以 为前缀时,该指令将按如下方式执行:callvirtmethodconstrainedthisType

  • If 是引用类型(而不是值类型),则取消引用并作为指向 的 'this' 指针传递。thisTypeptrcallvirtmethod
  • If 是值类型和 implements 则作为指向指令的 'this' 指针未经修改地传递,用于实现 by 。thisTypethisTypemethodptrcallmethodmethodthisType
  • If 是值类型且未实现,则被取消引用、装箱并作为指向指令的“this”指针传递。thisTypethisTypemethodptrcallvirtmethod

最后一种情况只有在 上定义 时才会发生,或者 上定义,并且未被 覆盖。在这种情况下,装箱会导致制作原始对象的副本。但是,由于 、 和 的方法都不能修改对象的状态,因此无法检测到这一事实。methodSystem.ObjectSystem.ValueTypeSystem.EnumthisTypeSystem.ObjectSystem.ValueTypeSystem.Enum

评论

0赞 freakish 3/29/2022
显然,顶级调用不在 .它可以去虚拟化。只是在 OP 的设置中没有办法调用不同的方法。.Call()null
0赞 canton7 3/29/2022
@freakish 编译器根本没有优化这种情况。是的,这个非常具体的情况很清楚,但在实践中并不特别常见。更常见的做法是构造一个类型并将结果存储在变量中,然后在变量上调用一个方法,突然跟踪变量是否可以同时变为 null 变得更加困难。此外,Microsoft 尝试将这些类型的优化(例如去虚拟化)放入运行时而不是编译器中,因此所有 CLR 语言都会受益。事实上,在本例中,运行时内联了整个调用
0赞 freakish 3/29/2022
是的,跟踪更难,但正如你所说:JIT已经这样做了(或者他们这么说)。我看不出将优化转移到 JIT 从而增加 JIT 编译时间的好处。老实说,将所有优化都转移到 JIT 是一个奇怪的决定。尤其是与平台无关的优化。
0赞 canton7 3/29/2022
这意味着他们不需要对 C#、VB.NET 和 F# 进行 3 次相同的优化,第三方语言也会受益。JIT 也比编译器拥有更多的知识:它知道在运行时使用了哪些具体类型,这意味着它可以比编译器多得多地去虚拟化/内联
1赞 Bogey 3/29/2022
公平 - 让我们更确切地说,理论上它“可以”合法地优化这些情况(即使它实际上并不费心这样做)