null参照の逆参照の問題を取り除きたいと思ったことはありませんか?その場合、NullableReferenceタイプを使用することは選択できません。なんでだろう?これが今日議論されることです。
私たちは警告しました、そしてそれは起こりました。約1年前、私の同僚は、 Nullable Referenceタイプを導入しても、null参照の逆参照を防ぐことができないと警告する記事を書きました。Roslynの奥深くで見つかった私たちの言葉の本当の確認ができました。
ヌル可能な参照タイプ
ヌル参照(以下、NR)タイプ を追加するというアイデア自体は、ヌル参照の逆参照に関連する問題が今日に関連しているため、私には興味深いようです。逆参照に対する保護の実装は非常に信頼性がありません。作成者が計画したように、値nullは、タイプが「?」でマークされている変数のみであると想定しています。たとえば、文字列型の変数?逆に、文字列型のnullを含めることができると言います。 ただし、とにかくnullをnull不可能な参照変数に渡すことを禁止する人は誰もいません。
(以下-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と、次のメッセージが表示されます。
ここでは、文字列をnullにすることはできないと言われています。ただし、ここではnullになることを理解しています。プロジェクトを開始すると、例外が発生します。
もちろん、これらは単なる合成例であり、その目的は、この紹介が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への変換を誓わないのは興味深いことです。
例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 StudioでtypeNameにカーソルを合わせると、その型が文字列であることがわかります。:
この場合、メソッドの戻りタイプは単純に文字列です。
この場合、タイプNNRの変数をさらに作成し、それにtypeNameを割り当てると、エラーは表示されません。
Roslynをドロップしてみましょう
悪意のためではなく、楽しみのために、示されている例の1つを再現することをお勧めします。
テスト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();
}
起動し、必要な場所に到達して、次のことを確認します。
次に、Addメソッドに移動して、予期される例外を取得します。
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もありません。つまり、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();
}
}
アプリケーションを起動すると、次のようになります。
これは誤解を招く
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)
この組み合わせは少し憂慮すべきです。ただし、おそらく、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) // <=
{
....
}
}
ケース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で気付くのはかなり難しいです。
これを提供することにより、開発者は、メソッドが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ルールが引き続き関連する理由です。たとえば、V3080やV3156など。
何卒よろしくお願い申し上げます。
この記事を英語を話す聴衆と共有したい場合は、翻訳リンクNikolayMironovを使用してください。Nullable Referenceはあなたを保護しません、そしてここに証拠があります。