Любопытный нуль-коалесцирующий оператор нестандартного поведения неявного преобразования

Примечание: кажется, это было исправлено в Roslyn

Этот вопрос возник при написании моего ответа на этот, который говорит об ассоциативности нуль-сливающегося оператора.

Как напоминание, идея оператора слияния нулей состоит в том, что выражение вида

x ?? y

сначала оценивает x, затем:

  • Если значение x нулевой, y оценивается, и это является конечным результатом выражения
  • Если значение x ненулевой, 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;
    }
}

Итак, у нас есть три пользовательских типа значений, A, B а также Cс преобразованиями из А в В, А в С и В в С.

Я могу понять и второй случай, и третий случай... но почему в первом случае происходит дополнительное преобразование А в В? В частности, я действительно ожидал, что первый и второй регистры будут одинаковыми - в конце концов, это всего лишь извлечение выражения в локальную переменную.

Есть ли кто-нибудь о том, что происходит? Я крайне неохотно кричу "ошибка", когда дело доходит до компилятора 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() Меня дважды вызывают здесь, это удивляет меня - я не вижу причин для того, чтобы выражение было оценено дважды.

5 ответов

Решение

Спасибо всем, кто внес вклад в анализ этой проблемы. Это явно ошибка компилятора. Похоже, это происходит только тогда, когда есть преобразование отмены, включающее два обнуляемых типа в левой части оператора объединения.

Я еще не определил, где именно что-то пошло не так, но в какой-то момент во время фазы компиляции "обнуляемое понижение" - после первоначального анализа, но до генерации кода - мы уменьшаем выражение

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() является 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/

Это определенно ошибка.

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)

Кажется, это происходит только тогда, когда выражение требует преобразования между двумя обнуляемыми типами; Я пробовал различные перестановки с одной из сторон, являющейся строкой, и ни одна из них не вызывала такого поведения.

Если вы посмотрите на сгенерированный код для левого сгруппированного случая, он на самом деле делает что-то вроде этого (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 это создаст ярлык, если оба a а также b равны нулю и возвращаются c, Еще если a или же b ненулевой, он переоценивает a как часть неявного преобразования в B перед возвратом какой из a или же b не является нулевым.

Из спецификации C# 4.0, §6.1.4:

  • Если обнуляемое преобразование из S? в T?:
    • Если исходное значение null (HasValue свойство false), результатом является null значение типа T?,
    • В противном случае преобразование оценивается как распаковка из S? в Sс последующим базовым преобразованием из S в Tс последующим переносом (§4.1.10) из T в T?,

Похоже, это объясняет вторую комбинацию распаковки и обертывания.


Компилятор 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 В заключение сделайте другую версию вашей программы, которая работает по тому же сценарию, но гораздо менее сложна.

Я использую три целочисленных свойства NULL с резервными хранилищами. Я установил каждый на 4, а затем запустить int? something2 = (A ?? B) ?? C;

( Полный код здесь)

Это просто читает А и ничего больше.

Это утверждение для меня выглядит так:

  1. Начните в скобках, посмотрите на A, верните A и закончите, если A не равно нулю.
  2. Если A был нулевым, оцените B, закончите, если B не нулевой
  3. Если A и B были нулевыми, оцените C.

Таким образом, поскольку A не нуль, он только смотрит на A и завершает.

В вашем примере размещение точки останова в первом случае показывает, что x, y и z не равны нулю, и поэтому я ожидаю, что с ними будут обращаться так же, как в моем менее сложном примере... но я боюсь, что я слишком много новичка в C# и полностью упустили суть этого вопроса!

Другие вопросы по тегам