好奇的空值销售员自定义隐式转换行为


542

注意:这似乎已在罗斯林(Roslyn)中修复

在写我对这个问题的答案时,出现了这个问题,它讨论了null结点运算符的结合性

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

x ?? y

首先评估x,然后:

  • 如果的值为xnull,y则求值,这是表达式的最终结果
  • 如果值x是非空,y评估,的值x是表达的最终结果,转换到编译时间类型的后y如果需要的话

现在通常不需要转换,或者只是从可为null的类型到不可为null的类型的转换-通常类型是相同的,或者只是从(say)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;
    }
}

因此,我们有三个自定义值类型,ABC,与从A转换到B,A至C和B到C.

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

发生什么情况的任何参与者?当涉及到C#编译器时,我非常想哭“ bug”,但是我对发生的事情感到困惑……

编辑:好的,由于配置程序的回答,这是正在发生的一个更糟糕的例子,这使我有更多理由认为这是一个错误。编辑:该示例现在甚至不需要两个null运算符...

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


32
我敢打赌,他们认为“没有人会使用它以这种方式” :)
cyberzed

57
想要看到更糟的东西吗?尝试将此行用于所有隐式转换:C? first = ((B?)(((B?)x) ?? ((B?)y))) ?? ((C?)z);。您将获得:Internal Compiler Error: likely culprit is 'CODEGEN'
配置器

5
另请注意,使用Linq表达式编译同一代码时不会发生这种情况。
配置器

8
@Peter的模式不太可能,但似乎很可能(("working value" ?? "user default") ?? "system default")
神秘主义者

23
@ yes123:当它只处理转换时,我并不完全相信。看到它两次执行一个方法,很明显这是一个错误。您会对某些看起来不正确但实际上完全正确的行为感到惊讶。C#团队比我更聪明-我倾向于以为我很愚蠢,直到我证明他们的错是什么。
乔恩·斯基特

Answers:


418

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

我还没有确定确切的地方出了问题,但是在编译的“可降低的降低”阶段的某个时候(在最初的分析之后,在代码生成之前),我们减少了表达式

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的特定表达式不可能为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/



12
现在,我有了Roslyn的最终用户预览,可以确认它已在那里固定。(尽管它仍然存在于本地C#5编译器中。)
Jon Skeet 2014年

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)

这似乎仅在表达式需要在两个可为空的类型之间进行转换时才发生。我尝试了多种排列方式,其中一侧是一个字符串,但没有一个引起这种行为。


11
哇-两次评估表达式确实确实很错误。好眼力。
乔恩·斯基特

看看在源中是否只有一个方法调用会稍微简单一些-但这仍然很清楚地证明了这一点。
乔恩·斯基特

2
我在我的问题中添加了这个“双重评估”的简单示例。
乔恩·斯基特

8
是否所有方法都应该输出“ X()”?这使得很难分辨出什么方法实际输出到控制台。
jeffora 2011年

2
它似乎X() ?? Y()在内部扩展为X() != null ? X() : Y(),因此为什么要对其进行两次评估。
科尔·约翰逊

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

如果您使用的 另一个发现,如果和并且均为null和return first,则会生成一个快捷方式。但是,如果或为非空值,则作为隐式转换的一部分,在返回或为非空值之前,将重新评估。abcabaBab

根据C#4.0规范第6.1.4节:

  • 如果可空转换是从S?T?
    • 如果源值为nullHasValue属性为false),则结果为nulltype 的值T?
    • 否则,转换被评估为从解包S?S,随后从底层转换ST,随后从包裹(§4.1.10)TT?

这似乎可以解释第二种展开包装的组合。


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所做的一样)在表达式树中完成后,它可以按预期工作。正在整理表达式以将其添加到我的帖子中。那么我将不得不假定这是一个“错误”。
user7116 2011年

@Jon:好的,当使用Expression Trees时,它会(x ?? y) ?? z变成嵌套的lambda,这可以确保按顺序求值而无需双重求值。这显然不是C#4.0编译器采用的方法。据我所知,在此特定代码路径中以非常严格的方式处理了6.1.4节,并且没有消除临时性,从而导致了双重评估。
user7116 2011年

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

在这里您可以看到x.HasValue已检查两次,并且如果x ?? y需要强制转换,x将强制转换两次。

我只是将其作为实现方式的产物??而不是编译器错误来加以说明。 要点:不要创建带有副作用的隐式转换运算符。

看来这是一个编译器错误,涉及到如何??实现。要点:不要在合并表达式中嵌套副作用。


哦,我肯定不希望正常使用这样的代码,但我认为它可能仍然被归类为在你的第一个扩张应包括编译器错误“但仅评估A和B的一次”。(想象一下它们是否是方法调用。)
Jon Skeet

@Jon我同意也可以-但我不会将其称为明确的。好吧,实际上,我可以看到它A() ? A() : B()可能会得到A()两次评估,但效果A() ?? B()却不是那么好。而且由于它仅在投射时发生...嗯..我只是自言自语地认为它肯定行为不正确。
菲利普·里克

10

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

我来到了这个bug被制作不同版本的方案,其涉及了同样的场景结束,但更复杂。

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

完整代码在这里

这只是读取A,仅此而已。

在我看来,这句话应该是:

  1. 从方括号中开始,看一下A,返回A,如果A不为null,则结束。
  2. 如果A为空,则评估B,如果B不为空,则完成
  3. 如果A和B为空,请评估C。

因此,由于A不为null,因此它仅查看A并完成。

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


5
乔恩(Jon)的示例有些晦涩难懂,因为他使用的是可为空的结构(类似于的内置类型(类似于int)的值类型)。他通过提供多个隐式类型转换将案例进一步推向一个晦涩的角落。这要求编译器在检查的同时更改数据类型null。由于这些隐式类型转换,他的示例与您的示例有所不同。
user7116 2011年
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.