首页 文章

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

提问于
浏览
508

Note: this appears to have been fixed in Roslyn

写这个问题的时候出现了这个问题this one,其中讨论了null-coalescing operator的相关性 .

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

x ?? y

首先评估 x ,然后:

  • 如果 x 的值为null,则计算 y ,这是表达式的最终结果

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

现在通常有's no need for a conversion, or it'只是从可空类型到非可空类型 - 通常类型相同,或者只是从(例如) 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”,但是我对于发生了什么感到难过......

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

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

5 回答

  • 9

    如果你看一下左分组案例的生成代码,它实际上会做这样的事情( 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,则在返回 ab 中的哪一个为非null之前,将 a 重新计算为隐式转换为 B 的一部分 .

    从C#4.0规范,§6.1.4:

    如果可空转换来自S? to T?:如果源值为null(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;
    

    我想知道这是不是因为给类型推理系统增加了额外的魔力?

  • 14

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

    似乎 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:不要创建带有副作用的隐式转换运算符 .

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

  • 51

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

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

    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#编译器中的许多错误都是错误缓存决策的结果 . 明智的一句话: every time you cache a fact for use later, you are potentially creating an inconsistency should something relevant change . 在这种情况下,初始分析后发生变化的相关事情是,对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/

  • 402

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

    我通过制作一个处理相同场景的程序的不同版本来得出这个结论,但更简单 .

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

    Full code here

    这只是读取A而不是其他内容 .

    这句话给我看起来应该是我应该:

    • 从括号开始,查看A,返回A并在A不为空时完成 .

    • 如果A为null,则计算B,如果B不为空则结束

    • 如果A和B为空,则评估C.

    因此,由于A不是null,它只查看A并完成 .

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

  • 79

    这绝对是一个错误 .

    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)
    

    这让我觉得每个 ?? coalesce表达式的第一部分都被评估了两次 . 该代码证明了这一点:

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

    输出:

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

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

相关问题