为啥 C# 编译器会在最后一个方法调用是条件调用时删除一串方法调用?

Posted

技术标签:

【中文标题】为啥 C# 编译器会在最后一个方法调用是条件调用时删除一串方法调用?【英文标题】:Why does the C# compiler remove a chain of method calls when the last one is conditional?为什么 C# 编译器会在最后一个方法调用是条件调用时删除一串方法调用? 【发布时间】:2018-08-21 13:02:03 【问题描述】:

考虑以下类:

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();

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

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

如果一个或多个关联的条件编译符号是,则包括对条件方法的调用 在调用点定义,否则调用被省略。 (§22.5.3)

这似乎并不表明应该忽略整个链条,因此我提出了问题。话虽如此,C# 6.0 draft spec from Microsoft 提供了更多细节:

如果定义了符号,则包含调用;否则,调用(包括对接收者的评估和调用的参数)将被省略。

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

鉴于此,我的问题是:C# 编译器不评估的原因是什么 a.GetB() 在这种情况下?条件调用的接收者是否存储在临时变量中?

【问题讨论】:

这只是一个猜测,但我认为由于您没有在方法链接版本中保留对 B 的引用,因此编译器会忽略它的创建,因为它错误地“认为”您只想调用Hello() 方法。好问题! @ZoharPeled 谢谢!后来我注意到 C# 6.0 草案中添加的细节,但行为早于该草案。它似乎确实表明这是预期的行为,所以我现在最感兴趣的是这背后的基本原理:为什么这是预期的,为什么直到最近才记录下来。 如果您将this 视为附加的隐藏方法参数(确实如此),则更有意义。它只是得到与所有其他参数相同的处理。 @LucasTrzesniewski 这是一个合理的解释——它可能会比现在被赞成的答案更好。 我目前还没有打开 Visual Studio 来查看 - 但如果 a.GetB().Hello(); 行上有某种视觉指示表明它受此约束,那就太好了消失的行为。因为否则这种语义对读者来说是相当不可见的。 【参考方案1】:

归结为这句话:

(包括接收方的评估和调用的参数)被省略。

在表达式中:

a.GetB().Hello();

“接收方的评价”是:a.GetB()。所以:根据规范省略,这是一个有用的技巧,允许[Conditional] 避免未使用的东西的开销。当你把它放到本地时:

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

那么“接收者的评估”只是本地的b,但仍然评估原始的var b = a.GetB();(即使本地的b最终被删除)。

可能会产生意想不到的后果,所以:请谨慎使用[Conditional]。但原因是日志和调试之类的东西可以很容易地添加和删除。请注意,如果处理得天真,参数可能会出现问题:

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

和:

var count = engine.DoImportantStuff();
LogStatus("added: " + count);

如果LogStatus 被标记为[Conditional],则可能非常不同 - 结果是您的实际“重要事情”没有完成。

【讨论】:

我确实在使用此功能来允许调试,同时避免发布版本中的开销。但是,如果您仔细查看我的问题,您会发现,由于我注意到 6.0 规范中添加的细节,我知道这是(现在?)预期的行为,我更感兴趣的原因是.这是 C# 中条件编译的一个重要副作用,之前没有记录(据我所知,被忽略的参数是一个已知事实)。不过,感谢您对此提供更多见解 - 这应该对未来的读者有用! @Kyrio 我认为它总是表现那样 - 并且打算那样表现。所以关键是:规范现在让它更明显了 我不确定我们如何确定这是有意为之,但如果我们假设是这样,那仍然不能真正回答我的问题。 也许 Eric 可以进一步阐明这一点。 马克力量与我同在。不是真的,但埃里克总能找到最好的问题来回答。当我发表那条评论时,我敢打赌他会在不 ping 他的情况下找到它。果然我赢了那个赌注……最近很幸运。告诉你……这不是第一次了!【参考方案2】:

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

Marc Gravell’s answer 已经表明这种行为是有意的以及它在实践中的含义。 您还询问了这背后的基本原理,但似乎对 Marc 提到的消除开销感到不满。

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

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

我不知道这个决定背后的理由,但我发现了一些我自己的合理推理。也许它也可以帮助你。

Method chaining 只有在每个先前的方法都有返回值时才可能。当你想用这些值做某事时,这是有道理的,即a.GetFoos().MakeBars().AnnounceBars();

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

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

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

您似乎对此不满意的原因是您的方法不仅尝试执行返回某个值所需的操作,而且您还有side effect,即I/O。如果您确实有一个pure function,那么真的如果您省略随后的调用,则没有理由GetB(),即如果您不打算对结果做任何事情。

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

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

只有对Hello()的调用被省略,而当使用方法链接时,整个链被省略。

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

例如GetFoos()?.MakeBars()?.AnnounceBars(); 只有在前面的方法没有返回 null 时才会结束,否则后面的调用将被忽略。

这可能违反直觉,但请尝试将您的场景视为与此相反的情况:编译器会在您的 a.GetB().Hello(); 链中忽略您在 Hello() 之前的调用,因为您无论如何都没有到达链的末端。


免责声明

这都是扶手椅推理,所以请接受这一点以及与猫王操作员的类比。

【讨论】:

好挖掘。关于接收器的注释首先出现在 C# 4 规范中。 虽然这似乎回答了这个问题,但它所揭示的逻辑令人恐惧。链接或不链接方法可能存在语义差异的想法......我想我需要一份新工作。 @NPSF3000 行a.GetB().Hello(); 没有返回值,而省略Hello()a.GetB(); 突然 一个返回值,只是被丢弃。但是,您的日常方法链接将更像var bars = GetFoos()?.MakeBars(),其中MakeBars() 有一个返回值。如果此短路或您手动删除?.MakeBars(),您仍然会得到一个有效的分配,即使结果可能会改变。所以请记住,这是一个边缘案例——尽管很奇怪。 这可能非常狡猾。更明确地说,如果你有 myObject.DoImportantStuff().DoDispensableStuff() 而不是 GetB。我不希望 DoImportantStuff 方法被丢弃,因为 DoDispensableStuff 是有条件的。此外,myObject.DoImportantStuff().DoDispensableStuff()myObject.DoImportantStuff()?.DoDispensableStuff() 之间的行为因构建目标而异,检测起来可能非常复杂。【参考方案3】:

真的应该根据条件调用的接收者是否存储在临时变量中而表现不同吗?

是的。

在这种情况下,C# 编译器不评估 a.GetB() 的原因是什么?

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

该功能是在 1999 年设计的,该功能的目的始终是删除整个声明。 2003 年的设计说明表明,设计团队当时意识到规范在这一点上并不清楚。到目前为止,规范只指出不会评估 arguments。我注意到规范犯了将参数称为“参数”的常见错误,尽管当然可以假设它们的意思是“实际参数”而不是“形式参数”。 应该创建一个工作项来修复 ECMA 规范。显然这从未发生过。 更正文本第一次出现在任何 C# 规范中是 C# 4.0 规范,我相信那是 2010 年。(我不记得这是我的更正之一,还是其他人发现的。)李> 如果 2017 ECMA 规范不包含此更正,那么这是一个错误,应该在下一个版本中修复。我猜,迟到 15 年总比没有好。

【讨论】:

以上是关于为啥 C# 编译器会在最后一个方法调用是条件调用时删除一串方法调用?的主要内容,如果未能解决你的问题,请参考以下文章

c#里为啥有的使用时函数需要new一个对象而有的不用?为啥不直接调用就好?

为啥 C# 编译器不调用隐式运算符。编译器错误?

为啥 C++/CLI 编译器不为过时的属性调用生成警告?

C# ASP.NET Core 5.0 - 为啥在使用 [Authorization] 时甚至不调用该方法

为啥我的简单 ajax 帖子没有调用 c# web 方法?

为啥在尝试调用采用动态参数的基本构造函数/方法时会出现此编译错误?