Любопытный оператор коллаборации с нулевым коалесцентом

Примечание: это, по-видимому, исправлено в Roslyn

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

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

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.

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

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

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

Ответ 1

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

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

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

Но оптимизатор может вмешаться и сказать "whoa, подожди минутку, мы уже проверили, что temp не равен нулю, нет необходимости проверять его на null во второй раз только потому, что мы вызываем оператор отмененного преобразования". Мы бы их оптимизировали до просто

new int?(op_Implicit(temp2.Value)) 

Я предполагаю, что мы где-то кэшируем тот факт, что оптимизированная форма (int?)Foo() равна new int?(op_implicit(Foo().Value)), но на самом деле это не оптимизированная форма; мы хотим оптимизированную форму Foo() - replace-to-tempor-and-then-convert.

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

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

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

ОБНОВЛЕНИЕ: я переписал оптимизатор с нулевым значением с нуля для Roslyn; теперь он делает лучшую работу и избегает таких странных ошибок. Некоторые мысли о том, как работает оптимизатор в Roslyn, см. В моей серии статей, которые начинаются здесь: https://ericlippert.com/2012/12/20/nullable-micro-optimizations-part-one/

Ответ 2

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

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)

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

Ответ 3

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

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

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

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


Компилятор С# 2008 и 2010 производит очень похожий код, однако это выглядит как регрессия из компилятора С# 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;

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

Ответ 4

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

Кажется, что 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: Не создавайте неявных операторов кастингов с побочными эффектами.

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

Ответ 5

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

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

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

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

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

Это выражение мне похоже на то, что он должен:

  • Начните в скобках, посмотрите на A, верните A и закончите, если A не является нулевым.
  • Если A был нулевым, оцените B, завершите, если B не равен null
  • Если A и B были пустыми, оцените C.

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

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