
Você já quis se livrar do problema de desreferenciamento de referência nula? Nesse caso, o uso de tipos de referência anuláveis não é sua escolha. Eu quero saber porque? Isso é o que será discutido hoje.
Nós avisamos e aconteceu. Há cerca de um ano, meus colegas escreveram um artigo avisando que a introdução de tipos de referência anuláveis não protegeria contra o cancelamento de referência nula. Agora temos uma confirmação real de nossas palavras, que foi encontrada nas profundezas de Roslyn.
Tipos de referência anuláveis
A própria ideia de adicionar tipos de referência anuláveis (doravante - NR) parece-me interessante, uma vez que o problema associado à desreferenciação de referências nulas é relevante até hoje. A implementação de proteção contra desreferenciamento é extremamente confiável. Conforme planejado pelos criadores, assumir o valor nulo pode apenas aquelas variáveis cujo tipo está marcado com um "?". Por exemplo, uma variável do tipo string? diz que pode conter null , do tipo string - pelo contrário.
No entanto, ninguém nos proíbe de passar variáveis de referência nulas para não anuláveis de qualquer maneira.(doravante - NNR) tipos, porque eles não são implementados no nível do código IL. O analisador estático embutido no compilador é responsável por essa limitação. Portanto, essa inovação é de natureza bastante consultiva. Aqui está um exemplo simples para mostrar como funciona:
#nullable enable
object? nullable = null;
object nonNullable = nullable;
var deref = nonNullable.ToString();
Como podemos ver, o tipo de nonNullable é especificado como NNR, mas podemos transmitir null com segurança . Obviamente, receberemos um aviso sobre a conversão de "Convertendo literal nulo ou possível valor nulo em tipo não anulável". No entanto, isso pode ser contornado adicionando um pouco de agressão:
#nullable enable
object? nullable = null;
object nonNullable = nullable!; // <=
var deref = nonNullable.ToString();
Um ponto de exclamação e não há avisos. Se um de vocês é um gourmet, então outra opção está disponível:
#nullable enable
object nonNullable = null!;
var deref = nonNullable.ToString();
Bem, mais um exemplo. Criamos dois projetos de console simples. No primeiro, escrevemos:
namespace NullableTests
{
public static class Tester
{
public static string RetNull() => null;
}
}
Na segunda, escrevemos:
#nullable enable
namespace ConsoleApp1
{
class Program
{
static void Main(string[] args)
{
string? nullOrNotNull = NullableTests.Tester.RetNull();
System.Console.WriteLine(nullOrNotNull.Length);
}
}
}
Passe o mouse sobre nullOrNotNull e veja a seguinte mensagem:

Somos informados de que a string não pode ser nula aqui . No entanto, entendemos que será nulo aqui . Começamos o projeto e obtemos uma exceção:

Obviamente, estes são apenas exemplos sintéticos, cujo objetivo é mostrar que esta introdução não garante proteção contra a desreferenciação de referência nula. Se você pensou que os sintéticos são enfadonhos e onde existem exemplos reais, então peço que não se preocupe, então tudo isso será.
Os tipos de NR têm outro problema - não está claro se estão incluídos ou não. Por exemplo, a solução possui dois projetos. Um está marcado com esta sintaxe e o outro não. Tendo entrado em um projeto com tipos NR, você pode decidir que assim que apenas um for marcado, todos serão marcados. No entanto, não será esse o caso. Acontece que você precisa sempre verificar se o contexto anulável está incluído no projeto ou arquivo. Caso contrário, você pode erroneamente pensar que o tipo de referência normal é NNR.
Como a evidência foi encontrada
Ao desenvolver novos diagnósticos no analisador PVS-Studio, sempre os testamos em nossa base de projetos reais. Ajuda em vários aspectos. Por exemplo:
- veja "ao vivo" na qualidade dos avisos recebidos;
- livrar-se de alguns dos falsos positivos;
- encontre pontos interessantes no código, sobre os quais você poderá falar;
- etc.
Um dos novos diagnósticos V3156 encontrou locais onde exceções podem ser lançadas devido a um potencial nulo . O texto da regra de diagnóstico é: "Não se espera que o argumento do método seja nulo". Sua essência é que o método não espera null , em valor pode ser passado como um argumento para null . Isso pode levar, por exemplo, a uma exceção ou execução incorreta do método chamado. Você pode ler mais sobre esta regra de diagnóstico aqui .
Provas aqui
Então chegamos à parte principal deste artigo. Aqui você verá fragmentos de código reais do projeto Roslyn, para os quais o diagnóstico emitiu avisos. Seu significado principal é que o tipo NNR é nulo ou não há verificação do valor do tipo NR. Tudo isso pode levar ao lançamento de uma exceção.
Exemplo 1
private static Dictionary<object, SourceLabelSymbol>
BuildLabelsByValue(ImmutableArray<LabelSymbol> labels)
{
....
object key;
var constantValue = label.SwitchCaseLabelConstant;
if ((object)constantValue != null && !constantValue.IsBad)
{
key = KeyForConstant(constantValue);
}
else if (labelKind == SyntaxKind.DefaultSwitchLabel)
{
key = s_defaultKey;
}
else
{
key = label.IdentifierNodeOrToken.AsNode();
}
if (!map.ContainsKey(key)) // <=
{
map.Add(key, label);
}
....
}
V3156 O primeiro argumento do método 'ContainsKey' não deve ser nulo. Valor nulo potencial: chave. SwitchBinder.cs 121 A
mensagem afirma que a chave é potencialmente nula . Vamos ver onde essa variável pode obter esse valor. Vamos verificar o método KeyForConstant primeiro :
protected static object KeyForConstant(ConstantValue constantValue)
{
Debug.Assert((object)constantValue != null);
return constantValue.IsNull ? s_nullKey : constantValue.Value;
}
private static readonly object s_nullKey = new object();
Como s_nullKey não é nulo , vamos ver o que constantValue.Value retorna :
public object? Value
{
get
{
switch (this.Discriminator)
{
case ConstantValueTypeDiscriminator.Bad: return null; // <=
case ConstantValueTypeDiscriminator.Null: return null; // <=
case ConstantValueTypeDiscriminator.SByte: return Boxes.Box(SByteValue);
case ConstantValueTypeDiscriminator.Byte: return Boxes.Box(ByteValue);
case ConstantValueTypeDiscriminator.Int16: return Boxes.Box(Int16Value);
....
default: throw ExceptionUtilities.UnexpectedValue(this.Discriminator);
}
}
}
Existem dois literais nulos aqui, mas, neste caso, não entraremos em nenhum caso com eles. Isso ocorre devido às verificações IsBad e IsNull . No entanto, gostaria de chamar sua atenção para o tipo de retorno desta propriedade. É um tipo NR, mas o método KeyForConstant já retorna um tipo NNR. Acontece que, em geral, o método KeyForConstant pode retornar nulo . Outra fonte que pode retornar nulo é o método AsNode :
public SyntaxNode? AsNode()
{
if (_token != null)
{
return null;
}
return _nodeOrParent;
}
Novamente, preste atenção ao tipo de retorno do método - é um tipo NR. Acontece que quando dizemos que null pode ser retornado do método , isso não afeta nada. É interessante que o compilador não jura converter de NR para NNR aqui:

Exemplo 2
private SyntaxNode CopyAnnotationsTo(SyntaxNode sourceTreeRoot,
SyntaxNode destTreeRoot)
{
var nodeOrTokenMap = new Dictionary<SyntaxNodeOrToken,
SyntaxNodeOrToken>();
....
if (sourceTreeNodeOrTokenEnumerator.Current.IsNode)
{
var oldNode = destTreeNodeOrTokenEnumerator.Current.AsNode();
var newNode = sourceTreeNodeOrTokenEnumerator.Current.AsNode()
.CopyAnnotationsTo(oldNode);
nodeOrTokenMap.Add(oldNode, newNode); // <=
}
....
}
V3156 O primeiro argumento do método 'Adicionar' não deve ser nulo. Valor nulo potencial: oldNode. SyntaxAnnotationTests.cs 439
Outro exemplo com a função AsNode descrita acima. Só que desta vez oldNode será do tipo NR. Considerando que a chave acima era do tipo NNR.
A propósito, não posso deixar de compartilhar com vocês uma observação interessante. Conforme descrevi acima, ao desenvolver diagnósticos, testamos em diferentes projetos. Ao verificar os aspectos positivos desta regra, um momento curioso foi percebido. Cerca de 70% de todos os avisos foram emitidos para métodos da classe Dictionary . Além disso, a maioria deles caiu no método TryGetValue... Talvez isso se deva ao fato de que, subconscientemente, não esperamos exceções de um método que contém a palavra try . Portanto, verifique seu código quanto a esse padrão para ver se você encontra algo semelhante.
Exemplo 3
private static SymbolTreeInfo TryReadSymbolTreeInfo(
ObjectReader reader,
Checksum checksum,
Func<string, ImmutableArray<Node>,
Task<SpellChecker>> createSpellCheckerTask)
{
....
var typeName = reader.ReadString();
var valueCount = reader.ReadInt32();
for (var j = 0; j < valueCount; j++)
{
var containerName = reader.ReadString();
var name = reader.ReadString();
simpleTypeNameToExtensionMethodMap.Add(typeName, // <=
new ExtensionMethodInfo(containerName, name));
}
....
}
V3156 O primeiro argumento do método 'Adicionar' é passado como um argumento para o método 'TryGetValue' e não se espera que seja nulo. Valor nulo potencial: typeName. SymbolTreeInfo_Serialization.cs 255
O analisador diz que o problema está no typeName . Vamos primeiro ter certeza de que esse argumento é realmente nulo em potencial . Vemos ReadString :
public string ReadString() => ReadStringValue();
Então, olhe para ReadStringValue :
private string ReadStringValue()
{
var kind = (EncodingKind)_reader.ReadByte();
return kind == EncodingKind.Null ? null : ReadStringValue(kind);
}
Ótimo, agora vamos refrescar nossa memória olhando para onde nossa variável foi passada:
simpleTypeNameToExtensionMethodMap.Add(typeName, // <=
new ExtensionMethodInfo(containerName,
name));
Acho que é hora de entrar no método Add :
public bool Add(K k, V v)
{
ValueSet updated;
if (_dictionary.TryGetValue(k, out ValueSet set)) // <=
{
....
}
....
}
Na verdade, se null for passado para o método Add como o primeiro argumento , obteremos uma ArgumentNullException . A propósito, é interessante que se passarmos o cursor sobre typeName no Visual Studio , veremos que seu tipo é string? :

Nesse caso, o tipo de retorno do método é simplesmente string :

Nesse caso, se você criar uma variável do tipo NNR e atribuir a ela typeName , nenhum erro será exibido.
Vamos tentar largar Roslyn
Não por malícia, mas por diversão, sugiro tentar reproduzir um dos exemplos apresentados.

Teste 1
Vamos dar o exemplo descrito no número 3:
private static SymbolTreeInfo TryReadSymbolTreeInfo(
ObjectReader reader,
Checksum checksum,
Func<string, ImmutableArray<Node>,
Task<SpellChecker>> createSpellCheckerTask)
{
....
var typeName = reader.ReadString();
var valueCount = reader.ReadInt32();
for (var j = 0; j < valueCount; j++)
{
var containerName = reader.ReadString();
var name = reader.ReadString();
simpleTypeNameToExtensionMethodMap.Add(typeName, // <=
new ExtensionMethodInfo(containerName, name));
}
....
}
Para reproduzi-lo, você precisa chamar o método TryReadSymbolTreeInfo , mas é privado . É bom que a classe com ele tenha um método ReadSymbolTreeInfo_ForTestingPurposesOnly , que já é interno :
internal static SymbolTreeInfo ReadSymbolTreeInfo_ForTestingPurposesOnly(
ObjectReader reader,
Checksum checksum)
{
return TryReadSymbolTreeInfo(reader, checksum,
(names, nodes) => Task.FromResult(
new SpellChecker(checksum,
nodes.Select(n => new StringSlice(names,
n.NameSpan)))));
}
É muito agradável que nos seja oferecido diretamente para testar o método TryReadSymbolTreeInfo . Portanto, vamos criar nossa classe lado a lado e escrever o seguinte código:
public class CheckNNR
{
public static void Start()
{
using var stream = new MemoryStream();
using var writer = new BinaryWriter(stream);
writer.Write((byte)170);
writer.Write((byte)9);
writer.Write((byte)0);
writer.Write(0);
writer.Write(0);
writer.Write(1);
writer.Write((byte)0);
writer.Write(1);
writer.Write((byte)0);
writer.Write((byte)0);
stream.Position = 0;
using var reader = ObjectReader.TryGetReader(stream);
var checksum = Checksum.Create("val");
SymbolTreeInfo.ReadSymbolTreeInfo_ForTestingPurposesOnly(reader, checksum);
}
}
Agora coletamos Roslyn , criamos um aplicativo de console simples, conectamos todos os arquivos dll necessários e escrevemos o seguinte código:
static void Main(string[] args)
{
CheckNNR.Start();
}
Começamos, chegamos ao local desejado e vemos:

Em seguida, vá para o método Add e obtenha a exceção esperada:

Deixe-me lembrar a você que o método ReadString retorna um tipo NNR, que, por design, não pode conter nulo . Este exemplo mais uma vez confirma a relevância das regras de diagnóstico do PVS-Studio para pesquisar a desreferenciação de referências nulas.
Teste 2
Bem, já que já começamos a reproduzir exemplos, por que não reproduzir mais um. Este exemplo não estará relacionado aos tipos de NR. No entanto, o mesmo diagnóstico V3156 o encontrou e eu gostaria de falar sobre ele. Aqui está o código:
public SyntaxToken GenerateUniqueName(SemanticModel semanticModel,
SyntaxNode location,
SyntaxNode containerOpt,
string baseName,
CancellationToken cancellationToken)
{
return GenerateUniqueName(semanticModel,
location,
containerOpt,
baseName,
filter: null,
usedNames: null, // <=
cancellationToken);
}
V3156 O sexto argumento do método 'GenerateUniqueName' é passado como um argumento para o método 'Concat' e não se espera que seja nulo. Valor nulo potencial: nulo. AbstractSemanticFactsService.cs 24
Vou ser sincero: ao fazer este diagnóstico, não esperava nenhum positivo na linha reta nula . Afinal, é bastante estranho enviar null para um método que lançará uma exceção por causa disso. Embora eu tenha visto lugares onde isso era justificado (por exemplo, com a classe Expression ), mas agora não é mais sobre isso.
Portanto, fiquei muito intrigado quando vi esse aviso. Vamos ver o que acontece no método GenerateUniqueName .
public SyntaxToken GenerateUniqueName(SemanticModel semanticModel,
SyntaxNode location,
SyntaxNode containerOpt,
string baseName,
Func<ISymbol, bool> filter,
IEnumerable<string> usedNames,
CancellationToken cancellationToken)
{
var container = containerOpt ?? location
.AncestorsAndSelf()
.FirstOrDefault(a => SyntaxFacts.IsExecutableBlock(a)
|| SyntaxFacts.IsMethodBody(a));
var candidates = GetCollidableSymbols(semanticModel,
location,
container,
cancellationToken);
var filteredCandidates = filter != null ? candidates.Where(filter)
: candidates;
return GenerateUniqueName(baseName,
filteredCandidates.Select(s => s.Name)
.Concat(usedNames)); // <=
}
Vemos que há apenas uma maneira de sair do método, nenhuma exceção é lançada e nenhum goto . Em outras palavras, nada impede que você passe usedNames para o método Concat e obtenha uma ArgumentNullException .
Mas tudo isso são palavras, vamos lá. Para fazer isso, veja onde você pode chamar esse método. O próprio método está na classe AbstractSemanticFactsService . A classe é abstrata, portanto, por conveniência, vamos usar a classe CSharpSemanticFactsService , que herda dela. No arquivo desta classe, criaremos o nosso próprio, que chamará o método GenerateUniqueName . Se parece com isso:
public class DropRoslyn
{
private const string ProgramText =
@"using System;
using System.Collections.Generic;
using System.Text
namespace HelloWorld
{
class Program
{
static void Main(string[] args)
{
Console.WriteLine(""Hello, World!"");
}
}
}";
public void Drop()
{
var tree = CSharpSyntaxTree.ParseText(ProgramText);
var instance = CSharpSemanticFactsService.Instance;
var compilation = CSharpCompilation
.Create("Hello World")
.AddReferences(MetadataReference
.CreateFromFile(typeof(string)
.Assembly
.Location))
.AddSyntaxTrees(tree);
var semanticModel = compilation.GetSemanticModel(tree);
var syntaxNode1 = tree.GetRoot();
var syntaxNode2 = tree.GetRoot();
var baseName = "baseName";
var cancellationToken = new CancellationToken();
instance.GenerateUniqueName(semanticModel,
syntaxNode1,
syntaxNode2,
baseName,
cancellationToken);
}
}
Agora coletamos Roslyn, criamos um aplicativo de console simples, conectamos todos os arquivos dll necessários e escrevemos o seguinte código:
class Program
{
static void Main(string[] args)
{
DropRoslyn dropRoslyn = new DropRoslyn();
dropRoslyn.Drop();
}
}
Lançamos o aplicativo e obtemos o seguinte:

Isso é enganoso
Digamos que concordamos com o conceito anulável. Acontece que se vemos um tipo NR, então acreditamos que ele pode conter um nulo potencial . No entanto, às vezes você pode ver situações em que o compilador nos diz o contrário. Portanto, serão considerados aqui alguns casos em que o uso deste conceito não é intuitivo.
Caso 1
internal override IEnumerable<SyntaxToken>? TryGetActiveTokens(SyntaxNode node)
{
....
var bodyTokens = SyntaxUtilities
.TryGetMethodDeclarationBody(node)
?.DescendantTokens();
if (node.IsKind(SyntaxKind.ConstructorDeclaration,
out ConstructorDeclarationSyntax? ctor))
{
if (ctor.Initializer != null)
{
bodyTokens = ctor.Initializer
.DescendantTokens()
.Concat(bodyTokens); // <=
}
}
return bodyTokens;
}
V3156 O primeiro argumento do método 'Concat' não deve ser nulo. Valor nulo potencial: bodyTokens. CSharpEditAndContinueAnalyzer.cs 219 Vamos dar uma
olhada em por que bodyTokens é potencialmente nulo e ver o operador condicional nulo :
var bodyTokens = SyntaxUtilities
.TryGetMethodDeclarationBody(node)
?.DescendantTokens(); // <=
Se entrarmos no método TryGetMethodDeclarationBody , veremos que ele pode retornar nulo . No entanto, é relativamente grande, então deixo um link para ele se você quiser ver por si mesmo. Com bodyTokens tudo fica claro, mas quero chamar a atenção para o argumento ctor :
if (node.IsKind(SyntaxKind.ConstructorDeclaration,
out ConstructorDeclarationSyntax? ctor))
Como podemos ver, seu tipo é definido como NR. Nesse caso, a desreferenciação ocorre com a linha abaixo:
if (ctor.Initializer != null)
Essa combinação é um pouco alarmante. No entanto, você pode dizer que, provavelmente, se IsKind retornar verdadeiro , ctor definitivamente não é nulo . Do jeito que está:
public static bool IsKind<TNode>(
[NotNullWhen(returnValue: true)] this SyntaxNode? node, // <=
SyntaxKind kind,
[NotNullWhen(returnValue: true)] out TNode? result) // <=
where TNode : SyntaxNode
{
if (node.IsKind(kind))
{
result = (TNode)node;
return true;
}
result = null;
return false;
}
Aqui, atributos especiais são usados para indicar em qual valor de saída os parâmetros não serão nulos . Podemos verificar isso observando a lógica do método IsKind . Acontece que dentro da condição, o tipo de ctor deve ser NNR. O compilador entende isso e diz que o ctor dentro da condição não será nulo . No entanto, para entender isso para nós, devemos ir para o método IsKind e observar o atributo lá. Caso contrário, parece que está sendo cancelada a referência a uma variável NR sem verificar se há nulo . Você pode tentar adicionar alguma clareza como esta:
if (node.IsKind(SyntaxKind.ConstructorDeclaration,
out ConstructorDeclarationSyntax? ctor))
{
if (ctor!.Initializer != null) // <=
{
....
}
}
Caso 2
public TextSpan GetReferenceEditSpan(InlineRenameLocation location,
string triggerText,
CancellationToken cancellationToken)
{
var searchName = this.RenameSymbol.Name;
if (_isRenamingAttributePrefix)
{
searchName = GetWithoutAttributeSuffix(this.RenameSymbol.Name);
}
var index = triggerText.LastIndexOf(searchName, // <=
StringComparison.Ordinal);
....
}
V3156 O primeiro argumento do método 'LastIndexOf' não deve ser nulo. Valor nulo potencial: searchName. AbstractEditorInlineRenameService.SymbolRenameInfo.cs 126
Estamos interessados na variável searchName . null pode ser gravado nele depois de chamar o método GetWithoutAttributeSuffix , mas não é tão simples. Vamos ver o que acontece nele:
private string GetWithoutAttributeSuffix(string value)
=> value.GetWithoutAttributeSuffix(isCaseSensitive:
_document.GetRequiredLanguageService<ISyntaxFactsService>()
.IsCaseSensitive)!;
Vamos aprofundar:
internal static string? GetWithoutAttributeSuffix(
this string name,
bool isCaseSensitive)
{
return TryGetWithoutAttributeSuffix(name, isCaseSensitive, out var result)
? result : null;
}
Acontece que o método TryGetWithoutAttributeSuffix retornará um resultado ou nulo . E o método retorna um tipo NR. No entanto, voltando um passo, notamos que o tipo do método mudou repentinamente para NNR. Isso acontece por causa do sinal oculto "!":
_document.GetRequiredLanguageService<ISyntaxFactsService>()
.IsCaseSensitive)!; // <=
A propósito, é bastante difícil perceber isso no Visual Studio:

Ao fornecê-lo, o desenvolvedor nos diz que o método nunca retornará nulo . Embora, olhando para os exemplos anteriores e indo para o método TryGetWithoutAttributeSuffix , pessoalmente eu não posso ter certeza sobre isso:
internal static bool TryGetWithoutAttributeSuffix(
this string name,
bool isCaseSensitive,
[NotNullWhen(returnValue: true)] out string? result)
{
if (name.HasAttributeSuffix(isCaseSensitive))
{
result = name.Substring(0, name.Length - AttributeSuffix.Length);
return true;
}
result = null;
return false;
}
Resultado
Por fim, quero dizer que tentar nos poupar das verificações de nulos extras é uma ótima ideia. No entanto, os tipos NR são bastante consultivos por natureza, porque ninguém nos proíbe estritamente de passar nulo para um tipo NNR. É por isso que as regras correspondentes do PVS-Studio permanecem relevantes. Por exemplo, como V3080 ou V3156 .
Tudo de bom e obrigado pela atenção.

Se você deseja compartilhar este artigo com um público que fala inglês, por favor, use o link de tradução: Nikolay Mironov. A referência anulável não o protegerá, e aqui está a prova .