BT

Disseminando conhecimento e inovação em desenvolvimento de software corporativo.

Contribuir

Tópicos

Escolha a região

Início Artigos Adequando projetos para C# 8 com tipos de referência que permitem valor nulo

Adequando projetos para C# 8 com tipos de referência que permitem valor nulo

Pontos Principais

  • As referências nulas precisam ser habilitadas por projeto;
  • As referências nulas podem ser desabilitadas quando o projeto referenciar Generics;
  • Muitas das advertências podem ser corrigidas cacheando-se propriedades em variáveis locais;
  • Verificações de parâmetros nulos ainda são necessários nos métodos públicos;
  • A desserialização funciona de maneira diferente no .NET Framework e no .NET Core;

Este artigo é um estudo de caso para atualizar uma biblioteca de classe do C# 7 para o C# 8 com tipos de referências que permitem nulo. O projeto usado neste caso, o Tortuga Anchor, é uma coleção de estilos de classes MVVM, códigos com Reflection, e várias funções adicionais. Ele foi escolhido porque é pequeno e possui uma boa mistura de padrões idiomáticos e incomuns no C#.

Configuração do Projeto

Atualmente, os tipos de referências que permitem nulo estão disponíveis apenas em projetos .NET Standard e .NET Core.

No arquivo de projeto precisamos adicionar ou modificar as seguintes configurações:

</PropertyGroup>
    <LangVersion>8.0</LangVersion>
    <NullableContextOptions>enable</NullableContextOptions>
</PropertyGroup>

Assim que salvar o projeto, os erros de referências nulas começarão aparecer. Se não aparecer, tente compilar o projeto.

Indicando que um tipo permite nulo

No método da interface GetPreviousValue, o tipo de retorno pode ser nulo. Para fazer isso de modo explícito, a referência ao objeto que permite nulo deve conter o modificador (?).

object? GetPreviousValue(string propertyName);

Muitos erros no compilador provavelmente serão resolvidos anotando variáveis, parâmetros e tipos de retorno com esse modificador.

Propriedade Lazy loading

Se uma propriedade tem processamento custoso, o padrão lazy-loading pode ser uma solução. Nesse padrão, se um campo privado for nulo, significa que ele ainda não foi gerado.

O C# 8 trabalha bem com esta situação e consegue analisar adequadamente o código, determinando se o retorno será não-nulo, mesmo que a variável retornada permita valores nulos.

string? m_CSharpFullName;
public string CSharpFullName
{
    get
    {
        if (m_CSharpFullName == null)
        {
            var result = new StringBuilder(m_TypeInfo.ToString().Length);
            BuildCSharpFullName(m_TypeInfo.AsType(), null, result);
            m_CSharpFullName = result.ToString();
        }
        return m_CSharpFullName;
    }
}

Deve-se notar que pode haver uma concorrência aqui, e outra thread poderia definir o valor de m_CSharpFullName de volta para null e o compilador não seria capaz de detectá-lo. Devemos tomar um cuidado especial ao manipular código multi-thread.

Uma variável é nula determinada por outra

Neste exemplo, a classe é desenhada de forma que m_ListeningToItemEvents será sempre true se m_ItemPropertyChanged for somente not-null. Não há como o compilador saber sobre esta regra. Quando isso acontecer, é possível acrescentar o operador null-forgiving (!) na variável (neste caso m_ItemPropertyChanged) para indicar que não será nulo naquele momento.

if (m_ListeningToItemEvents)
{
    if (item is INotifyPropertyChangedWeak)
        ((INotifyPropertyChangedWeak)item).AddHandler(m_ItemPropertyChanged!);
    else if (item is INotifyPropertyChanged)
        ((INotifyPropertyChanged)item).PropertyChanged += OnItemPropertyChanged;
}

Corrigindo falsos positivos com conversão explícita

No próximo exemplo, o compilador mostra de forma incorreta que a capacidade de permitir nulo de m_Base.Values não é compatível com IEnumerable<TValue>. Para remover o alerta, é adicionada a conversão explícita conforme exemplo abaixo.

readonly Dictionary<ValueTuple<TKey1, TKey2>, TValue> m_Base;
IEnumerable<TValue> IReadOnlyDictionary<ValueTuple<TKey1, TKey2>, TValue>.Values
{
    get { return (IEnumerable<TValue>)m_Base.Values; }
}

Observe nas linha de resultados do compilador que há uma redundância de conversão. Normalmente esta mensagem é um alerta, mas pode ser resolvida em tempo de release.

Corrigindo falsos positivos com variáveis temporárias ou conversões condicionais

No próximo exemplo, o compilador exibe um erro na linha CancelEdit. Embora o if verifique se item.Value não é nulo, o compilador não confia que na próxima leitura de item.Value ele ainda será não nulo.

foreach (var item in m_CheckpointValues)
{
    if (item.Value is IEditableObject)
        ((IEditableObject)item.Value).CancelEdit();
}

Uma maneira de contornar o problema seria armazenar item.Value numa variável temporária.

foreach (var item in m_CheckpointValues)
{
    object? value = item.Value;
    if (value is IEditableObject)
        ((IEditableObject)value).CancelEdit();
}

Podemos simplificar ainda mais usando a conversão condicional (operador as) seguida por uma chamada de método condicional (operador ?).

foreach (var item in m_CheckpointValues)
{
    (item.Value as IEditableObject)?.CancelEdit();
}

Genéricos e tipos que permitem valor nulo

Com o alto uso de tipos genéricos é possível encontrar problemas com tipos que permitem nulos. Considere este delegate:

public delegate void ValueChanged<in T>(T oldValue, T newValue);

A intenção deste delegate é que oldValue e newValue permitam valores nulos, então talvez seria ideal colocar um '?', porém isso retornaria o erro:

Erro CS8627. Um parâmetro de tipo nulo deve ser conhecido como um tipo de valor ou um tipo de referência que não permite valor nulo. Considere incluir uma 'class', 'struct' ou um tipo de restrição.

Não existe uma maneira fácil de suportar tipos de valor e tipos de referência ao mesmo tempo. Não se pode usar 'or' para tipo de constraint, precisamos criar um delegate para class e outro para struct

public delegate void ValueChanged<in T>(T? oldValue, T? newValue) where T : class;
public delegate void ValueChanged<T>(T? oldValue, T? newValue) where T : struct;

De qualquer forma deste modo não funciona, pois o delegate tem o mesmo nome e se tiver nomes diferentes teríamos que duplicar todo o código.

Felizmente o C# tem uma maneira de contornar isso. Utilizando a diretiva #nullable, você reverte para a semântica do C# 7 e o código continuará funcionando conforme o esperado.

#nullable disable
public delegate void ValueChanged<in T>(T oldValue, T newValue);
#nullable enable

Este modo não é isento de falhas. Desativar o recurso de uma referência de permitir nulo é tudo ou nada, pois não podemos converter oldValue em nullable e newValue em non-nullable.

Construtores, desserializadores e métodos de inicialização

Neste exemplo é necessário conhecer alguns truques dos serializadores. Existe uma função pouco conhecida que ignora o construtor de uma classe, conhecida como FormatterServices.GetUninitializedObject. Alguns serializadores , como o DataContractSerializer, usarão isso para melhorar a performance.

O que acontece caso precise executar sempre a lógica do construtor? Neste caso existe o atributo OnDeserializing. Este atributo trabalha como construtor subtituto logo após a chamada de GetUninitializedObject.

Pessoas desenvolvedoras de software costumam utilizar um método comum de inicialização a fim de reduzir a redundância e as chances de erros conforme abaixo:

protected AbstractModelBase()
{
    Initialize();
}
 [OnDeserializing]
void _ModelBase_OnDeserializing(StreamingContext context)
{
    Initialize();
}
void Initialize()
{
    m_PropertyChangedEventManager = new PropertyChangedEventManager(this);
    m_Errors = new ErrorsDictionary();
}

Isso se torna um problema para a verificação de nulo. Como as duas variáveis não são definidas explicitamente no construtor, serão marcadas como não inicializadas. Isso significa que o trabalho de copiar e colar precisará ser feito para remover o erro.

Ainda existe o risco de esquecer a inclusão do método OnDeserializing. Como a verificação de nulo não entende o método OnDeserializing, não poderá alertar sobre a possibilidade de objetos nulos.

Muitos desenvolvedores consideram confuso esse tipo de comportamento. Portanto, no .NET Core, o DataContractSerializer chamará o construtor. Mas isso significa que com o .NET Standard, será necessário testar o código de desserialização com o .NET Framework e com o .NET Core para verificar diferentes comportamentos.

Parâmetros que permitem nulo e o CallerMemberName

Um padrão que essa biblioteca utiliza muito é o CallerMemberName. Nomeado pelo atributo que utiliza, a ideia é adicionar um parâmetro opcional ao final do método. O compilador verifica o CallerMemberName e implicitamente fornece um valor para esse parâmetro.

public override bool IsDefined([CallerMemberName] string propertyName = null)

Teoricamente o parâmetro propertyName poderia ser definido como nulo, porém possivelmente irá gerar erros inesperados.

Uma maneira de converter esse código para C# 8 seria fazer o parâmetro permitir nulo. Mas isso é um engano pois o método não foi projetado para trabalhar com valores nulos. Neste caso podemos substituir o valor nulo por uma string vazia.

public override bool IsDefined([CallerMemberName] string propertyName = "")

Ainda é preciso verificar argumentos nulos?

Se a construção da biblioteca for pública (i.e. NuGet), então todos os métodos precisam de verificação de argumentos nulos. Os aplicativos que consomem essa biblioteca podem não usar os tipos de referência que permitem nulo, ou talvez nem usar o C# 8.

Se todo o código de uma aplicação utilizar referências que permitem nulo, então, talvez sim. Embora na teoria não será visto nenhum nulo inesperado, podem ainda ser estranhos devido ao código dinâmico, a reflection ou usando o operador null-forgiving (!).

Conclusões

Em um projeto com menos de 60 arquivos de classes, 24 deles exigiram mudanças. Mas nenhum particularmente significativo e enquanto o processo todo levou menos de uma hora. No final das contas foi um processo tranquilo e a maioria das coisas funcionou como o esperado. Espero que muitos projetos sejam beneficiados com esta funcionalidade e sejam aplicadas logo após lançamento do C# 8.

Sobre o Author

Jonathan Allen começou a trabalhar em projetos de MIS - Management Information Systems (sistemas informáticos de gestão) para uma clínica de saúde no final dos anos 90, trouxe aos poucos do Access e do Excel para uma solução empresarial. Após cinco anos escrevendo sistemas de trading para o setor financeiro, Allen se tornou consultor em vários projetos, incluindo a interface de usuário de um armazém automatizado, a camada intermediária de software para uma pesquisa sobre câncer, e big data para uma grande companhia de seguros imobiliários. No seu tempo livre ele gosta de estudar e escrever sobre artes marciais do século XVI.

Avalie esse artigo

Relevância
Estilo/Redação

Conteúdo educacional

BT