奇怪的 null 合并运算符自定义隐式转换行为

本文关键字:转换 换行 自定义 运算符 null 合并 | 更新日期: 2023-09-27 17:58:35

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

这个问题

是在写我对这个问题的回答时出现的,它谈到了零合并运算符的关联性。

提醒一下,空合并运算符的想法是表单的表达式

x ?? y

首先评估x,然后:

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

现在通常不需要转换,或者只是从可为空的类型转换为不可为空的类型 - 通常类型是相同的,或者只是从(比如(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# 编译器时,我非常犹豫是否要喊"错误",但我对正在发生的事情感到困惑......

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

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

奇怪的 null 合并运算符自定义隐式转换行为

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

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

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 不为空;没有必要仅仅因为我们调用了一个提升的转换运算符而第二次检查它是否为 null"。 我们会让他们将其优化为仅

new int?(op_Implicit(temp2.Value)) 

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

C# 编译器中的许多 bug 都是糟糕的缓存决策的结果。明智的一句话是:每次您缓存事实以供以后使用时,如果相关内容发生变化,您可能会造成不一致。在这种情况下,在初始分析后更改的相关内容是,对 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/

这绝对是一个错误。

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)

这似乎仅在表达式需要在两个可为 null 的类型之间进行转换时才会发生;我尝试了各种排列,其中一侧是一根绳子,但没有一个导致这种行为。

如果你看一下为左分组案例生成的代码,它实际上做了这样的事情(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 都为 null 并返回 c ,它将生成一个快捷方式。但是,如果ab为非 null,则它会重新评估a,作为隐式转换为 B 的一部分,然后再返回ab中的哪一个为非 null。

来自 C# 4.0 规范 §6.1.4:

  • 如果可为空的转换是从 S?T?
    • 如果源值为 null(HasValue属性为 false (,则结果为类型 T?null值。
    • 否则,转换被评估为从S?S的解包,然后是从ST的基础转换,然后是从TT?的包装(§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;

我想知道这是否是由于类型推断系统赋予了额外的魔法

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

似乎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将被铸造两次。

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

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

正如您从我的问题历史记录中看到的那样,我根本不是 C# 专家,但是,我尝试过这个,我认为这是一个错误......但是作为一个新手,我不得不说我不明白这里发生的一切,所以如果我离得很远,我会删除我的答案。

我通过制作一个处理相同场景但简单得多的程序的不同版本得出了这个bug结论。

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

(完整代码在这里(

这只读取 A,没有别的。

这句话在我看来应该是:

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

因此,由于 A 不为空,它只查看 A 并完成。

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