Nullable Referenceは防御せず、ここに証拠があります

image1.png


null参照の逆参照の問題を取り除きたいと思ったことはありませんか?その場合、NullableReferenceタイプを使用することは選択できません。なんでだろう?これが今日議論されることです。



私たちは警告しました、そしてそれは起こりました。約1年前、私の同僚は Nullable Referenceタイプを導入しても、null参照の逆参照を防ぐことができないと警告する記事書きましRoslynの奥深くで見つかった私たちの言葉の本当の確認ができました。



ヌル可能な参照タイプ



ヌル参照(以下、NR)タイプ を追加するというアイデア自体は、ヌル参照の逆参照に関連する問題が今日に関連しているため、私には興味深いようです。逆参照に対する保護の実装は非常に信頼性がありません。作成者が計画したように、値nullは、タイプが「?」でマークされている変数のみであると想定しています。たとえば、文字列型の変数逆に、文字列型のnullを含めることができると言います ただし、とにかくnullnull不可能な参照変数に渡すことを禁止する人は誰もいません。



(以下-NNR)タイプ。ILコードのレベルで実装されていないため。コンパイラに組み込まれている静的アナライザは、この制限の原因です。したがって、このイノベーションは本質的に助言的なものです。これがどのように機能するかを示す簡単な例です。



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


ご覧のとおり、nonNullableのタイプはNNRとして指定されていますが、ここでnullを安全に渡すことができますもちろん、「nullリテラルまたは可能なnull値をnull不可能なタイプに変換する」という警告が表示されます。ただし、これは少し攻撃を加えることで回避できます。



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


1つの感嘆符と警告はありません。あなたの一人がグルメなら、別のオプションが利用可能です:



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


さて、もう1つの例。2つの簡単なコンソールプロジェクトを作成しましょう。最初に、次のように記述します。



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


2番目に、次のように記述します。



#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


もちろん、これらは単なる合成例であり、その目的は、この紹介がnull参照の逆参照に対する保護を保証しないことを示すことです。合成が退屈だと思っていて、実際の例がある場合は、心配しないでください。そうすれば、これですべてです。



NRタイプには別の問題があります-それらが含まれているかどうかは明確ではありません。たとえば、ソリューションには2つのプロジェクトがあります。1つはこの構文でマークアップされ、もう1つはそうではありません。NRタイプでプロジェクトに参加したら、1つだけがマークされたら、すべてがマークされるように決定できます。ただし、これは当てはまりません。null可能なコンテキストがプロジェクトまたはファイルに含まれているかどうかを毎回確認する必要があることがわかりました。そうしないと、通常の参照タイプがNNRであると誤解する可能性があります。



証拠がどのように見つかったか



PVS-Studioアナライザーで新しい診断を開発するときは、常に実際のプロジェクトに基づいてテストします。それはさまざまな面で役立ちます。例えば:



  • 受け取った警告の品質で「ライブ」を参照してください。
  • 誤検知のいくつかを取り除きます。
  • コード内で興味深い点を見つけて、それについて話し合うことができます。


新しいV3156診断の1つで、潜在的なnullが原因で例外がスローされる可能性のある場所が見つかりました診断ルールの文言は次のとおりです。「メソッドの引数がnullになることは想定されていません」。その本質は、メソッドがnullを予期しないことであり、値は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であるとは想定されていません。潜在的な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);
    }
  }
}


2つのがあり、ヌルリテラルはここにあるが、この場合には、我々はいずれにも行かないであろう場合、それらと。これは、IsBadおよびIsNullチェックが原因です。ただし、このプロパティのリターンタイプに注意を向けたいと思います。これは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'Add 'メソッドの最初の引数がnullになることは想定されていません。潜在的なnull値:oldNode。 SyntaxAnnotationTests.cs439上記



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'Add 'メソッドの最初の引数は' TryGetValue 'メソッドに引数として渡され、nullになることは想定されていません。潜在的な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 StudiotypeNameカーソルを合わせると、その型が文字列であることがわかります。







image5.png


この場合、メソッドの戻りタイプは単純に文字列です。



image6.png


この場合、タイプNNRの変数をさらに作成し、それにtypeNameを割り当てると、エラーは表示されません。



Roslynをドロップしてみましょう



悪意のためではなく、楽しみのために、示されている例の1つを再現することをお勧めします。



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メソッドを呼び出す必要がありますが、これはプライベートです。それを含むクラスに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を含めることはできませんこの例では、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 'メソッドの6番目の引数は、引数として' 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));     // <=
}


メソッドから抜け出す方法は1つしかなく、例外はスローされず、gotoもありません。つまり、usedNamesConcatメソッドに渡して、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


これは誤解を招く



null可能な概念に同意するとしましょう。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であるとは想定されていません。潜在的な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)


この組み合わせは少し憂慮すべきです。ただし、おそらく、IsKindtrueを返した場合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でなければならないことがわかります。コンパイラはこれを理解し、条件内のctornullにならないことを示しますただし、これを理解するには、IsKindメソッドに移動して、そこにある属性に注意する必要があります。それ以外の場合は、nullをチェックせずにNR変数を逆参照するように見えます次のように明確さを追加してみてください。



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


ケース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'LastIndexOf 'メソッドの最初の引数はnullであるとは想定されていません。潜在的なnull値:searchName。AbstractEditorInlineRenameService.SymbolRenameInfo.cs 126searchName



変数に関心があります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 メソッド結果またはnullのいずれかを返すことがわかりましたそして、メソッドはNRタイプを返します。しかし、一歩戻ると、メソッドのタイプが突然NNRに変更されたことがわかります。これは、隠し記号「!」が原因で発生します。



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


ちなみに、VisualStudioで気付くのはかなり難しいです。



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チェックを保存しようとするのは素晴らしいアイデアだと言いたいですただし、NNRタイプにnull渡すことを厳密に禁止している人はいないため、NRタイプは本質的にかなり助言的です。これが、対応するPVS-Studioルールが引き続き関連する理由です。たとえば、V3080V3156など



何卒よろしくお願い申し上げます。





この記事を英語を話す聴衆と共有したい場合は、翻訳リンクNikolayMironovを使用してください。Nullable Referenceはあなたを保護しません、そしてここに証拠があります。



All Articles