当最后一个方法调用是有条件的时,为什么 C# 编译器会删除一连串的方法调用?

Why does the C# compiler remove a chain of method calls when the last one is conditional?

考虑以下 类:

public class A {
    public B GetB() {
        Console.WriteLine("GetB");
        return new B();
    }
}

public class B {
    [System.Diagnostics.Conditional("DEBUG")]
    public void Hello() {
        Console.WriteLine("Hello");
    }
}

现在,如果我们这样调用方法:

var a = new A();
var b = a.GetB();
b.Hello();

在发布版本中(即没有 DEBUG 标志),我们只会在控制台上看到 GetB,因为对 Hello() 的调用将被编译器忽略。在调试版本中,两个打印都会出现。

现在让我们链接方法调用:

a.GetB().Hello();

调试版本中的行为没有改变;但是,如果未设置标志,我们会得到不同的结果:both 调用都被省略,并且控制台上没有打印。快速查看 IL 显示整行未编译。

根据 latest ECMA standard for C#(ECMA-334,即 C# 5.0),将 Conditional 属性放置在方法上时的预期行为如下(强调我的):

A call to a conditional method is included if one or more of its associated conditional compilation symbols is defined at the point of call, otherwise the call is omitted. (§22.5.3)

这似乎并不表示应该忽略整个链,因此我的问题。也就是说,C# 6.0 draft spec from Microsoft 提供了更多细节:

If the symbol is defined, the call is included; otherwise, the call (including evaluation of the receiver and parameters of the call) is omitted.

调用的参数未被评估这一事实有据可查,因为这是人们使用此功能而不是函数主体中的 #if 指令的原因之一。然而,关于 "evaluation of the receiver" 的部分是新的 - 我似乎无法在其他地方找到它,它似乎确实解释了上述行为。

鉴于此,我的问题是:在这种情况下,C# 编译器不评估 a.GetB() 背后的基本原理是什么? 根据条件调用的接收者是否存储在临时变量中,它的行为真的会有所不同吗?

归结为短语:

(including evaluation of the receiver and parameters of the call) is omitted.

表达式中:

a.GetB().Hello();

"evaluation of the receiver" 是:a.GetB()。所以:根据规范,它被省略了,这是一个有用的技巧,允许[Conditional]避免未使用的东西的开销.当你把它放到本地时:

var b = a.GetB();
b.Hello();

那么 "evaluation of the receiver" 只是本地 b,但是原始 var b = a.GetB(); 仍然被评估(即使本地 b 最终被删除)。

可能会 产生意想不到的后果,因此:请谨慎使用 [Conditional]。但原因是可以轻松添加和删除诸如日志记录和调试之类的东西。请注意,如果处理得当,参数可能 有问题:

LogStatus("added: " + engine.DoImportantStuff());

和:

var count = engine.DoImportantStuff();
LogStatus("added: " + count);
如果 LogStatus 被标记为 [Conditional]

可能 非常 不同 - 结果是您的实际 "important stuff" 没有完成。

我做了一些挖掘,发现 C# 5.0 language specification 实际上已经包含了第 424 页的 17.4.2 条件属性 部分中的第二个引号。

已经表明这种行为是有意为之的,它在实践中意味着什么。 您还询问了这背后的 理由,但似乎对 Marc 提到的消除开销不满意。

也许您想知道为什么它被认为是可以删除的开销?

a.GetB().Hello(); 在您的场景中根本没有被调用而 Hello() 被省略可能看起来很奇怪。

我不知道该决定背后的理由,但我自己找到了一些似是而非的推理。也许它也能帮到你。

Method chaining 仅当每个先前的方法都有一个 return 值时才有可能。当您想对这些值执行某些操作时,这很有意义,即 a.GetFoos().MakeBars().AnnounceBars();

如果你有一个函数只 没有 return 值的东西,你不能在它后面链接一些东西,但可以把它放在方法链的末尾,就像您的条件方法一样,因为它必须具有 return 类型 void.

另请注意,先前方法调用的 结果 丢弃 ,因此在您的 a.GetB().Hello(); 示例中GetB() 的结果在执行此语句后没有理由存在。基本上,你 暗示 你只需要 GetB() 的结果来使用 Hello().

如果 Hello() 被省略了,为什么还需要 GetB() 呢?如果您省略 Hello(),您的行将归结为 a.GetB(); 而没有任何赋值,并且许多工具会发出警告,提示您没有使用 return 值,因为这很少是您想要做的事情。

你似乎对此不满意的原因是你的方法不仅试图做return某个特定值所必需的事情,而且你还有一个side effect, namely I/O. If you did instead have a pure function 真的没有理由 GetB() 如果您省略后续调用,即如果您不打算对结果做任何事情。

如果您将 GetB() 的结果赋值给一个变量,这本身就是一条语句,无论如何都会被执行。所以这个推理解释了为什么在

var b = a.GetB();
b.Hello();

仅省略了对 Hello() 的调用,而在使用方法链接时省略了整个链。

您也可以查看完全不同的地方以获得更好的视角:C# 6.0 中引入的 null-conditional operator or elvis operator ?。尽管它只是具有空检查的更复杂表达式的语法糖,但它允许您构建类似于方法链的东西,并带有基于空检查的 short-circuit 选项。

例如GetFoos()?.MakeBars()?.AnnounceBars();只有在前面的方法没有returnnull的情况下才会到达终点,否则后面的调用将被省略。

它可能是 counter-intuitive 但请尝试将您的场景想象成与此相反的情况:编译器会在 a.GetB().Hello(); 链中忽略 Hello() 之前的调用,因为您没有到达无论如何链的末端。


免责声明

这都是纸上谈兵的推理,所以请对这个和猫王运算符的类比持保留态度。

Should it really behave differently based on whether the receiver of the conditional call is stored in a temporary variable or not?

是的。

What's the rationale behind the C# compiler not evaluating a.GetB() in this situation?

Marc和Søren的回答基本正确。这个回答只是为了清楚地记录时间线。

  • 该功能于 1999 年设计,其目的始终是删除整个语句。
  • 2003年的设计笔记表明,设计团队当时意识到规范在这一点上不清楚。到目前为止,规范仅指出 arguments 不会被评估。我注意到该规范犯了调用参数 "parameters" 的常见错误,尽管人们当然可以假设它们的意思是 "actual parameters" 而不是 "formal parameters".
  • 应该创建一个工作项来解决这一点上的 ECMA 规范;显然那从未发生过。
  • 更正后的文本第一次出现在任何 C# 规范中是 C# 4.0 规范,我相信是在 2010 年。(我不记得这是我的更正之一,还是其他人发现的。)
  • 如果 2017 ECMA 规范不包含此更正,则该错误应在下一个版本中修复。我想,迟到 15 年总比没有好。