Nullable Reference no defiende y aquí está la evidencia

image1.png


¿Alguna vez ha querido deshacerse del problema de desreferenciación de referencias nulas? Si es así, entonces usar tipos de referencia que aceptan valores NULL no es su elección. ¿Me pregunto porque? Esto es lo que se discutirá hoy.



Advertimos y sucedió. Hace aproximadamente un año, mis colegas escribieron un artículo en el que advertían que la introducción de tipos de referencias que aceptan valores NULL no protegería contra la eliminación de referencias nulas. Ahora tenemos la confirmación real de nuestras palabras, que se encontraron en las profundidades de Roslyn.



Tipos de referencia que aceptan valores NULL



La idea misma de agregar tipos de referencias nulas (en adelante, NR) me parece interesante, ya que el problema asociado con la eliminación de referencias nulas es relevante en la actualidad. La implementación de la protección contra la desreferenciación es extremadamente poco confiable. Según lo planeado por los creadores, asumir el valor nulo solo podrán aquellas variables cuyo tipo esté marcado con un "?". Por ejemplo, una variable de tipo cadena? dice que puede contener nulo , de tipo cadena , por el contrario.



Sin embargo, nadie nos prohíbe pasar nulos a variables de referencia no anulables de todos modos.(en adelante, NNR), porque no se implementan a nivel de código IL. El analizador estático integrado en el compilador es responsable de esta limitación. Por lo tanto, esta innovación tiene un carácter bastante consultivo. Aquí hay un ejemplo simple para mostrar cómo funciona:



#nullable enable
object? nullable = null;
object nonNullable = nullable;
var deref = nonNullable.ToString();


Como podemos ver, el tipo nonNullable se especifica como NNR, pero al mismo tiempo podemos pasar nulo de forma segura allí . Por supuesto, recibiremos una advertencia sobre la conversión de "Conversión de un literal nulo o un posible valor nulo en un tipo que no acepta valores NULL". Sin embargo, esto puede evitarse agregando un poco de agresión:



#nullable enable
object? nullable = null;
object nonNullable = nullable!; // <=
var deref = nonNullable.ToString();


Un signo de exclamación y no hay advertencias. Si uno de ustedes es gourmet, entonces hay otra opción disponible:



#nullable enable
object nonNullable = null!;
var deref = nonNullable.ToString();


Bueno, un ejemplo más. Creamos dos proyectos de consola simples. En el primero escribimos:



namespace NullableTests
{
    public static class Tester
    {
        public static string RetNull() => null;
    }
}


En el segundo, escribimos:



#nullable enable 

namespace ConsoleApp1
{
    class Program
    {
        static void Main(string[] args)
        {
            string? nullOrNotNull = NullableTests.Tester.RetNull();
            System.Console.WriteLine(nullOrNotNull.Length);
        }
    }
}


Pase el cursor sobre nullOrNotNull y vea el siguiente mensaje:



image2.png


Se nos dice que la cadena no puede ser nula aquí . Sin embargo, entendemos que aquí será nulo . Comenzamos el proyecto y obtenemos una excepción:



image3.png


Por supuesto, estos son solo ejemplos sintéticos, cuyo propósito es mostrar que esta introducción no le garantiza protección contra la eliminación de referencias nulas. Si pensaba que los sintéticos son aburridos, y donde hay ejemplos reales, le pido que no se preocupe, entonces todo esto será.



Los tipos de NR tienen otro problema: no está claro si están incluidos o no. Por ejemplo, la solución tiene dos proyectos. Uno está marcado con esta sintaxis y el otro no. Habiendo ingresado a un proyecto con tipos NR, puede decidir que una vez que solo se marca uno, entonces todos están marcados. Sin embargo, este no será el caso. Resulta que debe comprobar siempre si el contexto que acepta valores NULL está incluido en el proyecto o archivo. De lo contrario, podría pensar erróneamente que el tipo de referencia normal es NNR.



Cómo se encontró la evidencia



Al desarrollar nuevos diagnósticos en el analizador PVS-Studio, siempre los probamos en nuestra base de proyectos reales. Ayuda en varios aspectos. Por ejemplo:



  • ver "en vivo" la calidad de las advertencias recibidas;
  • deshacerse de algunos de los falsos positivos;
  • encuentre puntos interesantes en el código, de los que luego pueda hablar;
  • etc.


Uno de los nuevos diagnósticos V3156 encontró lugares donde se pueden lanzar excepciones debido a un potencial nulo . La redacción de la regla de diagnóstico es: "No se espera que el argumento del método sea nulo". Su esencia es que el método no espera nulo , en valor se puede pasar como argumento a nulo . Esto puede provocar, por ejemplo, una excepción o una ejecución incorrecta del método llamado. Puede leer más sobre esta regla de diagnóstico aquí .



Pruebas aquí



Así que llegamos a la parte principal de este artículo. Aquí verá fragmentos de código real del proyecto Roslyn, para los cuales los diagnósticos emitieron advertencias. Su significado principal es que el tipo NNR se pasa como nulo o no se comprueba el valor del tipo NR. Todo esto puede provocar que se lance una excepción.



Ejemplo 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 No se espera que el primer argumento del método 'ContainsKey' sea nulo. Valor nulo potencial: clave. SwitchBinder.cs 121 El



mensaje indica que la clave es potencialmente nula . Veamos dónde esta variable puede obtener ese valor. Primero revisemos el método KeyForConstant :



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


Dado que s_nullKey no es nulo , veamos qué devuelve constantValue.Value :



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


Aquí hay dos literales nulos, pero en este caso no entraremos en ningún caso con ellos. Esto se debe a las comprobaciones de IsBad e IsNull . Sin embargo, me gustaría llamar su atención sobre el tipo de devolución de esta propiedad. Es un tipo NR, pero el método KeyForConstant ya devuelve un tipo NNR. Resulta que, en general, el método KeyForConstant puede devolver un valor nulo . Otra fuente que puede devolver un valor nulo es el método AsNode :







public SyntaxNode? AsNode()
{
  if (_token != null)
  {
    return null;
  }

  return _nodeOrParent;
}


Una vez más, preste atención al tipo de devolución del método: es un tipo NR. Resulta que cuando decimos que el método puede devolver null , esto no afecta nada. Lo interesante es que el compilador no jura convertir de NR a NNR aquí:



image4.png


Ejemplo 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 No se espera que el primer argumento del método 'Agregar' sea nulo. Valor nulo potencial: oldNode. SyntaxAnnotationTests.cs 439



Otro ejemplo con la función AsNode descrita anteriormente. Solo que esta vez oldNode será de tipo NR. Mientras que la clave anterior era de tipo NNR.



Por cierto, no puedo evitar compartir con ustedes una observación interesante. Como describí anteriormente, al desarrollar diagnósticos, lo probamos en diferentes proyectos. Al comprobar los aspectos positivos de esta regla, se advirtió un momento curioso. Aproximadamente el 70% de todas las advertencias se emitieron a métodos de la clase Dictionary . Además, la mayoría de ellos cayeron en el método TryGetValue... Quizás esto se deba al hecho de que subconscientemente no esperamos excepciones del método que contiene la palabra try . Así que verifique su código para este patrón para ver si encuentra algo similar.



Ejemplo 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 El primer argumento del método 'Agregar' se pasa como un argumento al método 'TryGetValue' y no se espera que sea nulo. Valor nulo potencial: typeName. SymbolTreeInfo_Serialization.cs 255



El analizador dice que el problema radica en el typeName . Primero asegurémonos de que este argumento sea potencialmente nulo . Observamos ReadString :



public string ReadString() => ReadStringValue();


Entonces, mire ReadStringValue :




private string ReadStringValue()
{
  var kind = (EncodingKind)_reader.ReadByte();
  return kind == EncodingKind.Null ? null : ReadStringValue(kind);
}


Genial, ahora refresquemos nuestra memoria mirando a dónde se pasó nuestra variable:



simpleTypeNameToExtensionMethodMap.Add(typeName, // <=
                              new ExtensionMethodInfo(containerName,
                                                      name));


Creo que es hora de entrar en el método Add :



public bool Add(K k, V v)
{
  ValueSet updated;

  if (_dictionary.TryGetValue(k, out ValueSet set)) // <=
  {
    ....
  }
  ....
}


De hecho, si se pasa null al método Add como primer argumento , obtendremos una ArgumentNullException . Por cierto, es interesante que si pasamos el cursor sobre typeName en Visual Studio , veremos que su tipo es string? :







image5.png


En este caso, el tipo de retorno del método es simplemente cadena :



image6.png


En este caso, si crea una variable de tipo NNR y le asigna typeName , no se mostrará ningún error.



Tratemos de dejar a Roslyn



No por malicia, sino por diversión, sugiero intentar reproducir uno de los ejemplos mostrados.



image7.png


Prueba 1



Tomemos el ejemplo descrito en el 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 reproducirlo, debe llamar al método TryReadSymbolTreeInfo , pero es privado . Es bueno que la clase con ella tenga un método ReadSymbolTreeInfo_ForTestingPurposesOnly , que ya es 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)))));
}


Es muy agradable que se nos ofrezca directamente probar el método TryReadSymbolTreeInfo . Por lo tanto, creemos nuestra clase en paralelo y escribamos el siguiente 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);
  }
}


Ahora recopilamos Roslyn , creamos una aplicación de consola simple, conectamos todos los archivos dll necesarios y escribimos el siguiente código:



static void Main(string[] args)
{
  CheckNNR.Start();
}


Empezamos, llegamos al lugar requerido y vemos:



image8.png


A continuación, vaya al método Add y obtenga la excepción esperada:



image9.png


Permítanme recordarles que el método ReadString devuelve un tipo NNR que, por diseño, no puede contener nulos . Este ejemplo confirma una vez más la relevancia de las reglas de diagnóstico de PVS-Studio para la búsqueda de desreferenciación de referencias nulas.



Prueba 2



Bueno, dado que ya hemos comenzado a reproducir ejemplos, ¿por qué no reproducir uno más? Este ejemplo no estará relacionado con los tipos NR. Sin embargo, los mismos diagnósticos de V3156 lo encontraron, y quería contárselo. Aquí está el 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 El sexto argumento del método 'GenerateUniqueName' se pasa como un argumento al método 'Concat' y no se espera que sea nulo. Valor nulo potencial: nulo. AbstractSemanticFactsService.cs 24



Seré honesto: al hacer este diagnóstico, realmente no esperaba ningún positivo en la línea recta nula . Después de todo, es bastante extraño enviar un valor nulo a un método que generará una excepción debido a esto. Aunque he visto lugares donde esto estaba justificado (por ejemplo, con la clase Expression ), pero ahora no se trata de eso.



Por lo tanto, estaba muy intrigado cuando vi esta advertencia. Veamos qué sucede en el 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 solo hay una salida del método, no se lanzan excepciones y no hay goto . En otras palabras, nada le impide pasar usedNames al método Concat y obtener una ArgumentNullException .



Pero estas son todas palabras, hagámoslo. Para hacer esto, mire dónde puede llamar a este método. El método en sí está en la clase AbstractSemanticFactsService . La clase es abstracta, así que para mayor comodidad, tomemos la clase CSharpSemanticFactsService , que hereda de ella. En el archivo de esta clase, crearemos el nuestro, que llamará al método GenerateUniqueName . Se parece a esto:



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


Ahora recopilamos Roslyn, creamos una aplicación de consola simple, conectamos todos los archivos dll necesarios y escribimos el siguiente código:



class Program
{
  static void Main(string[] args)
  {
    DropRoslyn dropRoslyn = new DropRoslyn();
    dropRoslyn.Drop();
  }
}


Lanzamos la aplicación y obtenemos lo siguiente:



image10.png


Esto es engañoso



Digamos que estamos de acuerdo con el concepto que acepta valores NULL. Resulta que si vemos un tipo NR, entonces creemos que puede contener un potencial nulo . Sin embargo, a veces puede ver situaciones en las que el compilador nos dice lo contrario. Por lo tanto, aquí se considerarán algunos casos en los que el uso de este concepto no es 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 No se espera que el primer argumento del método 'Concat' sea nulo. Valor nulo potencial: bodyTokens. CSharpEditAndContinueAnalyzer.cs 219 Echemos un



vistazo a por qué bodyTokens es potencialmente nulo y veamos el operador condicional nulo :



var bodyTokens = SyntaxUtilities
                 .TryGetMethodDeclarationBody(node)
                 ?.DescendantTokens();              // <=


Si entramos en el método TryGetMethodDeclarationBody , veremos que puede devolver un valor nulo . Sin embargo, es relativamente grande, así que te dejo un enlace si quieres verlo por ti mismo. Con bodyTokens todo está claro, pero quiero llamar la atención sobre el argumento ctor :



if (node.IsKind(SyntaxKind.ConstructorDeclaration, 
                out ConstructorDeclarationSyntax? ctor))


Como podemos ver, su tipo se establece en NR. En este caso, la desreferenciación se produce con la siguiente línea:



if (ctor.Initializer != null)


Esta combinación es un poco alarmante. Sin embargo, podría decir que, muy probablemente, si IsKind devuelve verdadero , entonces ctor definitivamente no es nulo . Como es:



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


Aquí, se utilizan atributos especiales que indican en qué valor de salida los parámetros no serán nulos . Podemos convencernos de esto mirando la lógica del método IsKind . Resulta que dentro de la condición, el tipo de ctor debe ser NNR. El compilador entiende esto y dice que el ctor dentro de la condición no será nulo . Sin embargo, para entender esto por nosotros, debemos entrar en el método IsKind y notar el atributo allí. De lo contrario, parece que se elimina la referencia a una variable NR sin verificar si es nula . Puede intentar agregar algo de claridad 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 No se espera que el primer argumento del método 'LastIndexOf' sea nulo. Valor nulo potencial: nombreBúsqueda. AbstractEditorInlineRenameService.SymbolRenameInfo.cs 126



Estamos interesados ​​en la variable searchName . null se puede escribir en él después de llamar al método GetWithoutAttributeSuffix , pero no es tan simple. Veamos qué pasa en él:



private string GetWithoutAttributeSuffix(string value)
    => value.GetWithoutAttributeSuffix(isCaseSensitive:
                _document.GetRequiredLanguageService<ISyntaxFactsService>()
                         .IsCaseSensitive)!;


Vayamos más profundo:



internal static string? GetWithoutAttributeSuffix(
            this string name,
            bool isCaseSensitive)
{
  return TryGetWithoutAttributeSuffix(name, isCaseSensitive, out var result) 
         ? result : null;
}


Resulta que el método TryGetWithoutAttributeSuffix devolverá un resultado o un valor nulo . Y el método devuelve un tipo NR. Sin embargo, retrocediendo un paso, notamos que el tipo de método cambió repentinamente a NNR. Esto sucede debido al letrero oculto "!":



_document.GetRequiredLanguageService<ISyntaxFactsService>()
         .IsCaseSensitive)!; // <=


Por cierto, es bastante difícil notarlo en Visual Studio:



image11.png


Al proporcionarlo, el desarrollador nos dice que el método nunca devolverá un valor nulo . Aunque, mirando los ejemplos anteriores y entrando en el método TryGetWithoutAttributeSuffix , personalmente no puedo estar seguro de esto:



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


Salida



Finalmente, quiero decir que tratar de ahorrarnos las verificaciones nulas innecesarias es una gran idea. Sin embargo, los tipos NR son de naturaleza bastante consultiva, porque nadie nos prohíbe estrictamente pasar nulo a un tipo NNR. Es por eso que las reglas correspondientes de PVS-Studio siguen siendo relevantes. Por ejemplo, como V3080 o V3156 .



Todo lo mejor y gracias por su atención.





Si desea compartir este artículo con una audiencia de habla inglesa, utilice el enlace de traducción: Nikolay Mironov. Nullable Reference no le protegerá, y aquí está la prueba .



All Articles