可空引用不能辩护,这是证据

image1.png


您是否曾经想摆脱过空引用解引用问题?如果是这样,则不选择使用Nullable引用类型。我想知道为什么?这就是今天将要讨论的内容。



我们警告过,事情发生了。大约一年前,我的同事写了一篇文章,警告说引入Nullable引用类型将无法避免对null引用的取消引用。现在我们对我们的话有了真正的确认,这是在罗斯林的深处发现的。



可空引用类型



添加可空引用(以下简称NR)类型的想法对我来说似乎很有趣,因为与解引用空引用相关的问题与今天有关。实施反引用保护是极其不可靠的。按照创建者的计划,假定值null只能是类型标记为“?”的那些变量。例如,字符串类型的变量说它可以包含null,其类型为string-相反。



但是,没有人禁止我们将null传递不可为空的参考变量(以下称为NNR)类型,因为它们不是在IL代码级别实现的。内置于编译器中的静态分析器是造成此限制的原因。因此,这种创新本质上是一种建议。这是一个简单的示例,说明其工作方式:



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


正如我们所看到的,nonNullable的类型被指定为NNR,但是我们可以安全地在其中传递null当然,我们将收到有关转换“将空文字或可能的空值转换为非空类型的警告”。但是,可以通过添加一些攻击性来规避此问题:



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


一个感叹号,没有警告。如果您中的一个是美食家,那么可以使用另一种选择:



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


好,再举一个例子。我们创建两个简单的控制台项目。首先,我们写:



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


在第二篇中,我们写:



#nullable enable 

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


将鼠标悬停nullOrNotNull上,然后看到以下消息:



image2.png


我们被告知这里的字符串不能为null但是,我们知道在这里它将为null我们启动项目并得到一个例外:



image3.png


当然,这些只是综合示例,其目的是表明本介绍不能保证您可以防止空引用取消引用。如果您认为合成材料很无聊,并且根本没有真正的例子,那么我请您不要担心,那么所有这些都将是。



NR类型还有另一个问题-尚不清楚它们是否包含在内。例如,该解决方案有两个项目。一个标记有这种语法,而另一个则没有。输入了NR类型的项目后,您可以决定一旦标记了一个项目,然后标记了所有项目。但是,事实并非如此。事实证明,如果项目或文件中包含可为空的上下文,则需要每次查看。否则,您可能会错误地认为普通引用类型是NNR。



如何找到证据



在PVS-Studio分析仪中开发新的诊断程序时,我们总是在实际项目的基础上对其进行测试。它在各个方面都有帮助。例如:



  • 看到“实时”警告的质量;
  • 消除一些误报;
  • 在代码中找到有趣的要点,然后您可以进行讨论;
  • 等等


新的V3156诊断程序之一发现了由于可能为null可能引发异常的地方诊断规则的措词为:“该方法的参数不应为空”。其本质是该方法不期望null,可以将value作为null的参数传递例如,这可能导致被调用方法异常或执行不正确。您可以在此处阅读有关此诊断规则的更多信息



这里的证明



因此,我们进入了本文的主要部分。在这里,您将看到Roslyn项目中的实际代码片段,诊断程序为此发出了警告。它们的主要含义是将NNR类型传递为null,或者不检查NR类型的值。所有这些都可能导致引发异常。



例子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“ ContainsKey”方法的第一个参数不应为null。可能的空值:键。SwitchBinder.cs 121



消息指出密钥可能为null让我们看看该变量在哪里可以得到这样的值。让我们首先检查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();


由于s_nullKey不为null让我们看看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);
    }
  }
}


有两个空文字在这里,但在这种情况下,我们不会去任何情况下他们。这是由于IsBadIsNull检查但是,我想提醒您注意此属性的返回类型。它是NR类型,但是KeyForConstant方法已经返回了NNR类型。事实证明,通常,KeyForConstant方法可以返回null 可以返回null的另一个来源AsNode方法







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

  return _nodeOrParent;
}


同样,请注意方法的返回类型-它是NR类型。事实证明,当我们说可以从该方法返回null,这不会有任何影响。有趣的是,编译器不会在这里发誓要从NR转换为NNR:



image4.png


例子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“添加”方法的第一个参数不应为null。可能的空值:oldNode。 SyntaxAnnotationTests.cs 439



带有上述AsNode函数的另一个示例。仅这次,oldNode的类型将为NR。而上述密钥是NNR类型。



顺便说一句,我不得不与你分享一个有趣的观察。如上所述,在开发诊断程序时,我们在不同的项目上对其进行测试。在检查该规则的正面结果时,注意到了一个奇怪的时刻。所有警告中约有70%发出给Dictionary类的方法。而且,它们大多数都属于TryGetValue方法... 可能是由于这样的事实:在潜意识里,我们不期望包含单词try的方法产生异常因此,请检查此模式的代码以查看是否找到类似的内容。



例子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“添加”方法的第一个参数作为参数传递给“ TryGetValue”方法,并且不应为null。可能的空值:typeName。SymbolTreeInfo_Serialization.cs 255



分析器认为问题出在typeName上首先,请确保此参数确实是潜在的null我们看一下ReadString



public string ReadString() => ReadStringValue();


因此,请看ReadStringValue




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


太好了,现在让我们通过将变量传递到的位置来刷新内存:



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


我认为是时候进入Add方法了



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

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


实际上,如果将null作为第一个参数传递Add方法,那么我们将获得ArgumentNullException 顺便说一句,有趣的是,如果将光标悬停Visual Studio中的typeName上方,我们将看到其类型为string?







image5.png


在这种情况下,方法的返回类型就是string



image6.png


在这种情况下,如果您进一步创建NNR类型的变量并为其分配typeName,则不会显示任何错误。



让我们尝试放下罗斯林



我不是为了恶意,而是为了娱乐,我建议尝试重现所示的示例之一。



image7.png


测试1



让我们以数字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)); 
  }
  ....
}


要重现它,您需要调用TryReadSymbolTreeInfo方法,但是该方法private很好的是,带有它具有一个ReadSymbolTreeInfo_ForTestingPurposesOnly方法,该方法已经是内部的



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


很高兴我们直接提供了对TryReadSymbolTreeInfo方法的测试因此,让我们并排创建我们的类并编写以下代码:



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


现在,我们收集Roslyn,创建一个简单的控制台应用程序,连接所有必需的dll文件并编写以下代码:



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


我们启动,到达所需的位置,然后看到:



image8.png


接下来,转到Add方法并获取预期的异常:



image9.png


让我提醒您,ReadString方法返回一个NNR类型,根据设计,该类型不能包含null该示例再次确认了PVS-Studio诊断规则与搜索空引用解除引用的相关性。



测试2



好吧,由于我们已经开始复制示例,为什么不再复制一个。此示例与NR类型无关。但是,它是由相同的V3156诊断程序发现的,我想向您介绍一下。这是代码:



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“ GenerateUniqueName”方法的第六个参数作为参数传递给“ Concat”方法,并且不应为null。可能为null的值:null。 AbstractSemanticFactsService.cs 24



我会说实话:在进行此诊断时,我并不真正期望直线null上有任何正数。毕竟,向此方法发送null会引发异常的方法很奇怪。尽管我已经看到了证明其合理性的地方(例如,使用Expression),但是现在不相关了。



因此,看到此警告时我很感兴趣。让我们看看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));     // <=
}


我们看到该方法只有一个出口,没有抛出异常,也没有goto。换句话说,没有什么可以阻止您将usedNames传递Concat方法并获取ArgumentNullException



但是这些都是言语,让我们去做。为此,请查看可以在何处调用此方法。该方法本身在AbstractSemanticFactsService类中。该类是抽象的,因此为方便起见,让我们采用从其继承CSharpSemanticFactsService。在此类的文件中,我们将创建自己的类,该类将调用GenerateUniqueName方法。看起来像这样:



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


现在,我们收集Roslyn,创建一个简单的控制台应用程序,连接所有必需的dll文件并编写以下代码:



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


我们启动该应用程序并获得以下信息:



image10.png


这是误导



假设我们同意可为空的概念。事实证明,如果我们看到NR类型,那么我们认为它可以包含潜在的null但是,有时您会看到编译器另行告诉我们的情况。因此,这里将考虑这种概念的使用不直观的几种情况。



情况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'Concat'方法的第一个参数不应为null。可能的空值:bodyTokens。CSharpEditAndContinueAnalyzer.cs 219让我们



看一下为什么bodyTokens可能为null并查看null条件运算符:



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


如果我们进入TryGetMethodDeclarationBody方法,则会看到它可以返回null但是,它相对较大,因此如果您想自己看看,我会留下一个链接使用bodyTokens一切都很清楚,但我想提请注意ctor参数



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


如我们所见,其类型设置为NR。在这种情况下,以下行将取消引用:



if (ctor.Initializer != null)


这种组合有点令人震惊。但是,您可能会说,很可能,如果IsKind返回true,则ctor绝对不是null。事情是这样的:



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


此处,使用特殊属性来指示参数在哪个输出值处不会为null我们可以通过查看IsKind方法的逻辑来验证这一点事实证明,在此条件下,ctor的类型必须为NNR。编译器理解这一点,并说条件内的ctor不会为null但是,为了对我们了解这一点,我们必须转到IsKind方法并注意那里的属性。否则,看起来就像在不检查null的情况下取消引用了NR变量您可以尝试增加一些清晰度,如下所示:



if (node.IsKind(SyntaxKind.ConstructorDeclaration, 
                out ConstructorDeclarationSyntax? ctor))
{
    if (ctor!.Initializer != null) // <=
    {
      ....
    }
}


情况二



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“ LastIndexOf”方法的第一个参数不应为null。可能的空值:searchName。AbstractEditorInlineRenameService.SymbolRenameInfo.cs 126



我们对searchName变量感兴趣可以在调用GetWithoutAttributeSuffix方法之后将null写入其中,但这并不那么简单。让我们看看其中发生了什么:



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


让我们更深入:



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


事实证明,TryGetWithoutAttributeSuffix方法将返回resultnull并且该方法返回NR类型。但是,退后一步,我们注意到该方法的类型突然变为NNR。发生这种情况是由于隐藏符号“!”:



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


顺便说一句,在Visual Studio中很难注意到它:



image11.png


通过提供它,开发人员告诉我们该方法将永远不会返回null虽然,通过查看前面的示例并进入TryGetWithoutAttributeSuffix方法,我个人不能确定这一点:



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


输出量



最后,我想说,试图为我们节省不必要的null检查是一个好主意。但是,NR类型本质上是一种建议,因为没有人严格禁止我们将null传递给NNR类型。这就是为什么相应的PVS-Studio规则保持相关性的原因。例如,例如V3080V3156



一切顺利,谢谢您的关注。





如果您想与讲英语的读者分享这篇文章,请使用翻译链接:Nikolay Mironov。可空引用不会保护您,这是证明



All Articles