好奇的null-coalescing运算符自定义隐式转换行为

时间:2011-06-06 19:11:50

标签: c# null-coalescing-operator

注意:这似乎已在Roslyn

中修复

在撰写this one的答案时出现了这个问题,null-coalescing operator讨论了{{3}}的相关性。

正如提醒一下,null-coalescing运算符的概念是表单的表达式

x ?? y

首先评估x,然后:

  • 如果x的值为null,则评估y,这是表达式的最终结果
  • 如果x的值为非空,则y 评估,x的值是表达式的最终结果,在必要时转换为编译时类型y
  • 之后

现在通常不需要转换,或者只是从可空类型到非可空类型 - 通常类型相同,或者只是来自(例如){{1}到int?。但是,可以创建自己的隐式转换运算符,并在必要时使用它们。

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

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

(x ?? y) ?? z

因此,我们有三种自定义值类型,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; } } AB,转换范围从A到B,A到C,B到C。

我可以理解第二种情况和第三种情况......但为什么在第一种情况下会有额外的A到B转换?特别是,我确实期望第一个案例和第二个案例是相同的 - 毕竟它只是将表达式提取到局部变量中。

任何关于正在发生的事情的接受者?当谈到C#编译器时,我非常愿意接受“bug”的诅咒,但是我对于发生的事情感到困惑......

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

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 在这里被调用两次的事实对我来说非常令人惊讶 - 我看不出任何理由让表达式被评估两次。

5 个答案:

答案 0 :(得分:411)

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

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

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;

根据我迄今为止的分析,我最好的猜测是可空的优化器在这里发挥作用。我们有一个可以为空的优化器,它可以查找我们知道可空类型的特定表达式不可能为null的情况。考虑以下天真的分析:我们可以先说

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不是null;没有必要再次将它检查为null,因为我们正在调用一个提升的转换运算符”。我们将它们优化为

new int?(op_Implicit(temp2.Value)) 

我的猜测是,我们在某处缓存了(int?)Foo()的优化形式为new int?(op_implicit(Foo().Value))的事实,但这实际上并不是我们想要的优化形式;我们想要Foo()的优化形式 - 替换为临时和然后转换。

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/

答案 1 :(得分:84)

这绝对是一个错误。

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)

这使我认为每个??合并表达式的第一部分被评估两次。 这段代码证明了这一点:

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

输出:

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

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

答案 2 :(得分:54)

如果您看一下左侧分组案例的生成代码,它实际上会做这样的事情(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为非null,则会在返回a或{{{{}}之前隐式转换为B时重新评估a 1}}是非空的。

来自C#4.0规范,§6.1.4:

  
      
  • 如果可空转换是从bS?:   
        
    • 如果源值为T?null属性为HasValue),则结果为false类型的null
    •   
    • 否则,转化将被评估为从T?S?的展开,然后是从SS的基础转换,然后是换行(§4.1) .10)从TT
    •   
  •   

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


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

T?

我想知道这不是由于给予类型推理系统的额外 magic 吗?

答案 3 :(得分:16)

实际上,我现在称这是一个错误,更清楚的例子。这仍然有效,但双重评估肯定不好。

好像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
  4. 您可以在此处看到x.HasValue被检查两次,如果x ?? y需要投射,x将被投放两次。

    我只是把它作为??如何实现的工件,而不是编译器错误。 Take-Away:不要创建带有副作用的隐式转换运算符。

    这似乎是围绕如何实现??的编译器错误。外卖:不要将具有副作用的合并表达式嵌套。

答案 4 :(得分:10)

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

我通过制作一个处理同一场景的程序的不同版本来得出这个bug结论,但不那么复杂。

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

Full code here

这只是读取A而不是别的。

这句话对我来说应该是:

  1. 从括号开始,查看A,返回A并在A不为空时完成。
  2. 如果A为null,则评估B,如果B不为空则结束
  3. 如果A和B为空,请评估C.
  4. 因此,由于A不为空,它只查看A并完成。

    在你的例子中,在First Case中放置一个断点表明x,y和z都不是null,因此,我希望它们与我不太复杂的例子一样对待....但我担心我我是太多的C#新手,完全错过了这个问题的重点!