注意:这似乎在罗斯林被修复了

这个问题出现在我回答这个问题的时候,这个问题讨论了空合并运算符的结合律。

提醒一下,空合并操作符的思想是窗体的表达式

x ?? y

首先计算x,然后:

如果x的值为空,则计算y,这是表达式的最终结果 如果x的值非空,则不计算y,并且x的值是表达式的最终结果,在必要时转换为y的编译时类型

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

对于简单的x ??y,我没有看到任何奇怪的行为。然而,使用(x ??y) ? ?我看到一些令人困惑的行为。

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

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;
    }
}

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

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

有人知道发生了什么吗?当谈到c#编译器时,我非常犹豫要不要喊“bug”,但我被难住了,不知道发生了什么……

编辑:好吧,这里有一个正在发生的事情的更糟糕的例子,多亏了配置器的答案,这让我进一步有理由认为这是一个bug。编辑:示例现在甚至不需要两个空合并操作符了…

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()在这里被调用两次的事实让我非常惊讶——我看不出表达式被求值两次的任何理由。


当前回答

如果你看一下左分组情况下生成的代码,它实际上是这样做的(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);
}

另一个发现,如果你首先使用,如果a和b都为空并返回c,它将生成一个快捷方式。然而,如果a或b非空,它将重新计算a作为隐式转换到b的一部分,然后返回a或b中哪一个非空。

来自c# 4.0规范,§6.1.4:

如果可空转换是从S?T ?: 如果源值为空(HasValue属性为false),则结果为类型为T?的空值。 否则,转换被计算为从S?到S,然后是从S到T的基本转换,然后是从T到T的换行(§4.1.10)。

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


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;

我想知道这是不是由于类型推断系统的额外魔力?

其他回答

如果你看一下左分组情况下生成的代码,它实际上是这样做的(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);
}

另一个发现,如果你首先使用,如果a和b都为空并返回c,它将生成一个快捷方式。然而,如果a或b非空,它将重新计算a作为隐式转换到b的一部分,然后返回a或b中哪一个非空。

来自c# 4.0规范,§6.1.4:

如果可空转换是从S?T ?: 如果源值为空(HasValue属性为false),则结果为类型为T?的空值。 否则,转换被计算为从S?到S,然后是从S到T的基本转换,然后是从T到T的换行(§4.1.10)。

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


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;

我想知道这是不是由于类型推断系统的额外魔力?

我不是c#专家,你可以从我的问题历史中看到,但是,我尝试了一下,我认为这是一个bug....但作为一个新手,我不得不说我不明白这里发生的一切,所以如果我错了,我会删除我的答案。

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

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

(完整代码在这里)

这个只读取A,其他什么都没有。

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

从方括号开始,查看A,返回A,如果A不为空就结束。 如果A为空,则计算B,如果B不为空则结束 如果A和B为空,则计算C。

因此,由于A不为空,它只看A并结束。

在您的示例中,在First Case中放置断点表明x、y和z都不为空,因此,我希望它们与我的不太复杂的示例....相同但我担心我是一个c#新手,完全错过了这个问题的要点!

这绝对是一个bug。

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)

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

实际上,我现在用更清晰的示例将其称为bug。这仍然成立,但双重评估肯定不是好事。

A ??B被实现为a。A: B.在这种情况下,也有很多强制转换(遵循三元?:操作符的常规强制转换)。但如果你忽略所有这些,那么根据它的实现方式,这是有意义的:

一个? ?B扩展到a。A: b A是x ??hasvalue: x ?y 替换所有出现的A -> (x. hasvalue: x ?y) .HasValue吗?(x. hasvalue: x ?y): B

这里你可以看到x. hasvalue被检查了两次,如果x ??Y需要强制施法,x将被强制施法两次。

我会把它简单地当作一种人工制品??是实现的,而不是编译器错误。要点:不要创建带有副作用的隐式强制转换操作符。

这似乎是一个编译器错误围绕如何??是如何实现的。要点:不要让合并表达式带有副作用。

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

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

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()的优化形式是新的int?(op_implicit(Foo().Value)),但这实际上不是我们想要的优化形式;我们希望将Foo()的优化形式替换为-temporary-然后转换。

c#编译器中的许多错误都是错误缓存决策的结果。一句话:每次缓存一个事实以供以后使用时,如果相关的东西发生了变化,就可能会造成不一致。在这种情况下,初始分析后的相关变化是,对Foo()的调用应该始终作为一个临时对象的取回来实现。

我们在c# 3.0中对可空重写传递做了大量的重组。这个错误在c# 3.0和4.0中重现,但在c# 2.0中没有,这意味着这个错误可能是我的错。对不起!

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

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