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

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

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

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

x ?? y

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

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

Ответы [ 5 ]

412 голосов
/ 08 июня 2011

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

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

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/

84 голосов
/ 07 июня 2011

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

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)

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

54 голосов
/ 06 июня 2011

Если вы посмотрите на сгенерированный код для случая с левой группой, он на самом деле делает что-то вроде этого (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 не равен 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;

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

16 голосов
/ 06 июня 2011

На самом деле, я сейчас назову это ошибкой, с более ясным примером.Это все еще сохраняется, но двойная оценка, конечно, не очень хорошая.

Кажется, что 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 будет сотворено дважды.

Я бы записал это просто как артефакт реализации ??, а не как ошибку компилятора. Еда на вынос: не создавайте неявных операторов приведения с побочными эффектами.

Кажется, это ошибка компилятора, связанная с реализацией ??.Еда на вынос: не вкладывайте коалесцирующие выражения с побочными эффектами.

10 голосов
/ 07 июня 2011

Я вообще не эксперт по 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 # и полностью упустил суть этого вопроса!

...