Tipos de referência anuláveis ​​em C # 8.0 e análise estática

Quadro 9


Não é segredo que a Microsoft esteja trabalhando no lançamento da oitava versão do C # há algum tempo. Na versão recente do Visual Studio 2019, uma nova versão do idioma (C # 8.0) já está disponível, mas até agora apenas como uma versão beta. Os planos para esta nova versão têm vários recursos, cuja implementação pode não parecer muito óbvia, ou melhor, não muito esperada. Uma dessas inovações é a capacidade de usar os tipos de Referência Nula. O significado declarado dessa inovação é a luta contra as exceções de referência nulas (NRE).

Estamos satisfeitos com o desenvolvimento da linguagem e os novos recursos devem ajudar os desenvolvedores. Por coincidência, em nosso analisador PVS-Studio para C #, os recursos para detectar exatamente os mesmos NREs no código foram expandidos relativamente recentemente. E nos perguntamos - existe algum sentido agora para os analisadores estáticos em geral, e para o PVS-Studio em particular, tentar procurar uma desreferenciação potencial de referências nulas, se, pelo menos no novo código usando a Referência Nullable, essa desreferenciação se tornar "impossível" ? Vamos tentar responder a esta pergunta.

Prós e contras da inovação


Para começar, vale lembrar que, na versão beta mais recente do C # 8.0, disponível no momento da redação deste artigo, a Referência Nula é desativada por padrão, ou seja, o comportamento dos tipos de referência não será alterado.

O que são tipos de referência anuláveis ​​no C # 8.0, se você os incluir? Esse é o mesmo bom e antigo tipo de referência, com a diferença de que variáveis ​​desse tipo agora devem ser marcadas com '?' (por exemplo, string? ), semelhante a como já é feito para Nullable <T> , ou seja, tipos significativos anuláveis ​​(por exemplo, int? ). No entanto, agora a mesma string sem '?' já começando a ser interpretado como uma referência não anulável, ou seja, este é um tipo de referência cuja variável não pode conter valores nulos .

A exceção de referência nula é uma das exceções mais irritantes, porque fala pouco sobre a origem do problema, especialmente se houver várias desreferências seguidas no método que lança a exceção. A capacidade de proibir a passagem de nulo para uma variável de referência do tipo parece boa, mas se o nulo anterior foi passado para o método, e alguma lógica de execução adicional foi vinculada a isso, então o que devo fazer agora? Obviamente, você pode passar um valor literal, constante ou simplesmente "impossível" em vez de nulo que, de acordo com a lógica do programa, não pode ser atribuído a essa variável em nenhum outro lugar. No entanto, a queda de todo o programa pode ser substituída por uma execução incorreta "silenciosa". Nem sempre será melhor do que ver o erro imediatamente.

E se, em vez disso, lançar uma exceção? Uma exceção significativa em um lugar onde algo deu errado é sempre melhor do que um NRE em algum lugar mais alto ou mais baixo da pilha. Mas é bom se estivermos falando sobre nosso próprio projeto, onde podemos consertar os consumidores e inserir um bloco try-catch, e ao desenvolver uma biblioteca usando a (Nullable Reference), assumimos a responsabilidade de que algum método sempre retorne um valor. E nem sempre é no código nativo que será (pelo menos simples) substituir o retorno nulo por lançar uma exceção (muito código pode ser afetado).

Você pode habilitar a Referência Nullable em todo o nível do projeto, adicionando a propriedade NullableContextOptions com o valor enable , ou no nível do arquivo usando a diretiva pré-processador:
#nullable enable string cantBeNull = string.Empty; string? canBeNull = null; cantBeNull = canBeNull!; 

Os tipos agora serão mais visuais. Pela assinatura do método, é possível determinar seu comportamento, se ele contém uma verificação de nulo ou não, ele pode retornar nulo ou não. Agora, se você tentar acessar uma variável de referência anulável sem verificar, o compilador gerará um aviso.

Bastante conveniente ao usar bibliotecas de terceiros, mas há uma situação com possível desinformação. O fato é que passar nulo ainda é possível, por exemplo, usando o novo operador que perdoa nulo (!). I.e. é que, com a ajuda de um único ponto de exclamação, você pode quebrar todas as outras suposições que serão feitas sobre uma interface usando essas variáveis:
 #nullable enable String GetStr() { return _count > 0 ? _str : null!; } String str = GetStr(); var len = str.Length; 

Sim, pode-se dizer que é errado escrever dessa maneira, e ninguém nunca fará isso, mas enquanto essa oportunidade permanecer, não será mais possível confiar totalmente apenas no contrato imposto pela interface desse método (que não pode retornar nulo).

E você pode, a propósito, escrever a mesma coisa com a ajuda de vários operadores !, Como o C # agora permite que você escreva assim (e esse código é completamente compilado):
 cantBeNull = canBeNull!!!!!!!; 

I.e. gostaríamos de enfatizar ainda mais: preste atenção - isso pode ser nulo !!! (nós da equipe chamamos isso de programação "emocional"). De fato, o compilador (da Roslyn), ao criar uma árvore de código de sintaxe, interpreta o operador! semelhante a colchetes simples, seu número, como é o caso entre colchetes, é ilimitado. Embora, se você escrever muitos deles, o compilador possa ser "despejado". Talvez isso mude na versão final do C # 8.0.

De maneira semelhante, você pode ignorar o aviso do compilador ao acessar uma variável de referência anulável sem verificar:
 canBeNull!.ToString(); 

Você pode escrever mais emocionalmente:
 canBeNull!!!?.ToString(); 

Essa sintaxe é realmente difícil de imaginar em um projeto real, colocando um operador que perdoa nulos que dizemos ao compilador: está tudo bem aqui, nenhuma verificação é necessária. Adicionando um operador elvis, dizemos: mas em geral pode não ser normal, vamos verificar.

E agora surge uma pergunta legítima - por que, se o conceito de um tipo de referência não anulável implica que uma variável desse tipo não pode conter nulo , ainda podemos escrevê-la com tanta facilidade aí? O fato é que, “sob o capô”, no nível do código IL, nosso tipo de referência não anulável permanece ... todo o mesmo tipo de referência “comum”. E toda a sintaxe de nulidade é na verdade apenas uma anotação para o analisador estático embutido no compilador (e, em nossa opinião, não é o analisador mais conveniente, mas mais sobre isso posteriormente). Em nossa opinião, incluir a nova sintaxe no idioma apenas como anotação para uma ferramenta de terceiros (mesmo que esteja embutida no compilador) não é a solução mais "bonita", porque para um programador que usa essa linguagem, isso é apenas uma anotação pode não ser óbvio - afinal, uma sintaxe muito semelhante para estruturas anuláveis ​​funciona de uma maneira completamente diferente.

Voltando a como ainda é possível "quebrar" os tipos de Referência Anulável. No momento da redação, se houver vários projetos na solução, ao passar de um método declarado em um projeto, uma variável de referência, por exemplo, do tipo String, para um método de outro projeto em que NullableContextOptions está ativado , o compilador decidirá que já é uma String não anulável, e não dará um aviso. E isso apesar do grande número de atributos [Nullable (1)] adicionados a cada método de campo e classe no código IL, quando as Nullable Reference estão ativadas . A propósito, esses atributos devem ser levados em consideração se você estiver trabalhando com uma lista de atributos por meio de reflexão, contando com a existência apenas dos atributos que você adicionou.

Essa situação pode criar problemas adicionais ao converter uma base de código grande em uma Referência Anulável. Muito provavelmente, esse processo será gradual, projeto por projeto. Obviamente, com uma abordagem competente para mudar, você pode mudar gradualmente para um novo funcional, mas se você já tiver um rascunho de trabalho, quaisquer alterações nele serão perigosas e indesejáveis ​​(funciona - não toque!). É por isso que, ao usar o analisador PVS-Studio, não há necessidade de editar o código-fonte ou de alguma forma marcá-lo para detectar possíveis NREs . Para verificar os locais onde uma NullReferenceException pode ocorrer , você só precisa iniciar o analisador e observar os 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 seu código.

Com o suporte dos tipos de Referência nula no analisador PVS-Studio, enfrentamos uma opção - o analisador deve interpretar variáveis ​​de referência não anuláveis ​​como sempre valores diferentes de zero? Depois de estudar a questão das possibilidades de "quebrar" essa garantia, chegamos à conclusão de que não existe - o analisador não deve fazer tal suposição. De fato, mesmo que tipos de referência não anuláveis ​​sejam usados ​​em qualquer lugar do projeto, o analisador pode suplementar seu uso apenas descobrindo situações nas quais um valor nulo pode aparecer em uma variável.

Como o PVS-Studio procura exceções de referência nulas


Os mecanismos de fluxo de dados no analisador C # PVS-Studio monitoram os possíveis valores das variáveis ​​durante a análise. Em particular, o PVS-Studio também realiza análises interprocedurais, ou seja, Ele tenta determinar o possível valor retornado pelo método, bem como os métodos chamados nesse método, etc. Entre outras coisas, o analisador lembra variáveis ​​que podem ser potencialmente nulas . Se no futuro o analisador vir a desreferenciação sem verificar essa variável, novamente, no código atual que está sendo verificado ou dentro do método chamado neste código, será emitido o aviso V3080 sobre uma possível exceção de referência nula.

Ao mesmo tempo, a principal idéia subjacente a esse diagnóstico é que o analisador jurará apenas se encontrar em algum lugar a atribuição de nulo a uma variável. Essa é a principal diferença entre o comportamento desse diagnóstico e o analisador incorporado no compilador que funciona com os tipos de Referência Nula. O analisador embutido no compilador jurará a qualquer desreferência de uma variável de referência nula não verificada do tipo, a menos que, é claro, esse analisador seja "enganado" pelo operador! de qualquer outra maneira, absolutamente qualquer analisador pode ser usado, especialmente se você definir esse objetivo e o PVS-Studio não for uma exceção).

O PVS-Studio jura apenas se for nulo (em um contexto local ou proveniente de um método). Ao mesmo tempo, mesmo que a variável seja uma variável de referência não anulável, o comportamento do analisador não será alterado - ele ainda jurará se vir que nulo foi gravado nela. Essa abordagem nos parece mais correta (ou pelo menos conveniente para o usuário do analisador), pois não é necessário "cobrir" todo o código com verificações nulas para encontrar desreferências em potencial - isso poderia ter sido feito antes, sem uma Referência Anulável, por exemplo, com os mesmos contratos. Além disso, o analisador agora pode ser usado para controle adicional sobre as mesmas variáveis ​​de referência não anuláveis. Se eles forem usados ​​"honestamente" e nunca forem atribuídos nulos - o analisador permanecerá silencioso. Se nulo for designado e a variável for desreferenciada sem verificação, o analisador avisará sobre isso com a mensagem V3080:
 #nullable enable String GetStr() { return _count > 0 ? _str : null!; } String str = GetStr(); var len = str.Length; <== V3080: Possible null dereference. Consider inspecting 'str' 


Vamos considerar alguns exemplos desse disparo dos diagnósticos do V3080 no código do próprio Roslyn. Verificamos este projeto há pouco tempo, mas, desta vez, consideraremos apenas os possíveis gatilhos de exceção de referência nula que não estavam nos artigos anteriores. Vamos ver como o analisador PVS-Studio pode encontrar uma possível desreferenciação de referências nulas e como esses locais 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 ser nula em uma das ramificações de execução de código. Em seguida, chainedTupleType é passado dentro do método ConstructTupleUnderlyingType e é usado lá com verificação por meio de Debug.Assert . Essa situação é muito comum em Roslyn, no entanto, vale lembrar que Debug.Assert é excluído na versão do assembly. Portanto, o analisador ainda considera perigosa a desreferenciação dentro do método ConstructTupleUnderlyingType . A seguir, apresentamos o corpo desse método, onde ocorre a desreferenciação:
 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; } 

Se o analisador deve levar em consideração essa afirmação é realmente um ponto discutível (alguns de nossos usuários desejam fazer isso), porque os contratos da System.Diagnostics.Contracts, por exemplo, o analisador agora leva em consideração. Vou contar apenas um pequeno exemplo do uso real do mesmo Roslyn em nosso analisador. Recentemente, suportamos a nova versão do Visual Studio e, ao mesmo tempo, atualizamos o analisador Roslyn para a versão 3. Depois disso, o analisador começou a cair ao verificar um determinado código no qual não havia travado anteriormente. Ao mesmo tempo, o analisador começou a cair não dentro do nosso código, mas dentro do código do próprio Roslyn - cair com uma exceção de referência nula. E a depuração adicional mostrou que, no local em que Roslyn agora cai, exatamente algumas linhas acima, há a mesma verificação nula no Debug.Assert . E ela, como vemos, não salvou.

Este é um exemplo muito bom de problemas com a Referência Nulável , porque o compilador considera Debug.Assert uma verificação válida em qualquer configuração. Ou seja, se você simplesmente habilitar #nullable enable e marcar o argumento chainedTupleTypeOpt como uma referência nula , não haverá avisos do compilador no local de desreferência no método ConstructTupleUnderlyingType .

Considere o seguinte exemplo de acionamento 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 observa que a chamada do método WithEffectiveAction pode retornar nulo , mas o resultado é usado sem verificação ( effectiveRuleset.GeneralDiagnosticOption ). O corpo do método WithEffectiveAction , que pode retornar nulo, é gravado na variável effectiveRuleset :
 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; } } 


Se você ativar o modo Nullable Reference para o método GetEffectiveRuleSet , teremos dois lugares nos quais precisamos alterar o comportamento. Como existe uma exceção no método acima, é lógico supor que a chamada do método seja agrupada em um bloco try-catch e ela reescreverá o método corretamente, lançando uma exceção em vez de retornar nulo. Mas, escalando os desafios, vemos que a interceptação é alta e as consequências podem ser imprevisíveis. Vejamos a variável de consumidor effectiveRuleset - IsStricterThan método
 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, essa é uma opção simples para duas enumerações com um possível valor de enumeração ReportDiagnostic.Default . Portanto, é melhor reescrever a chamada da seguinte maneira:

A assinatura WithEffectiveAction será alterada:
 #nullable enable public RuleSet? WithEffectiveAction(ReportDiagnostic action) 

a chamada terá a seguinte aparência:
 RuleSet? effectiveRuleset = ruleSet.GetEffectiveRuleSet(includedRulesetPaths); effectiveRuleset = effectiveRuleset?.WithEffectiveAction(ruleSetInclude.Action); if (IsStricterThan(effectiveRuleset?.GeneralDiagnosticOption ?? ReportDiagnostic.Default, effectiveGeneralOption)) effectiveGeneralOption = effectiveRuleset.GeneralDiagnosticOption; 

sabendo que o IsStricterThan realiza apenas comparação - a condição pode ser reescrita, por exemplo:
 if (effectiveRuleset == null || IsStricterThan(effectiveRuleset.GeneralDiagnosticOption, effectiveGeneralOption)) 

Vamos passar para a próxima mensagem do analisador.

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

O uso adicional da variável propertySymbol deve ser levado em consideração ao corrigir o aviso do analisador.
 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 alguns casos.
 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; } 

Usando um tipo de referência anulável, a chamada mudará assim:
 #nullable enable SourcePropertySymbol? propertySymbol = GetPropertySymbol(parent, resultBinder); MethodSymbol? accessor = propertySymbol?.GetMethod; if ((object)accessor != null) resultBinder = new InMethodBinder(accessor, resultBinder); 

Muito simples quando você sabe onde corrigi-lo. A análise estática encontra facilmente esse erro em potencial, obtendo todos os valores de campo possíveis em 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 de verificação de simpleName.Length. simpleName é o resultado de uma cadeia inteira de métodos e pode ser nulo . A propósito, por uma questão de curiosidade, você pode observar o método RemoveExtension e encontrar diferenças em Path.GetFileNameWithoutExtension. Aqui, poderíamos nos restringir à verificação de simpleName! = Null , mas no contexto de links diferentes de zero, o código será mais ou menos assim:
 #nullable enable public static string? RemoveExtension(string path) { .... } string simpleName; 

A chamada terá a seguinte aparência:
 simpleName = PathUtilities.RemoveExtension( PathUtilities.GetFileName(sourceFiles.FirstOrDefault().Path)) ?? String.Empty; 

Conclusão


Os tipos de referência anulável podem ser de grande ajuda no planejamento de uma arquitetura criada a partir do zero, mas a reformulação do código existente pode exigir muito tempo e cuidado, pois pode causar muitos erros sutis. Neste artigo, não pretendemos desencorajar ninguém de usar os tipos de Referência Nulável em nossos projetos. Acreditamos que essa inovação é geralmente útil para o idioma, embora a forma como foi implementada possa suscitar dúvidas.

Você deve sempre se lembrar das limitações inerentes a essa abordagem, e que o modo de Referência Nulável ativado não protege contra erros com a desreferenciação de links nulos e, se usado incorretamente, pode até levar a eles. Vale a pena considerar o uso de um analisador estático moderno, por exemplo, o PVS-Studio, que oferece suporte à análise interprocedural, como uma ferramenta adicional que, junto com a Referência Nullable, pode protegê-lo de não fazer referência a referências nulas. Cada uma dessas abordagens - tanto a análise interprocedural aprofundada quanto a anotação de assinaturas de métodos (que essencialmente faz a referência nula) - tem suas vantagens e desvantagens. O analisador permitirá que você obtenha uma lista de locais potencialmente perigosos e também, ao alterar um código existente, veja todas as consequências de tais alterações. Se você atribuir nulo em qualquer caso, o analisador deve indicar imediatamente a todos os consumidores a variável, onde ela não é verificada antes da referência à referência.

Você pode procurar por outros erros de forma independente, tanto no projeto considerado como no seu. Para fazer isso, basta baixar e experimentar o analisador PVS-Studio.



Se você deseja compartilhar este artigo com um público que fala inglês, use o link para a tradução: Paul Eremeev, Alexander Senichkin. Tipos de referência anuláveis ​​em C # 8.0 e análise estática

Source: https://habr.com/ru/post/pt455230/


All Articles