
Não é segredo que a Microsoft esteja trabalhando na 8ª versão da linguagem C # há algum tempo. A versão do novo idioma (C # 8.0) já está disponível na versão recente do Visual Studio 2019, mas ainda está na versão beta. Esta nova versão terá alguns recursos implementados de uma maneira um tanto não óbvia ou bastante inesperada. Tipos de referência anuláveis são um deles. Esse recurso é anunciado como um meio de combater as exceções de referência nulas (NRE).
É bom ver a linguagem evoluir e adquirir novos recursos para ajudar os desenvolvedores. Por coincidência, há algum tempo, aprimoramos significativamente a capacidade do analisador C # do PVS-Studio em detectar NREs. E agora estamos nos perguntando se os analisadores estáticos em geral e o PVS-Studio em particular ainda devem se preocupar em diagnosticar possíveis desreferências nulas, uma vez que, pelo menos no novo código que fará uso da Referência Nula, essas desreferências se tornarão "impossíveis"? Vamos tentar esclarecer isso.
Prós e contras do novo recurso
Um lembrete antes de continuarmos: a versão beta mais recente do C # 8.0, disponível no momento da redação deste post, tem os tipos de Referência Anulável desativados por padrão, ou seja, o comportamento dos tipos de referência não mudou.
Então, quais são os tipos de referência exatamente anuláveis no C # 8.0, se habilitarmos essa opção? Eles são basicamente os mesmos tipos de referência antigos, exceto que agora você precisará adicionar '?' após o nome do tipo (por exemplo,
string? ), semelhante a
Nullable <T> , ou seja, tipos de valores anuláveis (por exemplo,
int? ). Sem o '?', Nosso tipo de
string agora será interpretado como referência não anulável, ou seja, um tipo de referência que não pode ser atribuído como
nulo .
Exceção de referência nula é uma das exceções mais irritantes para entrar no seu programa, porque não fala muito sobre sua origem, especialmente se o método de arremesso contiver várias operações de desreferência em uma linha. A capacidade de proibir a atribuição nula a uma variável de um tipo de referência parece legal, mas e os casos em que a passagem de um
nulo para um método tem alguma lógica de execução dependendo disso? Em vez de
nulo , poderíamos, é claro, usar um valor literal, constante ou simplesmente "impossível" que logicamente não pode ser atribuído à variável em nenhum outro lugar. Mas isso representa o risco de substituir uma falha do programa por uma execução "silenciosa", mas incorreta, que geralmente é pior do que enfrentar o erro imediatamente.
Que tal lançar uma exceção então? Uma exceção significativa lançada em um local onde algo deu errado é sempre melhor do que um
NRE em algum lugar acima ou abaixo da pilha. Mas isso só é bom em seu próprio projeto, onde você pode corrigir os consumidores inserindo um bloco
try-catch e é de sua exclusiva responsabilidade. Ao desenvolver uma biblioteca usando a Referência Nulável (não), precisamos garantir que um determinado método sempre retorne um valor. Afinal, nem sempre é possível (ou pelo menos fácil), mesmo em seu próprio código, substituir o retorno de
null por lançamento de exceção (já que isso pode afetar muito código).
A Referência Nullable pode ser ativada no nível global do projeto, adicionando a propriedade
NullableContextOptions com o valor
enable, ou no nível do arquivo por meio da diretiva pré-processador:
#nullable enable string cantBeNull = string.Empty; string? canBeNull = null; cantBeNull = canBeNull!;
O recurso Referência Anulável tornará os tipos mais informativos. A assinatura do método fornece uma pista sobre seu comportamento: se ele tem uma verificação nula ou não, se pode retornar
nulo ou não. Agora, quando você tenta usar uma variável de referência anulável sem verificá-la, o compilador emitirá um aviso.
Isso é bastante conveniente ao usar bibliotecas de terceiros, mas também acrescenta o risco de enganar o usuário da biblioteca, pois ainda é possível passar
nulo usando o novo operador que perdoa nulo (!). Ou seja, adicionar apenas um ponto de exclamação pode quebrar todas as outras suposições sobre a interface usando essas variáveis:
#nullable enable String GetStr() { return _count > 0 ? _str : null!; } String str = GetStr(); var len = str.Length;
Sim, você pode argumentar que essa é uma programação ruim e ninguém escreveria um código como esse de verdade, mas enquanto isso puder ser feito, você não poderá se sentir seguro confiando apenas no contrato imposto pela interface de um determinado método ( dizendo que não pode retornar
nulo ).
A propósito, você pode escrever o mesmo código usando vários
! operadores, como o C # agora permite que você faça isso (e esse código é perfeitamente compilável):
cantBeNull = canBeNull!!!!!!!;
Ao escrever dessa maneira, enfatizamos a idéia: "veja, isso pode ser
nulo !!!" (nós, em nossa equipe, chamamos isso de programação "emocional"). De fato, ao criar a árvore de sintaxe, o compilador (de Roslyn) interpreta o
! operador da mesma maneira que interpreta parênteses regulares, o que significa que você pode escrever tantos
! é como você gosta - assim como entre parênteses. Mas se você escrever o suficiente deles, poderá "derrubar" o compilador. Talvez isso seja corrigido na versão final do C # 8.0.
Da mesma forma, você pode contornar o aviso do compilador ao acessar uma variável de referência anulável sem uma verificação:
canBeNull!.ToString();
Vamos adicionar mais emoções:
canBeNull!!!?.ToString();
Você quase nunca verá uma sintaxe assim em código real. Escrevendo o operador que
perdoa nulos , dizemos ao compilador: "Este código está correto, verifique se não é necessário". Adicionando o operador Elvis, dizemos: “Ou talvez não; vamos verificá-lo por precaução. ”
Agora, você pode perguntar razoavelmente por que você ainda pode ter
nulos atribuídos a variáveis de tipos de referência não anuláveis tão facilmente se o próprio conceito desse tipo implica que essas variáveis não podem ter o valor
nulo ? A resposta é que "sob o capô", no nível do código IL, nosso tipo de referência não anulável ainda é ... o bom e velho tipo de referência "regular" e toda a sintaxe de anulabilidade é na verdade apenas uma anotação para o built-in do compilador analisador (que, acreditamos, não é muito conveniente de usar, mas irei detalhar isso mais tarde). Pessoalmente, não achamos uma solução "pura" incluir a nova sintaxe simplesmente como uma anotação para uma ferramenta de terceiros (mesmo incorporada ao compilador) porque o fato de ser apenas uma anotação pode não ser óbvio. para o programador, pois essa sintaxe é muito semelhante à sintaxe para estruturas anuláveis, mas funciona de uma maneira totalmente diferente.
Voltando a outras maneiras de quebrar os tipos de Referência Nulável. No momento em que escrevemos este artigo, quando você tem uma solução composta por vários projetos, passando uma variável de um tipo de referência, por exemplo,
String de um método declarado em um projeto para um método em outro projeto que possui o
NullableContext o compilador assume que está lidando com uma String não anulável e o compilador permanecerá silencioso. E isso apesar das toneladas de atributos
[Nullable (1)] adicionados a todos os campos e métodos no código IL ao ativar as Referências Nullable
. A propósito, esses atributos devem ser levados em consideração se você usar a reflexão para manipular os atributos e assumir que o código contém apenas os personalizados.
Essa situação pode causar problemas adicionais ao adaptar uma base de código grande ao estilo de Referência nula. Esse processo provavelmente estará em execução por um tempo, projeto por projeto. Se você for cuidadoso, é claro, poderá integrar gradualmente o novo recurso, mas se já tiver um projeto em funcionamento, quaisquer alterações nele serão perigosas e indesejáveis (se funcionar, não toque nele!). É por isso que garantimos que você não precise modificar seu código-fonte ou marcá-lo para detectar possíveis
NREs ao usar o analisador PVS-Studio. Para verificar os locais que podem gerar uma
NullReferenceException, basta executar o analisador e procurar por avisos do V3080. Não há necessidade de alterar as propriedades do projeto ou o código fonte. Não há necessidade de adicionar diretivas, atributos ou operadores. Não há necessidade de alterar o código legado.
Ao adicionar o suporte a Nullable Reference no PVS-Studio, tivemos que decidir se o analisador deveria assumir que variáveis de tipos de referência não anuláveis sempre têm valores não nulos. Após investigarmos como essa garantia poderia ser violada, decidimos que o PVS-Studio não deveria fazer essa suposição. Afinal, mesmo que um projeto use tipos de referência não anuláveis o tempo todo, o analisador poderá adicionar esse recurso detectando aquelas situações específicas em que essas variáveis podem ter o valor
nulo .
Como o PVS-Studio procura exceções de referência nulas
Os mecanismos de fluxo de dados no analisador C # do PVS-Studio rastreiam possíveis valores de variáveis durante o processo de análise. Isso também inclui análise interprocedural, ou seja, rastrear possíveis valores retornados por um método e seus métodos aninhados, e assim por diante. Além disso, o PVS-Studio lembra variáveis que podem ter valor
nulo atribuído. Sempre que essa variável for desreferenciada sem verificação, seja no código atual em análise ou dentro de um método invocado nesse código, ele emitirá um aviso V3080 sobre uma possível exceção de referência nula em potencial.
A idéia por trás desse diagnóstico é fazer com que o analisador fique com raiva apenas quando vir uma atribuição
nula . Essa é a principal diferença do comportamento do nosso diagnóstico e do analisador interno do compilador que lida com os tipos de Referência Nula. O analisador embutido apontará para cada desreferência de uma variável de referência nula não verificada - já que não foi enganada pelo uso da
! operador ou mesmo apenas uma verificação complicada (deve-se notar, no entanto, que absolutamente qualquer analisador estático, o PVS-Studio não é uma exceção aqui, pode ser "enganado" de uma maneira ou de outra, especialmente se você pretende fazê-lo).
O PVS-Studio, por outro lado, avisa apenas se vir um
nulo (seja no contexto local ou no contexto de um método externo). Mesmo se a variável for de um tipo de referência não anulável, o analisador continuará apontando para ela se vir uma atribuição
nula para essa variável. Essa abordagem, acreditamos, é mais apropriada (ou pelo menos mais conveniente para o usuário), pois não exige "borrar" todo o código com verificações nulas para rastrear possíveis desreferências - afinal, essa opção estava disponível mesmo antes da referência nula. foram introduzidos, por exemplo, através do uso de contratos. Além disso, o analisador agora pode fornecer um melhor controle sobre as próprias variáveis de referência não anuláveis. Se essa variável for usada "razoavelmente" e nunca for atribuída
nula , o PVS-Studio não dirá uma palavra. Se a variável for atribuída
nula e desreferenciada sem uma verificação prévia, o PVS-Studio emitirá um aviso V3080:
#nullable enable String GetStr() { return _count > 0 ? _str : null!; } String str = GetStr(); var len = str.Length; <== V3080: Possible null dereference. Consider inspecting 'str'
Agora, vamos dar uma olhada em alguns exemplos que demonstram como esse diagnóstico é acionado pelo código do próprio Roslyn. Já
verificamos este projeto recentemente, mas desta vez analisaremos apenas as possíveis exceções de referência nula não mencionadas nos artigos anteriores. Veremos como o PVS-Studio detecta NREs em potencial e como eles podem ser corrigidos usando a nova sintaxe Nullable Reference.
V3080 [CWE-476] Possível desreferência nula dentro do método. Considere inspecionar o segundo argumento: chainedTupleType. Microsoft.CodeAnalysis.CSharp TupleTypeSymbol.cs 244 NamedTypeSymbol chainedTupleType; if (_underlyingType.Arity < TupleTypeSymbol.RestPosition) { .... chainedTupleType = null; } else { .... } return Create(ConstructTupleUnderlyingType(firstTupleType, chainedTupleType, newElementTypes), elementNames: _elementNames);
Como você pode ver, a variável
chainedTupleType pode receber o valor
nulo em uma das ramificações de execução. Em seguida, é passado para o método
ConstructTupleUnderlyingType e usado após uma verificação
Debug.Assert . É um padrão muito comum em Roslyn, mas lembre-se de que o
Debug.Assert foi removido na versão de lançamento. É por isso que o analisador ainda considera perigosa a desreferência dentro do método
ConstructTupleUnderlyingType . Aqui está o corpo desse método, onde a desreferência ocorre:
internal static NamedTypeSymbol ConstructTupleUnderlyingType( NamedTypeSymbol firstTupleType, NamedTypeSymbol chainedTupleTypeOpt, ImmutableArray<TypeWithAnnotations> elementTypes) { Debug.Assert (chainedTupleTypeOpt is null == elementTypes.Length < RestPosition); .... while (loop > 0) { .... currentSymbol = chainedTupleTypeOpt.Construct(chainedTypes); loop--; } return currentSymbol; }
Na verdade, é uma questão em disputa se o analisador deve levar em consideração as declarações assim (alguns de nossos usuários desejam) - afinal, o analisador leva em consideração os contratos do System.Diagnostics.Contracts. Aqui está um pequeno exemplo da vida real de nossa experiência de usar Roslyn em nosso próprio analisador. Ao
adicionar o suporte da versão mais recente do Visual Studio recentemente, também atualizamos o Roslyn para sua terceira versão. Depois disso, o PVS-Studio começou a travar em determinado código que nunca havia travado antes. A falha, acompanhada por uma exceção de referência nula, ocorreria não em nosso código, mas no código de Roslyn. A depuração revelou que o fragmento de código no qual Roslyn estava travando tinha esse tipo de verificação nula baseada em
Debug.Assert várias linhas mais altas - e essa verificação obviamente não ajudou.
É um exemplo gráfico de como você pode ter problemas com o Nullable Reference, porque o compilador trata o
Debug.Assert como uma verificação confiável em qualquer configuração. Ou seja, se você adicionar
#nullable enable e marcar o argumento
chainedTupleTypeOpt como uma referência nula
, o compilador não emitirá nenhum aviso sobre a desreferência dentro do método
ConstructTupleUnderlyingType .
Passando para outros exemplos de avisos do PVS-Studio.
V3080 Possível desreferência nula. Considere inspecionar 'eficazRuleset'. RuleSet.cs 146 var effectiveRuleset = ruleSet.GetEffectiveRuleSet(includedRulesetPaths); effectiveRuleset = effectiveRuleset.WithEffectiveAction(ruleSetInclude.Action); if (IsStricterThan(effectiveRuleset.GeneralDiagnosticOption, ....)) effectiveGeneralOption = effectiveRuleset.GeneralDiagnosticOption;
Esse aviso diz que a chamada do método
WithEffectiveAction pode retornar
nulo , enquanto o valor de retorno atribuído à variável
effectiveRuleset não é verificado antes do uso (
effectiveRuleset.GeneralDiagnosticOption ). Aqui está o corpo do método
WithEffectiveAction :
public RuleSet WithEffectiveAction(ReportDiagnostic action) { if (!_includes.IsEmpty) throw new ArgumentException(....); switch (action) { case ReportDiagnostic.Default: return this; case ReportDiagnostic.Suppress: return null; .... return new RuleSet(....); default: return null; } }
Com a Referência nula ativada para o método
GetEffectiveRuleSet , obteremos dois locais em que o comportamento do código deve ser alterado. Como o método mostrado acima pode gerar uma exceção, é lógico supor que a chamada para ela seja agrupada em um bloco
try-catch e seria correto reescrever o método para gerar uma exceção em vez de retornar
nulo . No entanto, se você rastrear algumas chamadas de volta, verá que o código de captura está muito longe para prever com segurança as conseqüências. Vamos dar uma olhada no consumidor da variável
effectiveRuleset , o método
IsStricterThan :
private static bool IsStricterThan(ReportDiagnostic action1, ReportDiagnostic action2) { switch (action2) { case ReportDiagnostic.Suppress: ....; case ReportDiagnostic.Warn: return action1 == ReportDiagnostic.Error; case ReportDiagnostic.Error: return false; default: return false; } }
Como você pode ver, é uma declaração de opção simples escolhendo entre duas enumerações, com
ReportDiagnostic.Default como o valor padrão. Portanto, seria melhor reescrever a chamada da seguinte maneira:
A assinatura de
WithEffectiveAction mudará:
#nullable enable public RuleSet? WithEffectiveAction(ReportDiagnostic action)
É assim que a chamada será:
RuleSet? effectiveRuleset = ruleSet.GetEffectiveRuleSet(includedRulesetPaths); effectiveRuleset = effectiveRuleset?.WithEffectiveAction(ruleSetInclude.Action); if (IsStricterThan(effectiveRuleset?.GeneralDiagnosticOption ?? ReportDiagnostic.Default, effectiveGeneralOption)) effectiveGeneralOption = effectiveRuleset.GeneralDiagnosticOption;
Como
IsStricterThan realiza apenas a comparação, a condição pode ser reescrita - por exemplo, assim:
if (effectiveRuleset == null || IsStricterThan(effectiveRuleset.GeneralDiagnosticOption, effectiveGeneralOption))
Próximo exemplo.
V3080 Possível desreferência nula. Considere inspecionar 'propertySymbol'. BinderFactory.BinderFactoryVisitor.cs 372 var propertySymbol = GetPropertySymbol(parent, resultBinder); var accessor = propertySymbol.GetMethod; if ((object)accessor != null) resultBinder = new InMethodBinder(accessor, resultBinder);
Para corrigir esse aviso, precisamos ver o que acontece com a variável
propertySymbol a seguir.
private SourcePropertySymbol GetPropertySymbol( BasePropertyDeclarationSyntax basePropertyDeclarationSyntax, Binder outerBinder) { .... NamedTypeSymbol container = GetContainerType(outerBinder, basePropertyDeclarationSyntax); if ((object)container == null) return null; .... return (SourcePropertySymbol)GetMemberSymbol(propertyName, basePropertyDeclarationSyntax.Span, container, SymbolKind.Property); }
O método
GetMemberSymbol também pode retornar
nulo em determinadas condições.
private Symbol GetMemberSymbol( string memberName, TextSpan memberSpan, NamedTypeSymbol container, SymbolKind kind) { foreach (Symbol sym in container.GetMembers(memberName)) { if (sym.Kind != kind) continue; if (sym.Kind == SymbolKind.Method) { .... var implementation = ((MethodSymbol)sym).PartialImplementationPart; if ((object)implementation != null) if (InSpan(implementation.Locations[0], this.syntaxTree, memberSpan)) return implementation; } else if (InSpan(sym.Locations, this.syntaxTree, memberSpan)) return sym; } return null; }
Com os tipos de referência anuláveis ativados, a chamada será alterada para isso:
#nullable enable SourcePropertySymbol? propertySymbol = GetPropertySymbol(parent, resultBinder); MethodSymbol? accessor = propertySymbol?.GetMethod; if ((object)accessor != null) resultBinder = new InMethodBinder(accessor, resultBinder);
É muito fácil de corrigir quando você sabe onde procurar. A análise estática pode capturar esse erro em potencial sem nenhum esforço, coletando todos os valores possíveis do campo de todas as cadeias de chamadas de procedimento.
V3080 Possível desreferência nula. Considere inspecionar 'simpleName'. CSharpCommandLineParser.cs 1556 string simpleName; simpleName = PathUtilities.RemoveExtension( PathUtilities.GetFileName(sourceFiles.FirstOrDefault().Path)); outputFileName = simpleName + outputKind.GetDefaultExtension(); if (simpleName.Length == 0 && !outputKind.IsNetModule()) ....
O problema está na linha da verificação
simpleName.Length . A variável
simpleName resulta da execução de uma longa série de métodos e pode ser atribuída
nula . A propósito, se você estiver curioso, poderá examinar o método
RemoveExtension para ver como ele é diferente de
Path.GetFileNameWithoutExtension. Uma verificação
simpleName! = Null seria suficiente, mas com tipos de referência não anuláveis, o código será alterado para algo como isto:
#nullable enable public static string? RemoveExtension(string path) { .... } string simpleName;
É assim que a chamada pode parecer:
simpleName = PathUtilities.RemoveExtension( PathUtilities.GetFileName(sourceFiles.FirstOrDefault().Path)) ?? String.Empty;
Conclusão
Os tipos de referência anulável podem ser uma grande ajuda ao projetar a arquitetura do zero, mas a reformulação do código existente pode exigir muito tempo e cuidado, pois pode levar a uma série de erros indescritíveis. Este artigo não visa desencorajá-lo a usar os tipos de Referência Anulável. Consideramos esse novo recurso geralmente útil, mesmo que a maneira exata como ele seja implementado possa ser controversa.
No entanto, lembre-se sempre das limitações dessa abordagem e lembre-se de que a ativação do modo de Referência Nula não protege você contra as NREs e que, quando mal utilizadas, pode se tornar a fonte desses erros. Recomendamos que você complemente o recurso Nullable Reference com uma ferramenta de análise estática moderna, como o PVS-Studio, que oferece suporte à análise interprocedural para proteger seu programa contra NREs. Cada uma dessas abordagens - análise interprocedural profunda e assinaturas de métodos de anotação (que é de fato o que o modo Nullable Reference faz) - tem seus prós e contras. O analisador fornecerá uma lista de locais potencialmente perigosos e permitirá ver as consequências da modificação do código existente. Se houver uma atribuição nula em algum lugar, o analisador apontará para cada consumidor da variável onde ela é desreferenciada sem uma verificação.
Você pode verificar este projeto ou seus próprios projetos em busca de outros defeitos - basta
baixar o PVS-Studio e experimentá-lo.