关于C#:好奇的null-coalescing运算符自定义隐式转换行为

Curious null-coalescing operator custom implicit conversion behaviour

注:这似乎是在罗斯林修复的。

这个问题是在写我对这个问题的答案时产生的,这个问题讨论了空合并操作符的关联性。

正如一个提醒,空合并运算符的概念是

1
x ?? y

首先评估x,然后:

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

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

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

下面是一个简短但完整的测试程序-结果在注释中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
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"的时候,我绝对不会大喊"bug",但是我很难理解到底发生了什么……

编辑:好吧,这是一个更糟糕的例子,这要归功于配置程序的回答,这给了我进一步的理由认为这是一个bug。编辑:该示例现在甚至不需要两个空合并运算符…

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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;
    }
}

其结果是:

1
2
3
Foo() called
Foo() called
A to int

事实上,在这里两次调用Foo()对我来说是非常令人惊讶的——我看不出任何理由可以对表达式进行两次计算。


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

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

1
result = Foo() ?? y;

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

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

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

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

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

1
result = Foo() ?? y;

是一样的

1
2
3
4
A? temp = Foo();
result = temp.HasValue ?
    (int?) temp :
    y;

然后我们可以这么说

1
conversionResult = (int?) temp

是一样的

1
2
3
4
A? temp2 = temp;
conversionResult = temp2.HasValue ?
    new int?(op_Implicit(temp2.Value)) :
    (int?) null

但是优化器可以介入并说"哇,等一下,我们已经检查了temp不是空的;不需要再次检查它是否为空,因为我们调用的是提升的转换运算符"。我们让他们把它优化到

1
new int?(op_Implicit(temp2.Value))

我的猜测是,我们正在缓存这样一个事实:(int?)Foo()的优化形式是new int?(op_implicit(Foo().Value)),但这实际上不是我们想要的优化形式;我们希望foo()的优化形式被临时替换,然后转换。

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

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

我将在数据库中输入一个bug,然后我们将查看是否可以在将来的语言版本中修复这个bug。再次感谢大家的分析,非常有帮助!

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


这绝对是一个错误。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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();
    }
}

此代码将输出:

1
2
3
4
5
6
7
X()
X()
A to B (0)
X()
X()
A to B (0)
B to C (0)

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

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

输出:

1
2
3
X()
X()
A to B (0)

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


如果您查看左分组案例生成的代码,它实际上是这样做的(csc /optimize-

1
2
3
4
5
6
7
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条:

  • If the nullable conversion is from S? to T?:

    • If the source value is null (HasValue property is false), the result is the null value of type T?.
    • Otherwise, the conversion is evaluated as an unwrapping from S? to S, followed by the underlying conversion from S to T, followed by a wrapping (§4.1.10) from T to T?.

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

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

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

我想知道这是否是因为类型推理系统的额外魔力?


实际上,我现在把它称为bug,用更清楚的例子。这仍然有效,但双重评价肯定不好。

似乎A ?? B是作为A.HasValue ? A : B来执行的。在这种情况下,也有很多铸件(遵循三元?:运算符的常规铸造)。但是如果你忽略了所有这些,那么基于它是如何实现的,这是有意义的:

  • A ?? B扩大到A.HasValue ? A : B
  • a是我们的x ?? y。扩大到x.HasValue : x ? y
  • 替换所有出现的a->(x.HasValue : x ? y).HasValue ? (x.HasValue : x ? y) : B
  • 在这里可以看到,x.HasValue被检查了两次,如果x ?? y需要铸造,x将铸造两次。

    我把它简单地说成是一个如何实现??的工件,而不是一个编译器错误。take away:don't create implicit casting operators with side effects.

    这似乎是一个围绕如何实现??的编译器bug。带走:不要用副作用嵌套合并表达式。


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

    我已经得出了这个结论,通过对您的程序做一个不同的版本来处理相同的场景,但要简单得多。

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

    (此处为完整代码)

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

    我觉得这句话应该:

  • 从括号中开始,看a,如果a不为空,返回a并结束。
  • 如果a为空,则计算b,如果b不为空,则完成。
  • 如果a和b为空,则计算c。
  • 因此,因为a不是空的,所以它只查看a并完成。

    在您的示例中,在第一种情况下放置一个断点表明x、y和z都不是空的,因此,我希望它们被视为与我的不太复杂的示例相同的东西……但我担心我太像一个新手了,完全错过了这个问题的要点!