【问题标题】:Curious null-coalescing operator custom implicit conversion behaviour奇怪的空合并运算符自定义隐式转换行为
【发布时间】:2011-09-09 13:18:48
【问题描述】:

注意:这似乎已在 Roslyn 中修复

这个问题是在我写给this one 的答案时出现的,它谈到了null-coalescing operator 的关联性。

提醒一下,null-coalescing 运算符的想法是表单的表达式

x ?? y

首先评估x,然后:

  • 如果 x 的值为 null,则计算 y,这就是表达式的最终结果
  • 如果x 的值不为空,则y 的值计算,x 的值是表达式的最终结果,经过转换为编译- 时间类型 y 如有必要

现在通常不需要转换,或者只是从可空类型到不可空类型 - 通常类型是相同的,或者只是从(比如)int?int。但是,您可以创建自己的隐式转换运算符,并在必要时使用这些运算符。

对于x ?? y 的简单情况,我没有看到任何奇怪的行为。但是,对于 (x ?? y) ?? z,我看到了一些令人困惑的行为。

这是一个简短但完整的测试程序 - 结果在 cmets 中:

using System;

public struct A
{
    public static implicit operator B(A input)
    {
        Console.WriteLine("A to B");
        return new B();
    }

    public static implicit operator C(A input)
    {
        Console.WriteLine("A to C");
        return new C();
    }
}

public struct B
{
    public static implicit operator C(B input)
    {
        Console.WriteLine("B to C");
        return new C();
    }
}

public struct C {}

class Test
{
    static void Main()
    {
        A? x = new A();
        B? y = new B();
        C? z = new C();
        C zNotNull = new C();

        Console.WriteLine("First case");
        // This prints
        // A to B
        // A to B
        // B to C
        C? first = (x ?? y) ?? z;

        Console.WriteLine("Second case");
        // This prints
        // A to B
        // B to C
        var tmp = x ?? y;
        C? second = tmp ?? z;

        Console.WriteLine("Third case");
        // This prints
        // A to B
        // B to C
        C? third = (x ?? y) ?? zNotNull;
    }
}

所以我们有三种自定义值类型,ABC,具有从 A 到 B、A 到 C 和 B 到 C 的转换。

第二种情况和第三种情况我都能理解...但是为什么在第一种情况下会有额外的 A 到 B 转换?特别是,我真的希望第一种情况和第二种情况是同一件事 - 毕竟它只是将表达式提取到局部变量中。

有谁知道发生了什么?当谈到 C# 编译器时,我非常犹豫是否要喊“错误”,但我对发生的事情感到困惑......

编辑:好的,这是一个更糟糕的例子,感谢配置器的回答,这让我有进一步的理由认为这是一个错误。编辑:该示例现在甚至不需要两个空合并运算符...

using System;

public struct A
{
    public static implicit operator int(A input)
    {
        Console.WriteLine("A to int");
        return 10;
    }
}

class Test
{
    static A? Foo()
    {
        Console.WriteLine("Foo() called");
        return new A();
    }

    static void Main()
    {
        int? y = 10;

        int? result = Foo() ?? y;
    }
}

这个的输出是:

Foo() called
Foo() called
A to int

Foo() 在这里被调用两次的事实让我非常惊讶 - 我看不出有任何理由让表达式被评估两次。

【问题讨论】:

  • 我打赌他们认为“没有人会以这种方式使用它”:)
  • 想看更糟糕的事情吗?尝试将此行与所有隐式转换一起使用:C? first = ((B?)(((B?)x) ?? ((B?)y))) ?? ((C?)z);。你会得到:Internal Compiler Error: likely culprit is 'CODEGEN'
  • 另请注意,使用 Linq 表达式编译相同代码时不会发生这种情况。
  • @Peter 不太可能的模式,但对(("working value" ?? "user default") ?? "system default") 来说似乎是合理的
  • @yes123:当它只处理转换时,我并不完全相信。看到它两次执行一个方法,很明显这是一个错误。您会惊讶于某些看起来不正确但实际上完全正确的行为。 C# 团队比我聪明 - 我倾向于认为我是愚蠢的,直到我证明某些事情是他们的错。

标签: c# null-coalescing-operator


【解决方案1】:

感谢所有参与分析此问题的人。这显然是一个编译器错误。它似乎只发生在合并运算符左侧涉及两个可为空类型的提升转换时。

我还没有确定到底哪里出了问题,但是在编译的“可空降低”阶段的某个时刻——在初始分析之后但在代码生成之前——我们减少了表达式

result = Foo() ?? y;

从上面的例子到道德上的等价物:

A? temp = Foo();
result = temp.HasValue ? 
    new int?(A.op_implicit(Foo().Value)) : 
    y;

显然这是不正确的;正确的降低是

result = temp.HasValue ? 
    new int?(A.op_implicit(temp.Value)) : 
    y;

到目前为止,根据我的分析,我的最佳猜测是可空优化器在这里偏离了轨道。我们有一个可以为空的优化器,它查找我们知道可以为空类型的特定表达式不可能为空的情况。考虑以下朴素的分析:我们可以先说

result = Foo() ?? y;

相同
A? temp = Foo();
result = temp.HasValue ? 
    (int?) temp : 
    y;

然后我们可以这么说

conversionResult = (int?) temp 

相同
A? temp2 = temp;
conversionResult = temp2.HasValue ? 
    new int?(op_Implicit(temp2.Value)) : 
    (int?) null

但是优化器可以介入并说“哇,等一下,我们已经检查过 temp 不为空;没有必要因为我们调用提升的转换运算符而第二次检查它是否为空”。我们让他们将其优化为只是

new int?(op_Implicit(temp2.Value)) 

我的猜测是,我们在某个地方缓存了 (int?)Foo() 的优化形式是 new int?(op_implicit(Foo().Value)) 的事实,但这实际上并不是我们想要的优化形式;我们想要 Foo()-replaced-with-temporary-and-then-converted 的优化形式。

C# 编译器中的许多错误都是由于错误的缓存决策造成的。聪明的一句话:每次你缓存一个事实供以后使用,如果相关的改变,你可能会造成不一致。在这种情况下,初始分析后发生变化的相关事情是对 Foo() 的调用应始终实现为临时获取。

我们在 C# 3.0 中对可为空的重写过程进行了大量重组。该错误在 C# 3.0 和 4.0 中重现,但在 C# 2.0 中没有重现,这意味着该错误可能是我的错误。对不起!

我会在数据库中输入一个错误,我们会看看我们是否可以为该语言的未来版本修复这个错误。再次感谢大家的分析;很有帮助!

更新:我从头开始为 Roslyn 重写了可为空的优化器;它现在做得更好,并避免了这些奇怪的错误。有关 Roslyn 中的优化器如何工作的一些想法,请参阅我从这里开始的系列文章:https://ericlippert.com/2012/12/20/nullable-micro-optimizations-part-one/

【讨论】:

【解决方案2】:

这绝对是一个错误。

public class Program {
    static A? X() {
        Console.WriteLine("X()");
        return new A();
    }
    static B? Y() {
        Console.WriteLine("Y()");
        return new B();
    }
    static C? Z() {
        Console.WriteLine("Z()");
        return new C();
    }

    public static void Main() {
        C? test = (X() ?? Y()) ?? Z();
    }
}

这段代码将输出:

X()
X()
A to B (0)
X()
X()
A to B (0)
B to C (0)

这让我觉得每个?? coalesce 表达式的第一部分都被计算了两次。 这段代码证明了这一点:

B? test= (X() ?? Y());

输出:

X()
X()
A to B (0)

这似乎只在表达式需要在两个可空类型之间进行转换时发生;我尝试了各种排列,其中一侧是字符串,但都没有导致这种行为。

【讨论】:

  • 哇 - 两次评估表达式似乎确实非常错误。很好发现。
  • 查看源代码中是否只有 一个 方法调用会稍微简单一些——但这仍然非常清楚地展示了这一点。
  • 我在我的问题中添加了一个稍微简单的“双重评估”示例。
  • 你所有的方法都应该输出“X()”吗?很难判断实际输出到控制台的方法是什么。
  • 似乎X() ?? Y() 在内部扩展为X() != null ? X() : Y(),因此它会被评估两次。
【解决方案3】:

如果您查看为左分组情况生成的代码,它实际上会执行以下操作 (csc /optimize-):

C? first;
A? atemp = a;
B? btemp = (atemp.HasValue ? new B?(a.Value) : b);
if (btemp.HasValue)
{
    first = new C?((atemp.HasValue ? new B?(a.Value) : b).Value);
}

另一个发现,如果您使用 first,如果ab 都为空并返回c,它将生成一个快捷方式。然而,如果ab 不为空,它会在返回ab 中的哪一个不为空之前,重新评估a 作为到B 的隐式转换的一部分。

来自 C# 4.0 规范,§6.1.4:

  • 如果可空转换是从S?T?
    • 如果源值为nullHasValue 属性为false),则结果为null 类型的值T?
    • 否则,转换被评估为从S?S 的展开,然后是从ST 的底层转换,然后是从T 到的换行(第 4.1.10 节) T?

这似乎解释了第二个展开-包裹组合。


C# 2008 和 2010 编译器生成非常相似的代码,但这看起来像是 C# 2005 编译器 (8.00.50727.4927) 的回归,它为上述生成以下代码:

A? a = x;
B? b = a.HasValue ? new B?(a.GetValueOrDefault()) : y;
C? first = b.HasValue ? new C?(b.GetValueOrDefault()) : z;

不知道是不是因为类型推断系统被赋予了额外的魔法

【讨论】:

  • +1,但我认为这并不能真正解释为什么要执行两次转换。它应该只评估一次表达式,IMO。
  • @Jon:我一直在玩,发现(就像@configurator 所做的那样)在表达式树中完成时,它可以按预期工作。致力于清理表达式以将其添加到我的帖子中。我不得不假设这是一个“错误”。
  • @Jon:好的,当使用表达式树时,它会将(x ?? y) ?? z 转换为嵌套的 lambda,从而确保按顺序进行评估而无需重复评估。这显然不是 C# 4.0 编译器采用的方法。据我所知,在这个特定的代码路径中以非常严格的方式处理了第 6.1.4 节,并且没有省略临时变量,导致双重评估。
【解决方案4】:

实际上,我现在将其称为错误,并提供更清晰的示例。这仍然成立,但双重评估肯定不好。

似乎A ?? B 被实现为A.HasValue ? A : B。在这种情况下,也有很多转换(遵循三元 ?: 运算符的常规转换)。但是,如果您忽略所有这些,那么根据它的实现方式,这是有道理的:

  1. A ?? B 扩展为 A.HasValue ? A : B
  2. A 是我们的x ?? y。扩展至x.HasValue : x ? y
  3. 替换所有出现的 A -> (x.HasValue : x ? y).HasValue ? (x.HasValue : x ? y) : B

这里可以看到x.HasValue被检查了两次,如果x ?? y需要强制转换,x会被强制转换两次。

我会把它简单地写成?? 是如何实现的工件,而不是编译器错误。 要点:不要创建具有副作用的隐式转换运算符。

这似乎是一个围绕?? 实现方式的编译器错误。要点:不要嵌套具有副作用的合并表达式。

【讨论】:

  • 哦,我绝对不想正常使用这样的代码,但我认为它可以仍然被归类为编译器错误,因为你的第一个扩展应该包括“但是只评估 A 和 B 一次”。 (想象一下,如果它们是方法调用。)
  • @Jon 我同意它也可以——但我不会说它是明确的。好吧,实际上,我可以看到A() ? A() : B() 可能会评估A() 两次,但A() ?? B() 不会那么多。而且因为它只发生在铸造时......嗯......我只是说服自己认为它的行为肯定不正确。
【解决方案5】:

从我的问题历史中可以看出,我根本不是 C# 专家,但是,我试过了,我认为这是一个错误....但是作为一个新手,我不得不说我不了解这里发生的一切,所以如果我离开了,我会删除我的答案。

我得出了这个bug 的结论,方法是制作一个不同版本的程序来处理相同的场景,但要简单得多。

我将三个空整数属性与后备存储一起使用。我将每个设置为 4,然后运行 ​​int? something2 = (A ?? B) ?? C;

(Full code here)

这只是读取 A,没有其他内容。

这句话在我看来应该是这样的:

  1. 从括号开始,查看 A,返回 A,如果 A 不为 null,则结束。
  2. 如果 A 为 null,则评估 B,如果 B 不为 null,则结束
  3. 如果 A 和 B 为空,则计算 C。

所以,由于 A 不为空,它只查看 A 并结束。

在您的示例中,在第一种情况下放置断点表明 x、y 和 z 都不为空,因此,我希望它们与我不太复杂的示例一样被对待....但我担心我我是 C# 新手,完全忽略了这个问题的重点!

【讨论】:

  • Jon 的示例有点晦涩难懂,因为他使用了一个可以为空的结构(一种类似于 int 等内置类型的值类型)。他通过提供多个隐式类型转换将案例进一步推向了一个不起眼的角落。这要求编译器在检查null 时更改数据的type。正是由于这些隐式类型转换,他的示例与您的示例不同。
猜你喜欢
  • 2017-05-28
  • 2014-02-18
  • 2018-03-12
  • 2011-03-16
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2017-09-18
  • 1970-01-01
相关资源
最近更新 更多