Você se lembra bem dos tipos de valor anuláveis? Nós olhamos "sob o capô"

image1.png


Recentemente, os tipos de referência anuláveis ​​se tornaram um tópico quente. No entanto, os bons e velhos tipos de valor anulável não desapareceram e ainda são usados ​​ativamente. Você se lembra bem das nuances de trabalhar com eles? Eu sugiro que você atualize ou teste seus conhecimentos lendo este artigo. Código de amostra C # e IL, referências à especificação CLI e código CoreCLR estão incluídos. Proponho começar com um problema interessante.



Nota . Se estiver interessado em tipos de referência anuláveis, você pode verificar alguns dos artigos de meus colegas: " Tipos de referência anuláveis ​​em C # 8.0 e análise estática ", " Referência anulável não protegida e aqui está a prova ."



Dê uma olhada no código de exemplo abaixo e responda o que será enviado ao console. E, tão importante, por quê. Vamos concordar imediatamente que você responderá da forma que está: sem dicas do compilador, documentação, leitura de literatura ou algo parecido. :)



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


Bem, vamos pensar um pouco. Tomemos algumas linhas de pensamento principais que, parece-me, podem surgir.



1. A partir do fato de que int? - tipo de referência.



Vamos raciocinar assim, o que é int? É um tipo de referência. Neste caso, um valor é escrito como nulo , ele também será registrado e aObj após a atribuição. Uma referência a algum objeto será escrita em b . Também será escrito em bObj após a atribuição. Como resultado, Object.ReferenceEquals tomará uma referência de objeto nula e não nula como argumentos , então ...



É óbvio, a resposta é False!



2. Partimos do fato de que int? - tipo significativo.



Ou talvez você duvide disso ? - tipo de referência? E você tem certeza disso apesar da expressão int? a = nulo ? Bem, vamos ir do outro lado e começar pelo que é int? - tipo significativo.



Nesse caso, a expressão int? a = null parece um pouco estranho, mas suponha que novamente em C # açúcar foi derramado por cima. Acontece que a armazena algum tipo de objeto. b também armazena algum tipo de objeto. Ao inicializar as variáveis aObj e bObj , os objetos armazenados em a e b serão empacotados, como resultado disso, diferentes referências serão gravadas em aObj e bObj . Acontece que Object.ReferenceEquals leva referências a diferentes objetos como argumentos, portanto ...



Tudo é óbvio, a resposta é falsa!



3. Assumimos que Nullable <T> é usado aqui .



Digamos que você não gostou das opções acima. Porque você sabe perfeitamente bem que não existe int? na verdade não, mas há um tipo de valor Nullable <T> e, neste caso, Nullable <int> será usado . Além disso, você entende que de fato em a e bhaverá objetos idênticos. Ao mesmo tempo, você não se esqueceu de que, ao gravar valores em aObj e bObj , ocorrerá o empacotamento e, como resultado, serão obtidas referências a diferentes objetos. Visto que Object.ReferenceEquals aceita referências a objetos diferentes, então ...



É óbvio, a resposta é False!



4 .;)



Para aqueles que começaram com tipos de valor - se de repente você tiver dúvidas sobre a comparação de referências, consulte a documentação em Object.ReferenceEquals em docs.microsoft.com... Em particular, também aborda o tópico de tipos de valor e embalagem / desempacotamento. É verdade que ele descreve um caso em que instâncias de tipos significativos são passados ​​diretamente para o método, retiramos a embalagem separadamente, mas a essência é a mesma.



Ao comparar tipos de valor. Se objA e objB forem tipos de valor, eles serão encaixotados antes de serem passados ​​para o método ReferenceEquals. Isso significa que se ambos objA e objB representam a mesma instância de um tipo de valor , o método ReferenceEquals retorna false , como mostra o exemplo a seguir.



Parece que aqui o artigo pode ser concluído, mas apenas ... a resposta correta é Verdadeira .



Bem, vamos descobrir.



Compreensão



Existem duas maneiras - simples e interessantes.



O caminho fácil



int? É anulável <int> . Abra o Nullable <T> documentação , onde olhamos para a seção "boxing e unboxing". Em princípio, isso é tudo - o comportamento é descrito lá. Mas se você quiser mais detalhes, eu o convido por um caminho interessante. ;)



Maneira interessante



Não teremos documentação suficiente neste caminho. Ela descreve o comportamento, mas não responde à pergunta 'por quê'?



O que é um int na verdade ? e nulo no contexto apropriado? Por que funciona assim? O código IL usa comandos diferentes ou não? O comportamento é diferente no nível CLR? Qualquer outra magia?



Vamos começar analisando a entidade int? para lembrar o básico e, gradualmente, chegar à análise do caso original. Como C # é uma linguagem bastante "atraente", periodicamente nos referiremos ao código IL para examinar a essência das coisas (sim, a documentação em C # não é o nosso costume hoje).



int?, Nullable <T>



Aqui, veremos os princípios básicos dos tipos de valor anuláveis ​​em princípio (o que são, para que compilam em IL, etc.). A resposta à pergunta da tarefa é discutida na próxima seção.



Vejamos um trecho de código.



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


Embora a inicialização dessas variáveis ​​pareça diferente em C #, o mesmo código IL será gerado para todas elas.



.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 você pode ver, em C # tudo é temperado com açúcar sintático do coração para que você e eu possamos viver melhor, na verdade:



  • int? - tipo significativo.
  • int? - o mesmo que Nullable <int>. O código IL está funcionando com Nullable <int32> .
  • int? aVal = null é o mesmo que Nullable <int> aVal = new Nullable <int> () . Em IL, isso se expande em uma instrução initobj que executa a inicialização padrão no endereço carregado.


Considere o seguinte trecho de código:



int? aVal = 62;


Descobrimos a inicialização padrão - vimos o código IL correspondente acima. O que acontece aqui quando queremos inicializar aVal para 62?



Vamos dar uma olhada no 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)


Novamente, nada complicado - o endereço aVal é carregado na pilha de avaliação , bem como o valor 62, e então o construtor com a assinatura Nullable <T> (T) é chamado. Ou seja, as duas expressões a seguir serão completamente idênticas:



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


Você pode ver o mesmo olhando para o código IL novamente:



// 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)


E as inspeções? Por exemplo, como o código a seguir realmente se parece?



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


Isso mesmo, para compreensão, vamos voltar ao código IL correspondente novamente.



.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 você deve ter adivinhado, realmente não há nulo - tudo o que acontece é uma chamada à propriedade Nullable <T> .HasValue . Ou seja, a mesma lógica em C # pode ser escrita de forma mais explícita em termos das entidades usadas da seguinte maneira.



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
}




Vamos resumir:



  • Os tipos de valor anulável são implementados às custas do tipo Nullable <T> ;
  • int? - na verdade, o tipo construído do tipo de valor genérico Nullable <T> ;
  • int? a = null - inicialização de um objeto do tipo Nullable <int> com o valor padrão, na verdade não há null aqui;
  • if (a == null) - novamente, não há nulo , há uma chamada para a propriedade Nullable <T> .HasValue .


O código-fonte do tipo Nullable <T> pode ser visualizado, por exemplo, no GitHub no repositório dotnet / runtime - um link direto para o arquivo de código-fonte . Não há muito código lá, então, por uma questão de interesse, aconselho você a dar uma olhada. A partir daí, você pode aprender (ou lembrar) os seguintes fatos.



Por conveniência, o tipo Nullable <T> define:



  • operador de conversão implícito de T para Nullable <T> ;
  • operador de conversão explícita do Nullable <T> para T .


A principal lógica de trabalho é implementada por meio de dois campos (e propriedades correspondentes):



  • Valor T - o próprio valor, sobre o qual é Nullable <T> ;
  • bool hasValue é um sinalizador que indica se o wrapper contém um valor. Entre aspas, como de fato Nullable <T> sempre contém um valor de tipo T .


Agora que temos uma atualização sobre os tipos de valor anuláveis, vamos ver o que está acontecendo com o pacote.



Embalagem anulável <T>



Deixe-me lembrá-lo de que ao empacotar um objeto de um tipo de valor, um novo objeto será criado no heap. O seguinte snippet de código ilustra esse comportamento:



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

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


O resultado de referências comparando está prevista para ser falsa , já que 2 operações de boxe têm ocorrido e dois objetos foram criados, as referências a que foram escritos em obj1 e obj2 .



Agora mude int para Nullable <int> .



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

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


O resultado ainda é esperado - falso .



E agora, em vez de 62, escrevemos o valor padrão.



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

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


Iii ... o resultado é repentinamente verdadeiro . Parece que temos as mesmas 2 operações de embalagem, criando dois objetos e links para dois objetos diferentes, mas o resultado é verdadeiro !



Sim, provavelmente é açúcar de novo, e algo mudou no nível do código IL! Vamos ver.



Exemplo 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


Exemplo 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


Exemplo 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, o empacotamento é feito da mesma maneira em todos os lugares - os valores das variáveis ​​locais são carregados na pilha de avaliação (instrução ldloc ), após o que o empacotamento ocorre chamando o comando box , para o qual é indicado qual tipo iremos empacotar.



Voltamo- nos para a especificação Common Language Infrastructure , olhamos para a descrição do comando box e encontramos uma observação interessante sobre tipos anuláveis:



Se typeTok for um tipo de valor, a instrução box converte val para sua forma em caixa. ...Se for um tipo anulável, isso é feito inspecionando a propriedade HasValue de val; se for falso, uma referência nula é colocada na pilha; caso contrário, o resultado da propriedade Value de boxing val é colocado na pilha.



A partir daqui, existem várias conclusões que pontuam o 'i':



  • o estado do objeto Nullable <T> é levado em consideração (o sinalizador HasValue que consideramos anteriormente é verificado ). Se Nullable <T> não contém um valor ( HasValue é falso ), a caixa resultará em nulo ;
  • se Nullable <T> contém o valor ( HasValue - true ), então não o objeto Nullable <T> será compactado , mas uma instância do tipo T , que é armazenada no campo de valor do tipo Nullable <T> ;
  • a lógica específica para lidar com o empacotamento Nullable <T> não é implementada no nível C # ou mesmo no nível IL - é implementada no CLR.


Vamos voltar aos exemplos Nullable <T> discutidos acima.



Primeiro:



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

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


Condição do item antes da embalagem:



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


O valor 62 é empacotado duas vezes (lembre-se que, neste caso, as instâncias do tipo int são empacotadas , e não Nullable <int> ), 2 novos objetos são criados, 2 referências a objetos diferentes são obtidas, o resultado disso é falso .



Segundo:



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

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


Condição do item antes da embalagem:



  • T -> int ;
  • valor -> padrão (neste caso, 0 é o valor padrão para int );
  • hasValue -> false .


Desde hasValue é falsa , há objetos são criados na pilha, eo boxe operação retorna nulo , o que é escrito para as variáveis Obj1 e obj2 . Comparar esses valores, como esperado, resulta em verdade .



No exemplo original, que estava logo no início do artigo, aconteceu exatamente a mesma coisa:



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
}


Por diversão, vamos dar uma olhada no código-fonte do CoreCLR do repositório dotnet / runtime mencionado anteriormente . Estamos interessados ​​no arquivo object.cpp , especificamente - o método Nullable :: Box , que contém a lógica de que precisamos:



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


Aqui está tudo o que falamos acima. Se não armazenarmos o valor, retornamos NULL :



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


Caso contrário, produzimos embalagens:



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


Conclusão



Por uma questão de interesse, proponho mostrar um exemplo do início do artigo aos meus colegas e amigos. Eles serão capazes de dar a resposta correta e substanciá-la? Se não, convide-os a ler o artigo. Se eles puderem - bem, meu respeito!



Espero que tenha sido uma aventura pequena, mas divertida. :)



PS Alguém pode ter uma pergunta: como começou a imersão neste tópico? Fizemos uma nova regra de diagnóstico no PVS-Studio sobre o fato de Object.ReferenceEquals trabalhar com argumentos, um dos quais é representado por um tipo significativo. De repente, descobriu-se que com Nullable <T> há um momento inesperado no comportamento de empacotar. Vimos o código IL - caixa como caixa... Dê uma olhada na especificação CLI - sim, é isso! Pareceu-me um caso bastante interessante, que vale a pena contar - uma vez! - e o artigo está na sua frente.





Se você quiser compartilhar este artigo com um público que fala inglês, use o link de tradução: Sergey Vasiliev. Verifique como você se lembra dos tipos de valor anuláveis. Vamos espiar por baixo do capô .



PPS A propósito, recentemente estou um pouco mais ativo no Twitter, onde posto alguns trechos de código interessantes, retuíto algumas notícias interessantes do mundo .NET e coisas do tipo. Proponho dar uma olhada, se você estiver interessado - inscreva-se ( link para o perfil ).



All Articles