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

542

Примечание: похоже, это было исправлено в 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, с преобразованиями из 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()здесь дважды вызывается, удивляет меня - я не вижу причин для того, чтобы выражение было оценено дважды.

Джон Скит
источник
32
Бьюсь об заклад, они думали, что «никто никогда не будет использовать его таким образом» :)
Cyberzed
57
Хотите увидеть что-то худшее? Попробуйте использовать эту линию со всеми неявными преобразованиями: C? first = ((B?)(((B?)x) ?? ((B?)y))) ?? ((C?)z);. Вы получите:Internal Compiler Error: likely culprit is 'CODEGEN'
Конфигуратор
5
Также обратите внимание, что этого не происходит при использовании выражений Linq для компиляции одного и того же кода.
конфигуратор
8
@ Петр маловероятный образец, но вероятный для(("working value" ?? "user default") ?? "system default")
Фактор Мистик
23
@ yes123: Когда дело касалось только преобразования, я не был полностью убежден. Видя, как он выполняет метод дважды, стало ясно, что это ошибка. Вы будете поражены некоторым поведением, которое выглядит неправильно, но на самом деле совершенно правильно. Команда C # умнее меня - я склонен полагать, что я глуп, пока не докажу, что что-то является их ошибкой.
Джон Скит

Ответы:

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;

Насколько я могу судить, исходя из моего анализа на данный момент, то, что обнуляемый оптимизатор сходит с рельсов. У нас есть обнуляемый оптимизатор, который ищет ситуации, когда мы знаем, что определенное выражение типа обнуляемого не может быть нулевым. Рассмотрим следующий наивный анализ: мы могли бы сначала сказать, что

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/

Эрик Липперт
источник
1
@Eric Интересно, это тоже объяснит: connect.microsoft.com/VisualStudio/feedback/details/642227
MarkPflug
12
Теперь, когда у меня есть предварительный просмотр Roslyn для конечного пользователя, я могу подтвердить, что он там исправлен. (Он все еще присутствует в родном компиляторе C # 5.)
Джон Скит
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)

Это заставило меня подумать, что первая часть каждого ??выражения coalesce оценивается дважды. Этот код доказал это:

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

выходы:

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

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

конфигуратор
источник
11
Ух ты - оценка выражения дважды кажется очень неправильной. Хорошо подмечено.
Джон Скит
Немного проще увидеть, если у вас есть только один вызов метода в источнике, но это все еще демонстрирует это очень четко.
Джон Скит
2
Я добавил немного более простой пример этой «двойной оценки» к своему вопросу.
Джон Скит
8
Все ваши методы должны выводить "X ()"? Это несколько затрудняет определение того, какой метод фактически выводится на консоль.
Джеффра
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);
}

Еще одна находка, если вы используете first ее, сгенерирует ярлык, если оба aи bравны null и возвращаются c. Тем не менее, если aили не bравно NULL, оно переоценивается aкак часть неявного преобразования в, Bпрежде чем возвращать, какой из aили не bравен NULL.

Из спецификации 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;

Интересно, это не из-за дополнительной магии, данной системе вывода типов?

user7116
источник
+1, но я не думаю, что это действительно объясняет, почему преобразование выполняется дважды. Следует оценивать выражение только один раз, ИМО.
Джон Скит
@Jon: я поиграл и обнаружил (как это сделал @configurator), что когда он работает в дереве выражений, он работает как положено. Работаю над очисткой выражений, чтобы добавить его в мой пост. Тогда я бы сказал, что это «ошибка».
user7116
@Jon: хорошо, когда используются деревья выражений, они превращаются (x ?? y) ?? zво вложенные лямбды, что обеспечивает оценку по порядку без двойной оценки. Это явно не тот подход, который используется компилятором C # 4.0. Из того, что я могу сказать, к разделу 6.1.4 очень строго подходят в этом конкретном пути кода, и временные значения не исключаются, что приводит к двойной оценке.
user7116
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 один раз». (Представьте, если бы они были вызовами методов.)
Джон Скит
@ Джон, я согласен, что это может быть так же хорошо, но я бы не назвал это четким. Ну, на самом деле, я вижу, что A() ? A() : B(), возможно , оценят A()дважды, но A() ?? B()не так сильно. И так как это происходит только на кастинге ... Хм ... Я только что заставил себя подумать, что это, конечно, ведет себя неправильно.
Филипп Рик
10

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

Я пришел к такому bugвыводу, сделав другую версию вашей программы, которая работает по тому же сценарию, но гораздо менее сложна.

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

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

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

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

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

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

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

Wil
источник
5
Пример Джона в некотором роде неясен, поскольку он использует обнуляемую структуру (тип-значение, «похожий» на встроенные типы, такие как an int). Он толкает дело дальше в неясный угол, предоставляя несколько неявных преобразований типов. Это требует, чтобы компилятор изменил тип данных при проверке null. Именно из-за этих неявных преобразований типов его пример отличается от вашего.
user7116