Tip
ソフトウェアの開発は初めてですか? 作業の 開始 に関するチュートリアルから始めます。
別の言語で経験がありますか? Kotlin の null 許容型、TypeScript の strictNullChecks、または Swift の省略可能な型を使用した場合、モデルはよく知られています。 C# では、別の型ではなく静的分析と警告診断が使用されます。
注釈で意図を表現するとnull 状態分析にざっと目を通してから、チュートリアル: null 許容参照型と null 非許容参照型で設計意図を表現するに進み、この機能を適用します。
null 許容参照型 は、コードで System.NullReferenceException がスローされる可能性を最小限に抑える一連の機能です。
nullを保持する変数と保持しない変数を宣言すると、コンパイラは、これらの宣言がコードでの使用方法と一致しない場合に警告します。 プログラムのランタイム動作は変更されません。 null 許容参照型は、純粋にコンパイル時の機能です。
3 つの構成要素が連携して動作します。
-
変数注釈 (
string対string?) は、nullを許可することを意図した参照を表します。 - null 状態の分析では、コード内の各時点で、式の値がnull ではないか、null の可能性があるかを判定します。
- API の属性は、"この引数は
nullできますが、戻り値は引数が null の場合にのみ null です" など、より微妙なコントラクトを記述します。
コンパイラはこれらのシグナルを組み合わせて診断を生成します。 null 非許容変数に対する警告は、変数が nullを受け取る可能性を意味します。 null 許容変数に対する警告は、コードが null チェックなしで 逆参照 する可能性を意味します。
逆参照 とは、変数が参照する値を使用することを意味します。 たとえば、それに対してメソッドを呼び出したり (variable.Method())、プロパティを参照したり (variable.Property)、インデックスを使ってアクセスしたり (variable[0]) できます。
null値を持つ変数を逆参照すると、実行時に例外がスローされます。 どちらの種類の警告も、コードの動作が、記述された設計と一致しないことを意味します。
Null 許容コンテキスト
最近の.NET テンプレートから作成されたプロジェクトは、プロジェクト ファイルに <Nullable>enable</Nullable> を設定するため、この記事のガイダンスは書かれたとおりに適用されます。 古いプロジェクトで作業している場合は、 .csproj を開き、 <PropertyGroup> に次の行が含まれていることを確認します。見つからない場合は追加します。
<Nullable>enable</Nullable>
大規模なアプリケーションの移行の詳細については、 null 許容移行戦略 に関する記事を参照して、その他の設定とディレクティブを参照してください。
注釈を使用して意図を表現する
既定では、すべての参照型変数は null 非許容 です。
?を追加して null 許容参照型を宣言します。
public static void Annotations()
{
string required = "always set"; // non-nullable: assigning null produces a warning
string? optional = null; // nullable: holding null is allowed
Console.WriteLine(required.Length);
if (optional is not null)
{
Console.WriteLine(optional.Length);
}
}
注釈はランタイム型を変更しません。
string と string? の両方が System.String。
?は、設計の意図をコンパイラに通知します。 この意図は、コンパイラによって生成される警告を形作ります。
- null 非許容変数の既定の null 状態 は not-null です。 コンパイラは、
null可能性のある値を割り当てると警告します。 - NULL 許容変数の既定の null 状態 は null かもしれない です。 最初に確認せずに変数を逆参照すると、コンパイラによって警告が表示されます。
注釈を使用して、必要な値と省略可能な値を型システムに表示します。 次の Person 型では、FirstName と LastName は非 null として宣言されています。つまり、すべての人がその両方を持っている必要があります。一方、MiddleName は null 許容です。これは、すべての人がそれを持っているわけではないためです。
public sealed class Person(string firstName, string lastName)
{
public string FirstName { get; } = firstName;
public string? MiddleName { get; init; }
public string LastName { get; } = lastName;
public override string ToString() => MiddleName is null
? $"{FirstName} {LastName}"
: $"{FirstName} {MiddleName} {LastName}";
}
public static void DesignIntent()
{
Person p1 = new("Ada", "Lovelace") { MiddleName = "King" };
Console.WriteLine(p1);
// Output: Ada King Lovelace
Person p2 = new("Grace", "Hopper");
Console.WriteLine(p2);
// Output: Grace Hopper
}
注釈によって、 ToStringの実装が促進されます。
FirstNameとLastNameは null 非許容であるため、オーバーライドでは、null チェックなしで補間文字列 ($"..." プレースホルダーに式を埋め込む{}構文) で直接使用されます。
MiddleName は null 許容であるため、オーバーライドによって最初に null がチェックされ、存在する場合にのみ含まれます。 コンパイラはその違いを厳密に適用します。null の可能性がある値を、null 非許容が期待される箇所に渡すコードでは警告が生成され、null 非許容メンバーを未初期化のままにするコンストラクターでも警告が生成されます。
null 状態分析
コンパイラは、すべての式の null 状態 を追跡します。 状態は、次の 2 つの値のいずれかです。
-
not-null: 式は
nullされていないことが判明しています。 -
maybe-null: 式が
null可能性があります。
コンパイラがコードを分析すると、ローカル変数の null 状態が更新されます。 割り当てと null チェックの 2 つの変更があります。 代入後、変数の null 状態は右側の式と一致します。 式が null または null 許容の場合、変数は null になる可能性があります。 式が null 以外のリテラルの場合、変数は not-null になります。 null チェックの後、変数の null 状態は、どの分岐が取得されたかを反映します。
public static void NullStateTracking()
{
string? message = null;
// Warning: dereference of a possibly null reference.
Console.WriteLine(message.Length);
message = "Hello, World!";
// No warning: the compiler tracks that message is now not-null.
Console.WriteLine(message.Length);
}
前の例では、 message が null である可能性があるため、最初の逆参照によって警告が生成されます。 null 以外のリテラルへの割り当ての後、コンパイラは message が null でないことを認識するため、2 番目の逆参照は安全です。
null 状態の分析は、if チェック、パターン マッチング(値の形をテストする is null や is { } などの式)、およびループしたり早期に return したりする制御フロー全体で機能します。
public sealed class Node(string name)
{
public string Name { get; } = name;
public Node? Parent { get; init; }
}
public static void FlowAnalysis(Node start)
{
Node? current = start;
while (current is not null)
{
// Inside the loop, the compiler knows current is not-null.
Console.WriteLine(current.Name);
current = current.Parent;
}
}
分析はメソッド本体の内部までは追跡しません。 null 状態を呼び出し元と通信するメソッドが必要な場合は、そのシグネチャで null 許容分析属性を 使用します。
! で警告を上書きします
コンパイラ以上のものを知っている場合があります。
null を許容する演算子!は、分析でそれ以外の場合でも、式が null ではないことを宣言します。
public static void NullForgiving()
{
// "ada" matches a switch arm that returns a non-null string,
// but the return type is string? so the compiler treats the
// result as maybe-null.
string? maybeName = LookUpName("ada");
// The ! tells the compiler "trust me, this isn't null." We just
// passed "ada", which the switch maps to "Ada Lovelace".
int length = maybeName!.Length;
Console.WriteLine(length); // => 12
}
// Returns string? because the wildcard arm yields null.
private static string? LookUpName(string id) => id switch
{
"ada" => "Ada Lovelace",
_ => null,
};
! は慎重に使用してください。 そうした箇所が1つあるごとに、コンパイラによる保護が効かなくなる箇所が増えます。 コンパイラがそれ自体で正しい結論に達するように、null チェックの追加、コードの再構築、または関連する API への注釈付けが優先されます。
API コントラクトを記述する属性
パラメーターまたは戻り値の型の注釈は、常に表現力が十分ではありません。 メソッドは null 引数を受け取る可能性がありますが、null 以外の結果が保証されます。 テスト メソッドは、引数が null でない場合にのみ true を返す場合があります。
null 許容分析属性を使用して、次のコントラクトを伝達します。
public static bool IsPresent([NotNullWhen(true)] string? value) =>
!string.IsNullOrEmpty(value);
public static void NullAnalysisAttributes()
{
string? input = ReadInput();
if (IsPresent(input))
{
// No null-forgiving operator needed: the attribute tells the compiler
// input is not-null when IsPresent returns true.
Console.WriteLine(input.Length);
}
}
private static string? ReadInput() => "hello";
NotNullWhenAttributeは、IsPresentがtrueを返すとき、引数が null ではないことをコンパイラに通知します。
if ブロック内では、コンパイラはvalueを not-null として扱い、null 許容演算子は必要ありません。 .NET 5 の時点では、すべての.NETランタイム API に注釈が付けられます。そのため、分析はそれらを呼び出すコードにメリットがあります。
既知の落とし穴
2 つのパターンは、警告なしで null を保持している null 非許容参照を残すことができます。 どちらのパターンも静的分析の制限であり、コードのバグではありません。
既定の構造体
defaultまたはnew()を使用して、null 非許容参照フィールドを持つ構造体を作成できます。 この方法では、構造体のフィールドは初期化されません。
public struct Student
{
public string FirstName;
public string? MiddleName;
public string LastName;
}
public static void DefaultStructPitfall()
{
Student s = default; // No warning, but FirstName and LastName are null.
Console.WriteLine(s.FirstName?.Length ?? -1);
}
フィールドは実行時に null を保持しますが、コンパイラは警告しません。 構造体を使用する必要がある場合は、 必要なメンバーを優先します。これは、呼び出し元がオブジェクト初期化子を介して初期化する必要があるメンバー、または呼び出し元が呼び出す必要があるパラメーター化されたコンストラクターです。
参照と構造体の配列
null 非許容の参照型の新しい配列には、各要素に値を代入するまで、すべての null 要素が含まれます。
public static void ArrayPitfall()
{
string[] values = new string[3]; // Elements are null at run time.
Console.WriteLine(values[0]?.Length ?? -1);
string[] initialized = ["a", "b", "c"]; // Collection expression initializes every slot.
Console.WriteLine(initialized[0].Length);
}
構造体の配列にも同じ落とし穴が適用されます。すべての要素は構造体の既定値として開始されるため、各要素の null 非許容参照フィールドは nullとして開始されます。
配列の作成の一部として配列要素を初期化します。
コレクション式 ( [1, 2, 3] リテラル構文) と ターゲット型の new (コンパイラが型を推論できる場合の new() の記述) により、完全な初期化が簡潔になります。
関連するコンテンツ
.NET