Curioso comportamiento de conversión implícita personalizada del operador de fusión nula

542

Nota: esto parece haberse solucionado en Roslyn

Esta pregunta surgió cuando escribí mi respuesta a esta , que habla sobre la asociatividad del operador de fusión nula .

Solo como recordatorio, la idea del operador de fusión nula es que una expresión de la forma

x ?? y

primero evalúa x, luego:

  • Si el valor de xes nulo, yse evalúa y ese es el resultado final de la expresión
  • Si el valor de xes no nulo, yse no evaluado, y el valor de xes el resultado final de la expresión, después de una conversión con el tipo de tiempo de compilación de ysi es necesario

Ahora, por lo general, no hay necesidad de una conversión, o es solo de un tipo anulable a uno no anulable; por lo general, los tipos son los mismos, o simplemente de (digamos) int?a int. Sin embargo, puede crear sus propios operadores de conversión implícitos, y estos se usan cuando es necesario.

Por el simple caso de x ?? y, no he visto ningún comportamiento extraño. Sin embargo, con (x ?? y) ?? zveo un comportamiento confuso.

Aquí hay un programa de prueba corto pero completo: los resultados están en los comentarios:

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

Entonces, tenemos tres tipos de valores personalizados A, By C, con conversiones de A a B, A a C y B a C.

Puedo entender tanto el segundo caso como el tercer caso ... pero ¿por qué hay una conversión adicional de A a B en el primer caso? En particular, realmente hubiera esperado que el primer caso y el segundo caso fueran lo mismo: después de todo, es solo extraer una expresión en una variable local.

¿Algún tomador de lo que está pasando? Soy extremadamente reticente a gritar "error" cuando se trata del compilador de C #, pero estoy perplejo en cuanto a lo que está sucediendo ...

EDITAR: De acuerdo, aquí hay un ejemplo más desagradable de lo que está sucediendo, gracias a la respuesta del configurador, lo que me da más razones para pensar que es un error. EDITAR: La muestra ni siquiera necesita dos operadores de fusión nula ahora ...

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

El resultado de esto es:

Foo() called
Foo() called
A to int

El hecho de que me Foo()llamen dos veces aquí me sorprende enormemente: no veo ninguna razón para que la expresión se evalúe dos veces.

Jon Skeet
fuente
32
Apuesto a que pensaron que "nadie lo usará de esa manera" :)
cibernó el
57
¿Quieres ver algo peor? Trate de usar esta línea con todas las conversiones implícitas: C? first = ((B?)(((B?)x) ?? ((B?)y))) ?? ((C?)z);. Obtendrá:Internal Compiler Error: likely culprit is 'CODEGEN'
Configurador
55
También tenga en cuenta que esto no sucede cuando se utilizan expresiones Linq para compilar el mismo código.
Configurador
8
@Peter patrón poco probable, pero plausible para(("working value" ?? "user default") ?? "system default")
Factor Mystic
23
@ yes123: Cuando se trataba solo de la conversión, no estaba completamente convencido. Al verlo ejecutar un método dos veces, fue bastante obvio que se trataba de un error. Te sorprendería un comportamiento que parece incorrecto pero que en realidad es completamente correcto. El equipo de C # es más inteligente que yo: tiendo a suponer que soy estúpido hasta que demuestre que algo es su culpa.
Jon Skeet

Respuestas:

418

Gracias a todos los que contribuyeron a analizar este problema. Es claramente un error del compilador. Parece suceder solo cuando hay una conversión elevada que involucra dos tipos anulables en el lado izquierdo del operador de fusión.

Todavía no he identificado dónde exactamente las cosas van mal, pero en algún momento durante la fase de compilación de "reducción anulable", después del análisis inicial pero antes de la generación del código, reducimos la expresión

result = Foo() ?? y;

del ejemplo anterior al equivalente moral de:

A? temp = Foo();
result = temp.HasValue ? 
    new int?(A.op_implicit(Foo().Value)) : 
    y;

Claramente eso es incorrecto; la bajada correcta es

result = temp.HasValue ? 
    new int?(A.op_implicit(temp.Value)) : 
    y;

Mi mejor suposición basada en mi análisis hasta ahora es que el optimizador anulable se está descarrilando aquí. Tenemos un optimizador anulable que busca situaciones en las que sabemos que una expresión particular de tipo anulable no puede ser nula. Considere el siguiente análisis ingenuo: primero podríamos decir que

result = Foo() ?? y;

es lo mismo que

A? temp = Foo();
result = temp.HasValue ? 
    (int?) temp : 
    y;

y entonces podríamos decir que

conversionResult = (int?) temp 

es lo mismo que

A? temp2 = temp;
conversionResult = temp2.HasValue ? 
    new int?(op_Implicit(temp2.Value)) : 
    (int?) null

Pero el optimizador puede intervenir y decir "whoa, espera un minuto, ya verificamos que la temperatura no es nula; no hay necesidad de verificarlo por segunda vez solo porque estamos llamando a un operador de conversión elevado". Los optimizamos para que solo

new int?(op_Implicit(temp2.Value)) 

Supongo que estamos en algún lugar almacenando en caché el hecho de que la forma optimizada de (int?)Foo()es, new int?(op_implicit(Foo().Value))pero esa no es realmente la forma optimizada que queremos; queremos la forma optimizada de Foo () - reemplazado por temporal y luego convertido.

Muchos errores en el compilador de C # son el resultado de malas decisiones de almacenamiento en caché. Una palabra para el sabio: cada vez que almacena un hecho en la memoria caché para usarlo más adelante, potencialmente está creando una inconsistencia en caso de que algo relevante cambie . En este caso, lo relevante que ha cambiado después del análisis inicial es que la llamada a Foo () siempre debe realizarse como una recuperación de un temporal.

Hicimos mucha reorganización del pase de reescritura anulable en C # 3.0. El error se reproduce en C # 3.0 y 4.0 pero no en C # 2.0, lo que significa que el error probablemente fue mi error. ¡Lo siento!

Obtendré un error ingresado en la base de datos y veremos si podemos solucionarlo para una versión futura del idioma. Gracias nuevamente a todos por su análisis; fue muy útil!

ACTUALIZACIÓN: Reescribí el optimizador anulable desde cero para Roslyn; ahora hace un mejor trabajo y evita este tipo de errores extraños. Para algunas ideas sobre cómo funciona el optimizador en Roslyn, vea mi serie de artículos que comienza aquí: https://ericlippert.com/2012/12/20/nullable-micro-optimizations-part-one/

Eric Lippert
fuente
1
@Eric Me pregunto si esto también explicaría: connect.microsoft.com/VisualStudio/feedback/details/642227
MarkPflug
12
Ahora que tengo la Vista previa del usuario final de Roslyn, puedo confirmar que está arreglado allí. (Sin embargo, todavía está presente en el compilador nativo de C # 5).
Jon Skeet
84

Esto definitivamente es un error.

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

Este código generará:

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

Eso me hizo pensar que la primera parte de cada ??expresión de fusión se evalúa dos veces. Este código lo demostró:

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

salidas:

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

Esto parece suceder solo cuando la expresión requiere una conversión entre dos tipos anulables; He intentado varias permutaciones con uno de los lados como una cadena, y ninguna de ellas causó este comportamiento.

configurador
fuente
11
Wow: evaluar la expresión dos veces parece realmente muy incorrecto. Bien descrito.
Jon Skeet
Es un poco más simple ver si solo tiene una llamada al método en la fuente, pero eso aún lo demuestra muy claramente.
Jon Skeet
2
He agregado un ejemplo un poco más simple de esta "doble evaluación" a mi pregunta.
Jon Skeet
8
¿Se supone que todos sus métodos deben generar "X ()"? Resulta un tanto difícil saber qué método se está enviando realmente a la consola.
jeffora
2
Parece que se X() ?? Y()expande internamente a X() != null ? X() : Y(), por lo tanto, por qué se evaluaría dos veces.
Cole Johnson
54

Si observa el código generado para el caso agrupado a la izquierda, en realidad hace algo como esto ( 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);
}

Otro hallazgo, si se utiliza first va a generar un acceso directo si ambos ay bson nulos y de retorno c. Sin embargo, si ao bno es nulo, se vuelve a evaluar acomo parte de la conversión implícita a Bantes de devolver cuál de ao bno es nulo.

De la especificación C # 4.0, §6.1.4:

  • Si la conversión anulable es de S?a T?:
    • Si el valor de origen es null( HasValuepropiedad es false), el resultado es el nullvalor de tipo T?.
    • De lo contrario, la conversión se evalúa como un desenvolvimiento de S?a S, seguido de la conversión subyacente de Sa T, seguido de un envoltorio (§4.1.10) de Ta T?.

Esto parece explicar la segunda combinación de desenvoltura y envoltura.


El compilador C # 2008 y 2010 produce un código muy similar, sin embargo, esto parece una regresión del compilador C # 2005 (8.00.50727.4927) que genera el siguiente código para lo anterior:

A? a = x;
B? b = a.HasValue ? new B?(a.GetValueOrDefault()) : y;
C? first = b.HasValue ? new C?(b.GetValueOrDefault()) : z;

Me pregunto si esto no se debe a la magia adicional dada al sistema de inferencia de tipos.

usuario7116
fuente
+1, pero no creo que realmente explique por qué la conversión se realiza dos veces. Solo debe evaluar la expresión una vez, IMO.
Jon Skeet
@ Jon: He estado jugando y descubrí (como lo hizo @configurator) que cuando se hace en un árbol de expresión funciona como se esperaba. Trabajando en la limpieza de las expresiones para agregarlo a mi publicación. Tendría que afirmar que se trata de un "error".
user7116
@ Jon: ok cuando se utilizan los árboles de expresión se convierte (x ?? y) ?? zen lambdas anidadas, lo que garantiza una evaluación en orden sin doble evaluación. Obviamente, este no es el enfoque adoptado por el compilador de C # 4.0. Por lo que puedo decir, la sección 6.1.4 se aborda de una manera muy estricta en esta ruta de código particular y los temporales no se eluden, lo que resulta en una doble evaluación.
user7116
16

En realidad, llamaré a esto un error ahora, con el ejemplo más claro. Esto aún se mantiene, pero la doble evaluación ciertamente no es buena.

Parece que A ?? Bse implementa como A.HasValue ? A : B. En este caso, también hay mucho casting (siguiendo el casting regular para el ?:operador ternario ). Pero si ignora todo eso, entonces esto tiene sentido en función de cómo se implementa:

  1. A ?? B se expande a A.HasValue ? A : B
  2. Aes nuestra x ?? y. Expandir ax.HasValue : x ? y
  3. reemplazar todas las ocurrencias de A -> (x.HasValue : x ? y).HasValue ? (x.HasValue : x ? y) : B

Aquí puede ver que x.HasValuese verifica dos veces, y si x ?? yrequiere lanzamiento, se xlanzará dos veces.

Lo mencionaría simplemente como un artefacto de cómo ??se implementa, en lugar de un error del compilador. Para llevar: no cree operadores de lanzamiento implícitos con efectos secundarios.

Parece ser un error del compilador que gira en torno a cómo ??se implementa. Para llevar: no anide expresiones de fusión con efectos secundarios.

Philip Rieck
fuente
Oh, yo definitivamente no me gustaría usar un código como éste normalmente, pero creo que podría todavía ser clasificado como un error del compilador en que su primera expansión debe incluir "pero sólo la evaluación de A y B una vez". (Imagínese si fueran llamadas de método)
Jon Skeet
@ Jon Estoy de acuerdo en que podría ser así, pero no lo llamaría claro. Bueno, en realidad, puedo ver que A() ? A() : B()posiblemente evaluará A()dos veces, pero A() ?? B()no tanto. Y dado que solo ocurre en el casting ... Hmm ... acabo de convencerme de que ciertamente no se está comportando correctamente.
Philip Rieck
10

No soy un experto en C # en absoluto, como puede ver en mi historial de preguntas, pero probé esto y creo que es un error ... pero como novato, tengo que decir que no entiendo todo lo que sucede aquí, así que eliminaré mi respuesta si estoy lejos.

Llegué a esta bugconclusión al hacer una versión diferente de su programa que trata con el mismo escenario, pero mucho menos complicado.

Estoy usando tres propiedades enteras nulas con almacenes de respaldo. Puse cada uno a 4 y luego corroint? something2 = (A ?? B) ?? C;

( Código completo aquí )

Esto solo lee la A y nada más.

Esta declaración para mí me parece que debería:

  1. Comience entre paréntesis, mire A, devuelva A y termine si A no es nulo.
  2. Si A era nulo, evalúe B, termine si B no es nulo
  3. Si A y B fueran nulos, evalúe C.

Entonces, como A no es nulo, solo mira a A y termina.

En su ejemplo, poner un punto de interrupción en el primer caso muestra que x, y y z no son todos nulos y, por lo tanto, esperaría que sean tratados de la misma manera que mi ejemplo menos complejo ... pero me temo que soy demasiado de un novato de C # y he perdido completamente el punto de esta pregunta.

Wil
fuente
55
El ejemplo de Jon es un caso de esquina oscuro en el sentido de que está utilizando una estructura anulable (un tipo de valor que es "similar" a los tipos integrados como an int). Empuja el caso aún más en un rincón oscuro al proporcionar múltiples conversiones de tipo implícito. Esto requiere que el compilador cambie el tipo de datos mientras se compara null. Debido a estas conversiones de tipo implícito, su ejemplo es diferente al suyo.
user7116