🛡️

【C#】null判定をコンパイラに適切に伝える[NotNull]

に公開

前置き

string? input = GetInput();
// ここではinputはnullかもしれない
if (input == null){
  throw new ArgumentNullException(nameof(input));
}
// ここではinputは確実にnullではない
Console.WriteLine($"input length is {input.Length}");

よくあるコードですね。
inputがnullのときは例外が投げられ、以降のコードは実行されません。コンパイラはこれを自動で判定してinput.Lengthに警告を出しません。

なら、このようなコードでも同じように動いてほしいと思うかもしれません。

string? input = GetInput();
ValidateInput(input);
// ここではinputは確実にnullではない…はずだが、警告が出る
Console.WriteLine($"input length is {input.Length}");

void ValidateInput(string? input){
  if (input == null){
    throw new ArgumentNullException(nameof(input));
  }
}

ですが、こちらは警告が出ます。

ところで、このようなケースのときに使える便利関数があります。ArgumentNullException.ThrowIfNullです。
やっていることは上記のValidateInputと全く同じです。

string? input = GetInput();
ArgumentNullException.ThrowIfNull(input);
// ここではinputは確実にnullではない
Console.WriteLine($"input length is {input.Length}");

が、今度は警告が出ません。

どういう仕組みなのか?

NotNull属性

ThrowIfNull関数の上でF12を押して定義を見てみましょう。

public static void ThrowIfNull(
  [NotNull] object? argument,
  [CallerArgumentExpression(nameof(argument))] string? paramName = null
)
{
  if (argument is null) {
    Throw(paramName);
  }
}

最初の引数の部分に[NotNull]という属性が付与されているのがわかります。 [1]
この属性は、引数がnullでないことを呼び出し元に伝えるための属性です。これにより、この関数の後が実行される = 引数のオブジェクトは確実にnullではない、ということをコンパイラに伝えることができます。

試しに、先程自作したValidateInput関数に[NotNull]属性を付与してみます。

using System.Diagnostics.CodeAnalysis;

string? input = GetInput();
ValidateInput(input);
// ここではinputは確実にnullではない
Console.WriteLine($"input length is {input.Length}");

void ValidateInput([NotNull] string? input)
{
  if (input == null) {
    throw new ArgumentNullException(nameof(input));
  }
}

すると、警告が出なくなります!
alt text

ちなみに性善説で成り立っている属性なので、極論こんなこともできます。

string? input = GetInput();
UsoValidateInput(input);
// ここではinputは確実にnullだけど、コンパイラは騙される
Console.WriteLine($"input length is {input.Length}");

void UsoValidateInput([NotNull] string? input)
{
  // 判定を逆にした!
  if (input != null) {
    throw new ArgumentNullException(nameof(input));
  }
}

NotNullWhen属性

条件付きでnullでないことを伝える属性もあります。その名もズバリNotNullWhen属性です。
結果がtrueならnullじゃない、あるいは逆にfalseならnullじゃない、ということを伝えることができます。

どこで使われているかというと、Try*メソッドでよく使われています。

string? nullableString = GetInput();
if(int.TryParse(nullableString, out var result))
{
  // ここに来た時点で nullableString は確実にnullではない
  Console.WriteLine(nullableString.Length);
}

一応定義を見てみると、こんな感じです。変換成功した(=true)ならsはnullではない、ということを伝えています。

public static bool TryParse([NotNullWhen(true)] string? s, out int result)

.NET Standard 2.0

落とし穴

このように大変便利な仕組みなのですが、1つ落とし穴があります。それは、.NET Standard 2.0や.NET Frameworkでは(そのままだと)使えないということです。またか
例えば以下のような(よくある!)コードを書いたとします。これをビルドすると 古い環境限定で警告が出ます。

string? input = GetInput();
if(string.IsNullOrWhiteSpace(input))
{
  throw new InvalidDataException();
}
// ここではinputは確実にnullではない…けど、.NET Standard 2.0では警告が出る
Console.WriteLine($"input length is {input.Length}");

厄介なのは、TargetFrameworksnetstandard2.0;net8.0のように複数指定しているとおかしな警告が出るところです。
例えば上記コードをVSで見るとこうなります。nullなんだかnullじゃないんだか、どっちや!という感じですが、これは.NETのバージョン事に判定が違うためです。
どっちやねん

一方、RiderだとバージョンをUIで切り替えられるので、以下のようにバージョン毎に警告が出たり出なかったりするのがわかります。

.NET 8.0
alt text

.NET Standard 2.0
alt text

例によって定義を見ると

// .NET 8.0
public static bool IsNullOrWhiteSpace([NotNullWhen(false)] string? value);

// .NET Standard 2.0
public static bool IsNullOrWhiteSpace(string? value);

となっています。納得ですね。

対策

自分で[NotNullWhen(false)]属性を付与したラッパー関数を作ります。

public static bool IsNullOrWhiteSpaceEx([NotNullWhen(false)] string? s)
  => string.IsNullOrWhiteSpace(s);

そうは言っても.NET Standard 2.0だとNotNullWhen属性が使えないのでは?と思うかもしれません。[2]
が、コンパイラはこの名前の属性さえ付いていれば良いので、以下のように自分で定義してしまえばOKです。[3]

// ここの名前空間下にないと認識しない
namespace System.Diagnostics.CodeAnalysis;
// boolの引数がある[NotNullWhenAttribute]という名前なら他は何でも良い
public sealed class NotNullWhenAttribute(bool _) : Attribute;

あるいは、PolySharpのようなNuGetパッケージを使うのも手です。導入するだけで上記のNotNullWhen属性含め様々な属性が使えるようになります。新しい環境なら何も生成しないので、どのプロジェクトにでもとりあえず突っ込んでおけます。
https://github.com/Sergio0694/PolySharp

ただ上記のどちらの方法でも大本のstring.IsNullOrWhiteSpaceはそのままなので、どのみち自前のラッパー関数は必要です。
めんどくさいですね

参考文献

脚注
  1. ちなみに2番めのCallerArgumentExpression属性は、引数の式を文字列として取得するための属性です。これにより、例外メッセージに引数名を含めることができます。今回は関係ないのでスルー。 ↩︎

  2. 実際、組み込みとして用意されたのは.NET Core 3以降です。 ↩︎

  3. 属性として定義していればOKという緩い制約にしておくことで、古い環境でも使おうと思えば簡単に使えるし、組み込みで用意されていればそのまま使えるし、何より互換性を一切壊さない、という良くできた設計です。ただ、string.IsNullOrWhiteSpaceのようなやつには付与してほしかった……(これも破壊的変更になってしまうので、文化的に不可能なのはわかりますが) ↩︎

GitHubで編集を提案

Discussion