¿Recuerda bien los tipos de valores que aceptan valores NULL? Miramos "debajo del capó"

image1.png


Recientemente, los tipos de referencia que aceptan valores NULL se han convertido en un tema candente. Sin embargo, los buenos tipos de valores que aceptan valores NULL no han desaparecido y todavía se utilizan activamente. ¿Recuerdas bien los matices de trabajar con ellos? Le sugiero que actualice o pruebe sus conocimientos leyendo este artículo. Se incluyen ejemplos de código C # e IL, referencias a la especificación CLI y código CoreCLR. Propongo empezar con un problema interesante.



Nota . Si está interesado en los tipos de referencia que aceptan valores NULL, puede consultar algunos de los artículos de mis colegas: " Tipos de referencia que aceptan valores NULL en C # 8.0 y análisis estático ", " Las referencias que aceptan valores NULL no protegen y Aquí está la prueba ".



Eche un vistazo al código de ejemplo a continuación y responda lo que se enviará a la consola. Y, igualmente importante, por qué. Acordamos de inmediato que responderá como está: sin sugerencias de compilación, documentación, lectura de literatura o algo así. :)



static void NullableTest()
{
  int? a = null;
  object aObj = a;

  int? b = new int?();
  object bObj = b;

  Console.WriteLine(Object.ReferenceEquals(aObj, bObj)); // True or False?
}


image2.png


Bueno, pensemos un poco. Tomemos algunas líneas de pensamiento principales que, me parece, pueden surgir.



1. Partiendo del hecho de que int? - tipo de referencia.



Razonemos así, ¿qué es int? Es un tipo de referencia. En este caso, un valor se escribe en nulo , también se registrará y se aObj después de la asignación. Se escribirá una referencia a algún objeto en b . También se escribirá en bObj después de la asignación. Como resultado, Object.ReferenceEquals tomarán nula y no nula referencia a un objeto como argumentos , así que ...



Es obvio, la respuesta es Falso!



2. Partimos del hecho de que int? - tipo significativo.



¿O tal vez dudas de ese int? - ¿tipo de referencia? ¿Y estás seguro de esto a pesar de la expresión int? a = nulo ? Bueno, vayamos del otro lado y comencemos por lo que es int? - tipo significativo.



En este caso, la expresión int? a = null se ve un poco extraño, pero supongamos que nuevamente en C # se vertió azúcar encima. Resulta que un almacena algún tipo de objeto. b también almacena algún tipo de objeto. Al inicializar las variables aObj y BOBJ , los objetos almacenados en una y b serán embalados, como resultado de lo cual se escribirán diferentes referencias a aObj y bObj . Resulta que Object.ReferenceEquals toma referencias a diferentes objetos como argumentos, por lo tanto ... ¡



Todo es obvio, la respuesta es Falso!



3. Suponemos que aquí se utiliza Nullable <T> .



Digamos que no le gustaron las opciones anteriores. Porque sabes perfectamente que no hay int? en realidad no, pero hay un tipo de valor Nullable <T> , y en este caso se utilizará Nullable <int> . También se entiende que, de hecho, en una y bhabrá objetos idénticos. Al mismo tiempo, no olvidó que al escribir valores en aObj y bObj , se producirá un empaquetado y, como resultado, se obtendrán referencias a diferentes objetos. Dado que Object.ReferenceEquals acepta referencias a diferentes objetos, entonces ... ¡



Es obvio, la respuesta es Falso!



4 .;)



Para aquellos que comenzaron con tipos de valor: si de repente tiene alguna duda sobre la comparación de referencias, puede consultar la documentación sobre Object.ReferenceEquals en docs.microsoft.com... En particular, también toca el tema de los tipos de valores y el boxeo / unboxing. Es cierto que describe un caso en el que las instancias de tipos significativos se pasan directamente al método, sacamos el empaque por separado, pero la esencia es la misma.



Al comparar tipos de valor. Si objA y objB son tipos de valor, se encuadran antes de pasar al método ReferenceEquals. Esto significa que si tanto objA como objB representan la misma instancia de un tipo de valor , el método ReferenceEquals , no obstante, devuelve falso , como muestra el siguiente ejemplo.



Parecería que aquí se puede terminar el artículo, pero solo ... la respuesta correcta es Verdadero .



Bueno, averigüémoslo.



Comprensión



Hay dos formas: simple e interesante.



La manera fácil



¿En t? Es anulable <int> . Abra la Posibilidad de nulos <T> documentación , donde nos fijamos en la sección de "Boxing y unboxing". En principio, eso es todo: el comportamiento se describe allí. Pero si quieres más detalles, te invito por un camino interesante. ;)



Forma interesante



No tendremos suficiente documentación en este camino. Ella describe el comportamiento pero no responde a la pregunta "¿por qué"?



¿Qué es un int en realidad ? y nulo en el contexto apropiado? ¿Por qué funciona así? ¿El código IL usa comandos diferentes o no? ¿El comportamiento es diferente a nivel CLR? ¿Alguna otra magia?



¿Empecemos analizando la entidad int? recordar los conceptos básicos y llegar gradualmente al análisis del caso original. Dado que C # es un lenguaje bastante "delicioso", nos referiremos periódicamente al código IL para ver la esencia de las cosas (sí, la documentación de C # no es nuestro método hoy).



int?, anulable <T>



Aquí veremos los conceptos básicos de los tipos de valores que aceptan valores NULL en principio (qué son, para qué se compilan en IL, etc.). La respuesta a la pregunta de la tarea se analiza en la siguiente sección.



Veamos un fragmento de código.



int? aVal = null;
int? bVal = new int?();
Nullable<int> cVal = null;
Nullable<int> dVal = new Nullable<int>();


Aunque la inicialización de estas variables parece diferente en C #, se generará el mismo código IL para todas ellas.



.locals init (valuetype [System.Runtime]System.Nullable`1<int32> V_0,
              valuetype [System.Runtime]System.Nullable`1<int32> V_1,
              valuetype [System.Runtime]System.Nullable`1<int32> V_2,
              valuetype [System.Runtime]System.Nullable`1<int32> V_3)

// aVal
ldloca.s V_0
initobj  valuetype [System.Runtime]System.Nullable`1<int32>

// bVal
ldloca.s V_1
initobj  valuetype [System.Runtime]System.Nullable`1<int32>

// cVal
ldloca.s V_2
initobj  valuetype [System.Runtime]System.Nullable`1<int32>

// dVal
ldloca.s V_3
initobj  valuetype [System.Runtime]System.Nullable`1<int32>


Como ves, en C # todo se condimenta con azúcar sintáctica del corazón para que tú y yo podamos vivir mejor, de hecho:



  • ¿En t? - tipo significativo.
  • ¿En t? - lo mismo que Nullable <int>. El código IL funciona con Nullable <int32> .
  • ¿En t? aVal = null es lo mismo que Nullable <int> aVal = new Nullable <int> () . En IL, esto se expande en una instrucción initobj que realiza la inicialización predeterminada en la dirección cargada.


Considere el siguiente fragmento de código:



int? aVal = 62;


Descubrimos la inicialización predeterminada: vimos el código IL correspondiente arriba. ¿Qué sucede aquí cuando queremos inicializar aVal en 62?



Echemos un vistazo al código IL:



.locals init (valuetype [System.Runtime]System.Nullable`1<int32> V_0)
ldloca.s   V_1
ldc.i4.s   62
call       instance void valuetype 
           [System.Runtime]System.Nullable`1<int32>::.ctor(!0)


De nuevo, nada complicado: la dirección aVal se carga en la pila de evaluación , así como el valor 62, y luego se llama al constructor con la firma Nullable <T> (T) . Es decir, las siguientes dos expresiones serán completamente idénticas:



int? aVal = 62;
Nullable<int> bVal = new Nullable<int>(62);


Puede ver lo mismo mirando el código IL nuevamente:



// int? aVal;
// Nullable<int> bVal;
.locals init (valuetype [System.Runtime]System.Nullable`1<int32> V_0,
              valuetype [System.Runtime]System.Nullable`1<int32> V_1)

// aVal = 62
ldloca.s   V_0
ldc.i4.s   62
call       instance void valuetype                           
           [System.Runtime]System.Nullable`1<int32>::.ctor(!0)

// bVal = new Nullable<int>(62)
ldloca.s   V_1
ldc.i4.s   62
call       instance void valuetype                             
           [System.Runtime]System.Nullable`1<int32>::.ctor(!0)


¿Y las inspecciones? Por ejemplo, ¿cómo se ve realmente el siguiente código?



bool IsDefault(int? value) => value == null;


Así es, para entenderlo, volvamos al código IL correspondiente nuevamente.



.method private hidebysig instance bool
IsDefault(valuetype [System.Runtime]System.Nullable`1<int32> 'value')
cil managed
{
  .maxstack  8
  ldarga.s   'value'
  call       instance bool valuetype 
             [System.Runtime]System.Nullable`1<int32>::get_HasValue()
  ldc.i4.0
  ceq
  ret
}


Como habrás adivinado, realmente no hay nulo ; todo lo que sucede es una llamada a la propiedad Nullable <T> .HasValue . Es decir, la misma lógica en C # se puede escribir de forma más explícita en términos de las entidades utilizadas de la siguiente manera.



bool IsDefaultVerbose(Nullable<int> value) => !value.HasValue;


Código IL:



.method private hidebysig instance bool 
IsDefaultVerbose(valuetype [System.Runtime]System.Nullable`1<int32> 'value')
cil managed
{
  .maxstack  8
  ldarga.s   'value'
  call       instance bool valuetype 
             [System.Runtime]System.Nullable`1<int32>::get_HasValue()
  ldc.i4.0
  ceq
  ret
}




Resumamos:



  • Los tipos de valores que aceptan valores NULL se implementan a expensas del tipo Nullable <T> ;
  • ¿En t? - en realidad, el tipo construido del tipo de valor genérico Nullable <T> ;
  • ¿En t? a = null - inicialización de un objeto de tipo Nullable <int> con el valor predeterminado, en realidad no hay nulo aquí;
  • if (a == null) - nuevamente, no hay nulo , hay una llamada a la propiedad Nullable <T> .HasValue .


El código fuente del tipo Nullable <T> se puede ver, por ejemplo, en GitHub en el repositorio dotnet / runtime, un enlace directo al archivo de código fuente . No hay mucho código allí, así que por el bien de su interés le aconsejo que lo revise. A partir de ahí, puede aprender (o recordar) los siguientes hechos.



Por conveniencia, el tipo Nullable <T> define:



  • operador de conversión implícita de T a Nullable <T> ;
  • encargado de la conversión explícita de anulable <T> a T .


La lógica principal de trabajo se implementa a través de dos campos (y propiedades correspondientes):



  • Valor T : el valor en sí, que está envuelto con Nullable <T> ;
  • bool hasValue es una bandera que indica si el contenedor contiene un valor. Entre comillas, como en el hecho de que aceptan valores NULL <T> siempre contiene un valor de tipo T .


Ahora que tenemos una memoria refrescante de tipos de valores que aceptan valores NULL, veamos qué pasa con el empaque.



Empaquetado <T> anulable



Permítame recordarle que al empaquetar un objeto de un tipo de valor, se creará un nuevo objeto en el montón. El siguiente fragmento de código ilustra este comportamiento:



int aVal = 62;
object obj1 = aVal;
object obj2 = aVal;

Console.WriteLine(Object.ReferenceEquals(obj1, obj2));


Se espera que el resultado de comparar referencias sea falso , ya que se han producido 2 operaciones de boxeo y se han creado dos objetos, cuyas referencias se escribieron en obj1 y obj2 .



Ahora cambie int a Nullable <int> .



Nullable<int> aVal = 62;
object obj1 = aVal;
object obj2 = aVal;

Console.WriteLine(Object.ReferenceEquals(obj1, obj2));


Aún se espera el resultado: falso .



Y ahora, en lugar de 62, escribimos el valor predeterminado.



Nullable<int> aVal = new Nullable<int>();
object obj1 = aVal;
object obj2 = aVal;

Console.WriteLine(Object.ReferenceEquals(obj1, obj2));


Iii ... el resultado es repentinamente cierto . Parecería que tenemos las mismas 2 operaciones de empaque, creando dos objetos y enlaces a dos objetos diferentes, ¡pero el resultado es cierto !



Sí, probablemente sea azúcar nuevamente, ¡y algo ha cambiado a nivel de código IL! Veamos.



Ejemplo N1.



Código C #:



int aVal = 62;
object aObj = aVal;


Código IL:



.locals init (int32 V_0,
              object V_1)

// aVal = 62
ldc.i4.s   62
stloc.0

//  aVal
ldloc.0
box        [System.Runtime]System.Int32

//     aObj
stloc.1


Ejemplo N2.



Código C #:



Nullable<int> aVal = 62;
object aObj = aVal;


Código IL:



.locals init (valuetype [System.Runtime]System.Nullable`1<int32> V_0,
              object V_1)

// aVal = new Nullablt<int>(62)
ldloca.s   V_0
ldc.i4.s   62
call       instance void
           valuetype [System.Runtime]System.Nullable`1<int32>::.ctor(!0)

//  aVal
ldloc.0
box        valuetype [System.Runtime]System.Nullable`1<int32>

//     aObj
stloc.1


Ejemplo N3.



Código C #:



Nullable<int> aVal = new Nullable<int>();
object aObj = aVal;


Código IL:



.locals init (valuetype [System.Runtime]System.Nullable`1<int32> V_0,
              object V_1)

// aVal = new Nullable<int>()
ldloca.s   V_0
initobj    valuetype [System.Runtime]System.Nullable`1<int32>

//  aVal
ldloc.0
box        valuetype [System.Runtime]System.Nullable`1<int32>

//     aObj
stloc.1


Como podemos ver, el empaquetado se realiza de la misma manera en todas partes: los valores de las variables locales se cargan en la pila de evaluación (instrucción ldloc ), después de lo cual se realiza el empaquetado llamando al comando box , para lo cual se indica el tipo que empaquetaremos.



Pasamos a la especificación de Common Language Infrastructure , miramos la descripción del comando box y encontramos una nota interesante con respecto a los tipos que



aceptan valores NULL: si typeTok es un tipo de valor, la instrucción box convierte val a su forma en caja. ...Si es un tipo que acepta valores NULL, esto se hace inspeccionando la propiedad HasValue de val; si es falso, se inserta una referencia nula en la pila; de lo contrario, el resultado de la propiedad Value de val boxing se coloca en la pila.



A partir de aquí hay varias conclusiones que salpican la 'i':



  • se tiene en cuenta el estado del objeto Nullable <T> (se comprueba el indicador HasValue que consideramos anteriormente ). Si Nullable <T> no contiene un valor ( HasValue es falso ), el cuadro dará como resultado un valor nulo ;
  • si Nullable <T> contiene el valor ( HasValue - true ), entonces no se empaquetará el objeto Nullable <T> , sino una instancia del tipo T , que se almacena en el campo de valor del tipo Nullable <T> ;
  • la lógica específica para manejar el empaquetado que acepta valores Nulos <T> no se implementa en el nivel C # o incluso en el nivel IL; se implementa en CLR.


Volvamos a los ejemplos de Nullable <T> discutidos anteriormente.



Primero:



Nullable<int> aVal = 62;
object obj1 = aVal;
object obj2 = aVal;

Console.WriteLine(Object.ReferenceEquals(obj1, obj2));


Condición del artículo antes del embalaje:



  • T -> int ;
  • valor -> 62 ;
  • hasValue -> verdadero .


El valor 62 se empaqueta dos veces (recuerde que en este caso se empaquetan instancias de tipo int , no Nullable <int> ), se crean 2 nuevos objetos, se obtienen 2 referencias a diferentes objetos, cuyo resultado es falso .



Segundo:



Nullable<int> aVal = new Nullable<int>();
object obj1 = aVal;
object obj2 = aVal;

Console.WriteLine(Object.ReferenceEquals(obj1, obj2));


Condición del artículo antes del embalaje:



  • T -> int ;
  • valor -> predeterminado (en este caso, 0 es el valor predeterminado para int );
  • hasValue -> falso .


Dado que hasValue es falso , no se crean objetos en el montón y la operación de encajonado devuelve un valor nulo , que se escribe en las variables obj1 y obj2 . La comparación de estos valores, como se esperaba, da verdadero .



En el ejemplo original, que estaba al principio del artículo, sucede exactamente lo mismo:



static void NullableTest()
{
  int? a = null;       // default value of Nullable<int>
  object aObj = a;     // null

  int? b = new int?(); // default value of Nullable<int>
  object bObj = b;     // null

  Console.WriteLine(Object.ReferenceEquals(aObj, bObj)); // null == null
}


Para divertirnos, echemos un vistazo al código fuente de CoreCLR del repositorio dotnet / runtime mencionado anteriormente . Estamos interesados ​​en el archivo object.cpp , específicamente en el método Nullable :: Box , que contiene la lógica que necesitamos:



OBJECTREF Nullable::Box(void* srcPtr, MethodTable* nullableMT)
{
  CONTRACTL
  {
    THROWS;
    GC_TRIGGERS;
    MODE_COOPERATIVE;
  }
  CONTRACTL_END;

  FAULT_NOT_FATAL();      // FIX_NOW: why do we need this?

  Nullable* src = (Nullable*) srcPtr;

  _ASSERTE(IsNullableType(nullableMT));
  // We better have a concrete instantiation, 
  // or our field offset asserts are not useful
  _ASSERTE(!nullableMT->ContainsGenericVariables());

  if (!*src->HasValueAddr(nullableMT))
    return NULL;

  OBJECTREF obj = 0;
  GCPROTECT_BEGININTERIOR (src);
  MethodTable* argMT = nullableMT->GetInstantiation()[0].AsMethodTable();
  obj = argMT->Allocate();
  CopyValueClass(obj->UnBox(), src->ValueAddr(nullableMT), argMT);
  GCPROTECT_END ();

  return obj;
}


Aquí está todo lo que hablamos anteriormente. Si no almacenamos el valor, devolvemos NULL :



if (!*src->HasValueAddr(nullableMT))
    return NULL;


De lo contrario, producimos envases:



OBJECTREF obj = 0;
GCPROTECT_BEGININTERIOR (src);
MethodTable* argMT = nullableMT->GetInstantiation()[0].AsMethodTable();
obj = argMT->Allocate();
CopyValueClass(obj->UnBox(), src->ValueAddr(nullableMT), argMT);


Conclusión



En aras del interés, propongo mostrar un ejemplo del principio del artículo a mis colegas y amigos. ¿Podrán dar la respuesta correcta y fundamentarla? Si no es así, invítelos a leer el artículo. Si pueden, bueno, ¡mi respeto!



Espero que haya sido una pequeña pero divertida aventura. :)



PD : Alguien puede tener una pregunta: ¿cómo comenzó la inmersión en este tema? Hicimos una nueva regla de diagnóstico en PVS-Studio sobre el hecho de que Object.ReferenceEquals funciona con argumentos, uno de los cuales está representado por un tipo significativo. De repente resultó que con Nullable <T> hay un momento inesperado en el comportamiento de empaque. Miramos el código IL - caja como caja... Eche un vistazo a la especificación CLI, ¡sí, eso es todo! Parece que este es un caso bastante interesante, que vale la pena contar, ¡una vez! - y el artículo está frente a ti.





Si desea compartir este artículo con una audiencia de habla inglesa, utilice el enlace de traducción: Sergey Vasiliev. Compruebe cómo recuerda los tipos de valores que aceptan valores NULL. Echemos un vistazo debajo del capó .



PPS Por cierto, recientemente he estado un poco más activo en Twitter, donde publico algunos fragmentos de código interesantes, retuiteo algunas noticias interesantes del mundo .NET y algo así. Propongo mirar a través, si está interesado, suscríbase ( enlace al perfil ).



All Articles