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

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