Fevereiro de 2018

Volume 33 – Número 2

Essential .NET - C# 8.0 e tipos de referência que permitem valor nulo

De Mark Michaelis | Fevereiro de 2018

Tipos de referência que permitem valor nulo —o quê? Nem todos os tipos de referência permitem valor nulo? 

Eu amo o C# e acho fantástico o design cuidadoso da linguagem. Não obstante, da forma como está hoje, e mesmo após sete versões do C#, ainda não temos uma linguagem perfeita. Com isso, quero dizer que, embora seja sensato esperar que sempre haverá novos recursos a adicionar ao C#, infelizmente também há alguns problemas. E, com problemas, não quero dizer bugs, mas erros fundamentais. Talvez uma das maiores áreas problemáticas — e que existe desde o C# 1.0 — está relacionada ao fato de que tipos de referência podem ser nulos e, na verdade, os tipos de referência são nulos por padrão. Veja alguns dos motivos pelos quais os tipos de referência que permitem valor nulo não são ideais:

  • Invocar um membro em um valor nulo gerará uma exceção System.NullReferenceException e todas as invocações resultantes de uma System.NullReferenceException no código de produção são bugs. Infelizmente, entretanto, com os tipos de referência que permitem valor nulo, nós acabamos “caindo em tentação” de fazer o que é errado em vez de fazer o certo. A ação “tentadora” é invocar um tipo de referência sem verificar se há um valor nulo.
  • Há uma inconsistência entre os tipos de referência e os tipos de valor (após a introdução de Nullable<T>), já que os tipos de valor permitem valor nulo quando decorados com “?” (por exemplo, o número int?); caso contrário, o padrão é não permitir valor nulo. Em contrapartida, os tipos de referência permitem valor nulo por padrão. Isso é “normal” para aqueles de nós que programam em C# há muito tempo, mas se pudéssemos fazer tudo novamente, gostaríamos que o padrão para os tipos de referência fosse não permitir valor nulo e que a adição de um “?” fosse uma forma explícita de permitir nulos.
  • Não é possível executar a análise de fluxo estático para verificar em todos os caminhos se um valor será nulo ou não antes de ser desreferenciado. Considere, por exemplo, se houve invocações de código não gerenciado, multithreading ou atribuição/substituição de nulo com base em condições de tempo de execução (sem mencionar se a análise inclui a verificação de todas as APIs de biblioteca invocadas).
  • Não há uma sintaxe sensata para indicar que um valor de tipo de referência nulo é inválido para uma declaração em particular.
  • Não há uma maneira de decorar parâmetros para não permitir nulos.

Como já foi dito, apesar de tudo isso, eu amo tanto o C# que simplesmente aceito o comportamento do nulo como uma idiossincrasia do C#. No entanto, com a versão 8.0, a equipe da linguagem C# tem a intenção de aprimorá-la. Especificamente, eles esperam fazer o seguinte:

  • Fornecer sintaxe para esperar nulos: Permitir que o desenvolvedor identifique explicitamente quando será esperado que um tipo de referência contenha nulos — e, portanto, não sinalize as ocasiões quando os nulos forem explicitamente atribuídos.
  • Fazer com que os tipos de referência padrão esperem tipos que não permitam valor nulo: Alterar a expectativa padrão de todos os tipos de referência para que não permitam valor nulo, mas fazer isso com uma opção de compilador de aceitação em vez de repentinamente saturar o desenvolvedor com avisos para o código existente.
  • Reduzir a ocorrência de NullReferenceExceptions: Reduzir a probabilidade de exceções NullReferenceException ao aprimorar a análise de fluxo estático que sinaliza ocasiões em potencial onde não foi explicitamente verificado se um valor contém nulos antes de invocar um dos membros do valor.
  • Habilitar a supressão de aviso de análise de fluxo estático: Suporte a alguma forma da declaração “confie em mim, sou programador” que permite ao desenvolvedor substituir a análise de fluxo estático do compilador e, portanto, suprimir todos os avisos de uma possível NullReferenceException.

No restante do artigo, vamos considerar cada uma destas metas e como o C# 8.0 implementa o suporte fundamental para elas na linguagem C#.

Fornecer a sintaxe para esperar nulos

Para começar, deve haver uma sintaxe para distinguir quando um tipo de referência deve esperar nulos e quando não deve. A sintaxe óbvia para permitir nulos é o uso do ? como uma declaração que permite valor nulo — para um tipo de valor e um tipo de referência. Ao incluir o suporte a tipos de referência, o desenvolvedor tem a opção de aceitar o nulo com, por exemplo:

string? text = null;

A adição desta sintaxe explica por que o aprimoramento crítico que permite valor nulo é resumido pelo nome aparentemente confuso de “tipos de referência que permitem valor nulo”. Não é porque há alguns tipos de dados de referência que permitem valor nulo, mas porque agora há um suporte — de aceitação — explícito para os tais tipos de dados.

Dada a sintaxe de tipos de referência que permitem valor nulo, qual é a sintaxe do tipo de referência que não permite valor nulo?  Embora isso:

string! text = "Inigo Montoya"

possa parecer uma ótima opção, introduz a questão do que significa ao simplesmente:

string text = GetText();

Ficamos com três declarações, a saber: tipos de referência que permitem valor nulo, tipos de referência que não permitem valor nulo e tipos de referência eu não sei? Eca, não!!

Em vez disso, o que realmente queremos é:

  • Tipos de referência que permitem valor nulo: string? text = null;
  • Tipos de referência que não permitem valor nulo: string text = "Davi Barros"

Isso implica, é claro, em uma alteração significativa na linguagem, que faz com que os tipos de referência sem modificador não permitam valor nulo por padrão.

Fazer com que os tipos de referência padrão esperem tipos que não permitam valor nulo

Mudar as declarações de referência padrão (nenhum modificador que permite valor nulo) para não permitir valor nulo é talvez o requisito mais difícil de todos para reduzir a idiossincrasia que permite valor nulo. O fato é que hoje, o texto de cadeia de caracteres resulta em um tipo de referência chamando texto que permite que o texto seja nulo, espera que o texto seja nulo e, na verdade, tem como padrão texto nulo em vários casos, como em um campo ou em uma matriz. Entretanto, assim como acontece com os tipos de valor, os tipos de referência que permitem que o nulo seja a exceção — e não o padrão. Seria preferível se, quando atribuíssemos nulo a texto ou falhássemos ao inicializar texto para algo diferente de nulo, o compilador sinalizasse qualquer desreferência da variável de texto (o compilador já sinaliza a desreferência de uma variável local antes de sua inicialização).

Infelizmente, isso significa alterar a linguagem e emitir um aviso quando você atribui um nulo (string text = null, por exemplo) ou atribuir um tipo de referência que permite valor nulo (como string? text = null; string moreText = text;). A primeira (string text = null) é uma alteração interruptiva (a emissão de um aviso para algo que antes não incorria em aviso é uma alteração interruptiva).  Para evitar a saturação de desenvolvedores com avisos assim que eles comecem a usar o compilador do C# 8.0, o suporte a valor nulo estará desativado por padrão — portanto, sem alteração interruptiva. Para tirar proveito disso, portanto, você precisará aceitar ao habilitar o recurso. (Observe, entretanto, que na versão prévia disponível no momento da produção deste texto, itl.tc/csnrtp, a Nulidade está ativada por padrão).

É claro que assim que o recurso for habilitado, os avisos aparecerão, apresentando a você a escolha. Escolha explicitamente se o tipo de referência deverá permitir nulos ou não. Caso não permita, remova a atribuição de nulo, removendo, assim, o aviso. No entanto, isso potencialmente introduzirá um aviso mais tarde, já que a variável não estará atribuída e será necessário atribuir um valor não nulo a ela. Como alternativa, se o nulo for explicitamente intencional (representando “desconhecido”, por exemplo), altere o tipo de declaração para permitir valor nulo, como em:

string? text = null;

Reduzir a ocorrência de NullReferenceExceptions

Dada uma forma de declarar tipos como os que permitem ou não um valor nulo, agora será a análise de fluxo estático do compilador quem determinará quando a declaração é potencialmente violada. Embora declarar um tipo de referência como um tipo que permite valor nulo ou evitar uma atribuição de nulo a um tipo que não permite valor nulo funcione, novos avisos ou erros poderão aparecer mais tarde no código. Como já foi mencionado, os tipos de referência que não permitem valor nulo causarão um erro posteriormente no código caso a variável local nunca seja atribuída (isso era verdadeiro para variáveis locais antes do C# 8.0). Caso contrário, a análise de fluxo estático sinalizará qualquer invocação de desreferência de um tipo que permite valor nulo para o qual não puder detectar uma verificação prévia de nulo e/ou de qualquer atribuição do valor que permite valor nulo para um valor diferente de nulo. A Figura 1 mostra alguns exemplos.

Figura 1 Exemplos de resultados da análise de fluxo estático

string text1 = null;
// Warning: Cannot convert null to non-nullable reference
string? text2 = null;
string text3 = text2;
// Warning: Possible null reference assignment
Console.WriteLine( text2.Length ); 
// Warning: Possible dereference of a null reference
if(text2 != null) { Console.WriteLine( text2.Length); }
// Allowed given check for null

De qualquer forma, o resultado final é uma redução nas potenciais NullReference­Exceptions usando a análise de fluxo estático para verificar uma intenção que permita valor nulo.

Como já foi discutido, a análise de fluxo estático deve sinalizar quando um tipo que não permite valor nulo potencialmente terá atribuído um valor nulo — diretamente ou quando for atribuído um tipo que permite valor nulo. Infelizmente, isso não é infalível. Por exemplo, se um método declarar que retorna um tipo de referência que não retorna valor nulo (talvez uma biblioteca que ainda não tenha sido atualizada com modificadores de nulidade) ou um que erroneamente retorne nulo (talvez um aviso tenha sido ignorado), ou uma exceção não fatal ocorra e uma atribuição esperada não seja executada, ainda será possível que um tipo de referência que não permite valor nulo possa terminar com um valor nulo. Isso é lamentável, mas o suporte para os tipos de referência que permitem valor nulo deve reduzir a probabilidade da geração de uma NullReferenceException, embora não a elimine (isso é semelhante à falibilidade da verificação do compilador quando uma variável é atribuída). Da mesma forma, nem sempre a análise de fluxo estático reconhecerá que o código, na verdade, verifica a existência de um nulo antes de desreferenciar um valor. Na verdade, a análise de fluxo só verifica a nulidade em um corpo de método de locais e de parâmetros, e aproveita as assinaturas do método e do operador para determinar a validade. Por exemplo, ela não examina o corpo de um método chamado IsNullOrEmpty para analisar se o método verifica a existência de nulos, de forma que nenhuma verificação de nulos é necessária.

Habilitar a supressão de aviso de análise de fluxo estático

Dada a possível falibilidade da análise de fluxo estático, e se a sua verificação de nulos (talvez com uma chamada como object.ReferenceEquals(s, null) ou string.IsNullOrEmpty()) não for reconhecida pelo compilador? Quando o programador tiver certeza de que um valor não será nulo, ele poderá desreferenciar após o operador ! (por exemplo, text!), como em:

string? text;...
if(object.ReferenceEquals(text, null))
{  var type = text!.GetType()
}

Sem o ponto de exclamação, o compilador avisará sobre uma possível invocação de nulo. Da mesma forma, ao atribuir um valor que permite nulo a um valor que não permite nulo, você pode decorar o valor atribuído com um ponto de exclamação para informar o compilador de que você, o programador, tem certeza de que:

string moreText = text!;

Desta forma, você ode substituir a análise de fluxo estático da mesma forma como pode usar uma conversão explícita. É claro que, no tempo de execução, ainda ocorrerá a verificação adequada.

Conclusão

A introdução do modificador de nulidade para tipos de referência não introduz um novo tipo. Os tipos de referência ainda permitem valor nulo e a compilação de string? resulta em IL, que ainda é simplesmente System.String. A diferença no nível IL é a decoração de tipos modificados que permitem valor nulo com um atributo:

System.Runtime.CompilerServices.NullableAttribute

Dessa forma, as compilações downstream podem continuar a aproveitar a intenção declarada. Além disso, supondo que o atributo esteja disponível, as versões anteriores do C# ainda podem referenciar bibliotecas compiladas pelo C# 8.0 — ainda que sem qualquer melhoria de nulidade. O mais importante é que isso significa que as APIs existentes (como a API .NET) podem ser atualizadas com metadados que permitem valor nulo sem a interrupção da API. Além disso, isso significa que não há suporte para a sobrecarga com base no modificador de nulidade.

Há uma consequência infeliz no aprimoramento da manipulação de nulos no C# 8.0. A transição das declarações que tradicionalmente permitem valor nulo para que não permitam, a princípio introduzirá um número significativo de avisos. Embora isso seja lamentável, acredito que um saldo sensato tenha sido mantido entre irritação com o código e o seu aprimoramento:

  • Avisar você sobre a remoção de uma atribuição de nulo para um tipo que não permite valor nulo potencialmente elimina um bug porque um valor não será mais nulo quando não tiver de ser.
  • Como alternativa, a adição de um modificador que permite valor nulo aprimora seu código ao ser mais explícito sobre a sua intenção.
  • Com o tempo, a incompatibilidade de impedância entre o código que permite valor nulo atualizado e o código antigo se dissolverá, reduzindo o número de bugs de NullReferenceException que costumavam ocorrer.
  • O recurso de nulidade está desativado por padrão em projetos existentes e, portanto, você poderá lidar com ele no momento que escolher. No final, você terá um código mais robusto. Para os casos em que você tiver mais certeza do que o compilador, use o operador ! (declarando “Confie em mim, eu sou um programador.”) como uma conversão.

Outros aprimoramentos no C# 8.0

Existem três áreas adicionais principais de aprimoramento sob consideração para o C# 8.0:

Fluxos Assíncronos: O suporte para fluxos assíncronos permite que a sintaxe de await itere sobre uma coleção de tarefas (Task<bool>). Por exemplo, você pode invocar

foreach await (var data in asyncStream)

e o thread não será bloqueado para quaisquer instruções após o await, mas prosseguirá com eles assim que a iteração for concluída. E o iterador suspenderá o próximo item por meio de solicitação (a solicitação é uma invocação de Task<bool> MoveNextAsync no iterador do fluxo enumerável) seguido por uma chamada a T Current { get; }.

Implementações padrão de interface: Com o C#, você pode implementar várias interfaces, de forma que as assinaturas de cada interface sejam herdadas. Além disso, é possível fornecer uma implementação de membro em uma classe base de forma que todas as classes derivadas tenham uma implementação padrão do membro. Infelizmente, não é possível implementar várias interfaces, além de fornecer implementações padrão da interface — ou seja, herança múltipla. Com a introdução de implementações de interface padrão, nós superamos a restrição. Supondo que uma implementação padrão razoável seja possível, com o C# 8.0, você poderá incluir uma implementação de membro padrão (somente propriedades e métodos) e todas as classes que implementarem a interface terão uma implementação padrão. Embora a herança múltipla pudesse ser um benefício adicional, o aprimoramento real que isso oferece é a capacidade de estender interfaces com membros adicionais sem introduzir uma alteração de API interruptiva. Você poderia, por exemplo, adicionar um método Count a IEnumerator<T> (embora sua implementação exija iteração sobre todos os itens da coleção) sem interromper todas as classes que implementaram a interface. Observe que esse recurso exige uma versão de estrutura correspondente (algo que não era exigido desde o C# 2.0 e genéricos).

Extensão de tudo: Com o LINQ, veio a introdução de métodos de extensão. Lembro-me de um jantar com Anders Hejlsberg naquela época, quando perguntei sobre outros tipos de extensão, como as propriedades. Hejlsberg, me informou que a equipe só estava considerando o que era necessário para a implementação do LINQ. Agora, 10 anos depois, essa suposição está sendo reavaliada e eles estão considerando a adição de métodos de extensão não só para as propriedades, como também para eventos, operadores e até mesmo construtores (esse último revela algumas implementações de padrão de fábrica intrigantes). O ponto importante a ser observado — especialmente em relação às propriedades, é que os métodos de extensão são implementados em classes estáticas e, portanto, não há um estado de instância adicional para o tipo estendido introduzido. Se você precisasse de um estado desse, precisava armazená-lo em uma coleção indexada pela instância do tipo estendido para poder recuperar o estado associado.


Mark Michaelis é fundador da IntelliTect, onde atua como arquiteto técnico principal e instrutor. Há quase 20 anos trabalha como Microsoft MVP, e é Diretor Regional da Microsoft desde 2007. Michaelis atua em diversas equipes de análise de design de software da Microsoft, incluindo C#, Microsoft Azure, SharePoint e Visual Studio ALM. Ele apresenta palestras em conferências de desenvolvedores e escreveu diversos livros, incluindo o mais recente, “Essential C# 7.0 (6th Edition)” (itl.tc/EssentialCSharp). Você pode entrar em contato com ele pelo Facebook, em facebook.com/Mark.Michaelis, pelo seu blog IntelliTect.com/Mark, no Twitter: @markmichaelis ou pelo email mark@IntelliTect.com.

Agradecemos aos seguintes especialistas técnicos da Microsoft pela revisão deste artigo: Kevin Bost, Grant Ericson, Tom Faust, Mads Torgersen


Discuta esse artigo no fórum do MSDN Magazine