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


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

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

как напоминание, идея оператора null-coalescing заключается в том, что выражение вида

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;
    }
}

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

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

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

EDIT: хорошо, вот более неприятный пример того, что происходит, благодаря ответу конфигуратора, который дает мне еще одну причину думать, что это ошибка. Изменить: образец даже не нуждается в двух операторах слияния нулей...

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 507

5 ответов:

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

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

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;

мое лучшее предположение, основанное на моем анализе до сих пор, заключается в том, что оптимизатор nullable здесь сходит с рельсов. У нас есть оптимизатор nullable, который ищет ситуации, когда мы знаем, что конкретное выражение типа nullable не может быть 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# являются результатом плохих решений кэширования. Слово к мудрым: каждый раз, когда вы кэшируете факт для использования позже, вы потенциально создаете несоответствие, если что-то важное изменится. В данном случае актуальна вещь, которая изменилась пост первоначальный анализ заключается в том, что вызов Foo() всегда должен быть реализован как выборка временного.

мы провели большую реорганизацию нулевого перезаписывающего прохода в C# 3.0. Ошибка воспроизводится в C# 3.0 и 4.0, но не в C# 2.0, что означает, что ошибка была наверное моя ошибка. Прости!

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

UPDATE: я переписал nullable оптимизатор с нуля для 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)

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

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

выходы:

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

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

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

  • если преобразование nullable от S? до T?:
    • если исходное значение null (HasValue недвижимость false), результат null значение типа T?.
    • в противном случае преобразование оценивается как разворачивание из S? до S, а затем базовое преобразование из S до T, а затем обертывание (§4.1.10) от T to 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 будет брошен дважды.

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

похоже, это компилятор ошибка вращается вокруг того, как есть. Вынос: не гнездите коалесцирующие выражения с побочными эффектами.

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

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

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

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

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

это заявление для меня выглядит так, как мне следует:

  1. процесс пуска в скобках, посмотреть, вернуться и закончить, если это не null.
  2. если был нуль, оценить Б закончить, если б не null
  3. если A и B были null, оцените C.

Итак, поскольку A не является нулем, он только смотрит на А и заканчивает.

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