アノテーション属性

前節のジェネリクスの問題を筆頭に、 いくつか、T? という記法だけでは解決できない問題があります。 ジェネリックな型でなくても例えば以下のような場合に、? の有無だけではフロー解析がうまく働きません。

  • プロパティの get と set で null 許容性が違う場合がある
  • 参照引数で、「null が渡ってきてもいいけど、非 null な値で必ず上書きする」みたいな挙動があり得る
  • TryGetValue のように、戻り値が true の時だけ非 null な値を返す出力引数がある
  • 「引数が null の場合に限り戻り値も null」みたいな場合がある

こういう場合への対処としていくつか、属性によってフロー解析を制御する手段が用意されています。 いずれの属性もSystem.Diagnostics.CodeAnalysis名前空間で定義されています。

分類属性名概要
事前条件 AllowNull (T であっても)入力として null を受け付ける
DisallowNull (T? であっても)入力として null を受け付けない
事後条件 MaybeNull (T であっても)出力として null を返す
NotNull (T? であっても)出力として null を返さない
条件付き
事後条件
MaybeNullWhen 戻り値が true/false どちらかの時だけ MaybeNull 使い
NotNullWhen 戻り値が true/false どちらかの時だけ NotNull 使い
null 依存性 NotNullIfNotNull 引数が null の時に限り戻り値が null
フロー DoesNotReturn このメソッドを呼んだらもう戻ってこないという意味で、それ以降のフロー解析をしない
DoesNotReturnIf 引数が true/false どちらかの時だけ DoesNotReturn 扱い

out引数に対しては「メソッド内で非 null な値を代入している」、 通常の引数やin引数に対しては「もし null が渡ってきたら例外を出すなど、それ以降の処理を続行させない」という扱い。

アノテーション属性の利用例

これらの属性が必要になる具体例をいくつか紹介していきましょう。

まず、Array.Resize は配列の長さを変更するメソッドですが、参照引数で null を受け付けはするものの、絶対に非 null なインスタンスを作って渡します。そこで、以下のように、NotNull 属性が付いています。

public class Array
{
    // null を受け付けるけど、返しはしない。
    public static void Resize<T>([NotNull] ref T[]? array, int newSize);
}

その結果、以下のようなコードが書けます。

using System;
 
class Program
{
    static void Main()
    {
        // null を渡せる。
        int[]? array = null;
        Array.Resize(ref array, 4);
 
        // でも、呼び出し後は非 null 保証がある。
        Console.WriteLine(array.Length); // 警告なし
    }
}

TextWrite.NewLine は get で null を返すことはありません。 しかし、「null を set すると Environment.NewLine を使う」という仕様があって、set だけが null 許容です。 そこで、以下のように、set にだけ AllowNull が付いています。

public class TextWriter
{
    // set だけ null 許容
    public virtual string NewLine
    {
        get => ...
        [AllowNull]
        set => ...
    }
}

ジェネリクス都合で T? と書けない問題を MaybeNull 属性で回避している例としては StrongBox<T>.ValueThreadLocal<T>.Valueなどがあります。

public class StrongBox<T>
{
    [MaybeNull] public T Value => ...
}
 
public class ThreadLocal<T>
{
    [MaybeNull] public T Value => ...
}

.NET には名前が Try から始まって、処理の成否を bool で返すメソッドが結構多いですが、 こういう場合「戻り値が true の時だけ null でない値を取れる」ということが多いです。 例えば、Version.TryParseが該当します。 また、string.IsNullEmpty のように、他の処理と兼ねて null チェックしているものがあります。 こういう場合に NotNullWhen などの条件付き事後条件を使います。

public class Version
{
    // 戻り値が true の時には非 null 値を version 変数に入れて返す。
    public static bool TryParse(
        string? input,
        [NotNullWhen(true)] out Version? version);
}
 
public class String
{
    // 中で null チェックをしているので、true を返すなら value は非 null とわかる。
    public static bool IsNullOrEmpty([NotNullWhen(false)] string? value);
}

Path.GetFileNameなど、単純に null を伝搬する(null が来たら素通しで null を返す)ようなメソッドも多いです。 また、Volatile.Read/Writeのように、引数の値を戻り値や他の参照引数に伝搬するものがあって、値の伝搬によって null 許容性も伝搬します。 こういう場合に使うのが NotNullIfNotNull 属性です。

class Path
{
    // 引数が null のとき、戻り値に null を素通しする仕様。
    [return: NotNullIfNotNull("path")]
    public static string? GetFileName(string? path);
}
 
class Volatile
{
    // location に value を書き込むメソッドなので、value の null 判定が location に伝搬。
    public static void Write<T>([NotNullIfNotNull("value")] ref T location, T value) where T : class?;
 
    // location に入っている値をそのまま返すメソッドなので、location の null 判定が戻り値に伝搬。
    [return: NotNullIfNotNull("location")]
    public static T Read<T>(ref T location) where T : class?;
}

(ちなみに、この例の "path""location"nameof(path)nameof(location) と書きたいところですが、nameof 演算子の仕様上、メソッドの外から引数を参照することは残念ながらできません。 この NotNullIfNotNull 属性によってそれなりに強い需要が生じてしまったので修正が入る可能性はありますが、破壊的変更になりそうなのであんまり期待はできません。)

一部のメソッドは、そのメソッドを呼んだら最後、もう絶対に正常には戻ってこないものがあります。例えばEnvironment.FailFastはプログラムを即座に止めてしまう(おかしな状態のままプログラムが進むよりは、一思いにクラッシュした方がマシな場面で使う)メソッドなので、このメソッドの呼び出しから後ろが実行されることは絶対にありません。 こういう場合、フロー解析もそのメソッドまでで止めてしまいたく、そのために使う属性が DoesNotReturn です。

public static class Environment
{
    [DoesNotReturn]
    public static void FailFast(string message);
}

これは以下のような使い方を想定しています。

static int M(string? s)
{
    if (s is null)
    {
        Environment.FailFast("null は許さない。絶対にだ!");
    }
 
    // null だったら FailFast 行きで、FailFast は DoesNotReturn なので、
    // ここに来た時点で s は非 null な保証がある。
    return s.Length;
}

プログラムのクラッシュの他、絶対に例外を出すことがわかっているメソッドにも DoesNotReturn 属性が使えます。

static int M(string? s)
{
    if (s is null)
    {
        Throw(nameof(s));
    }
 
    return s.Length;
}
 
// throw はインライン展開を阻害するのでここだけメソッドを分離
[DoesNotReturn]
static void Throw(string name) => throw new ArgumentNullException(name);

同じプログラムのクラッシュでも、条件付きな場合があります。 Debug.Assertがわかりやすいでしょう。 このメソッドは引数が false の時に限ってプログラムを止めます。 こういうメソッドに対して使うがの DoesNotReturnIf 属性です。

public static class Debug
{
    public static void Assert([DoesNotReturnIf(false)] bool condition);
}

ちなみに、「絶対に戻ってこないからフロー解析をしなくていい」という処理は、 null 許容性の他に確実な初期化でも使いたいものです。 ただ、DoesNotReturn/DoesNotReturnIf 属性は null に関してしか働きません。 (確実な初期化の方がシビアな判定をすべき(でないとセキュリティ ホールになりえる)もので、 C# コンパイラーのフロー解析だけじゃなく .NET ランタイムのレベルでも検証をしたいけど、そこまで実装する余裕がないからという理由。)

属性の影響範囲

これら null 許容性に関する属性は、(少なくとも C# 8.0 時点では)メソッドの外に対してだけ影響します。 以下のように、メソッド内ではフロー解析に寄与していません。

#nullable enable
using System;
using System.Diagnostics.CodeAnalysis;
 
class Program
{
    // メソッドを作る側(メソッドの中)には影響していない。
    [return: MaybeNull]
    static string M() => null; // ここで警告が出る。
 
    static void Main()
    {
        // メソッドを使う側(メソッドの外)にはちゃんと影響してる。
        var s = M();
 
        // MaybeNull なのに null チェックしていないのでここで警告。
        Console.WriteLine(s.Length);
    }
}

外から見た都合(メソッドを使う側)の方が大事なので優先的に実装された結果です。

メソッド内(メソッドを作る側)のフロー解析も将来的には実装される可能性は高いですが、 現状は ! 演算子を使って警告を回避するしかありません。

特殊扱いされるメソッド

前節で紹介した属性を使うことで、いろいろな状況に対応可能です。 しかし、「属性を使って汎用的に解決するほどの需要がない」ということで、 1つ1つ特別扱いすることでフロー解析しているメソッドがいくつかあります。

以下のようなものが該当します(要するに、== の代用になる類のメソッドです)。

これらはちゃんと、== 演算子と同様、null 許容性を伝搬します。 例えば以下のように、EqualityComparer<T>.Default.Euqlas を使って null チェックができます。

private static void EqualityComaprerEquals(string x, string? y)
{
    // IEqualityComparer.Equals は == と同じ扱いを受ける。
    if (EqualityComparer<string>.Default.Equals(x, y))
    {
        // こっちは y が非 null なことがわかるので警告が出ない。
        Console.WriteLine(y.Length);
    }
    else
    {
        // こっちは null な可能性が残るので警告が出る。
        Console.WriteLine(y.Length);
    }
}

移行期間

null 許容参照型はそれなりの期間を掛けて徐々に完成していく予定です。 以下の2つの意味で、少しずつ警告が増えたり減ったりします。

  • C# コンパイラーのフロー解析の精度が上がる
  • .NET Core の基本ライブラリに正しくアノテーション属性が付く

! 演算子の説明でも出てきましたが、 フロー解析はそれなりに労力がかかり、完璧なものは作れません。 バージョンアップとともに少しずつ精度が上がっていくものと思われます。

ほとんどの場合は「過剰に警告が出て、それを ! 演算子で抑止」となり、 精度が上がれば警告が減る方向になると思われます。 しかし一部は、もしかすると警告が増えることが考えられます。

例えば今「抜け穴になっていることはわかっているけど見逃している」状態なのが配列の要素の初期化です。 以下のコードは、フロー解析の漏れであって、可能であれば警告を出したいコードです。 (コンストラクター内で全要素に対して 非 null 初期化しているかどうかまで解析したい。) しかし、少なくとも C# 8.0 時点では警告を出せません。

#nullable enable
using System;
 
class ArrayInit
{
    string[] _buffer;
 
    public ArrayInit()
    {
        // _buffer 自体には new string[] を代入したけど、その要素には何も代入していない。
        // C# の仕様上、_buffer[0] は null になってる(おかしい)。
        // string (? を付けていない)なので null になってはいけないはず。
        _buffer = new string[1];
    }
 
    // string[] からの要素の取り出しなので、string (非 null)のはず。
    // 警告は出ない。
    public string Value => _buffer[0];
}
 
class Program
{
    static void Main()
    {
        var x = new ArrayInit();
        string s = x.Value;
 
        // どこにも警告が出ないものの、実行するとここで null 参照例外発生。
        Console.WriteLine(s.Length);
    }
}

.NET Core 側としても、基本クラス ライブラリに膨大な数のクラス、メソッドがあり、 1度のリリースですべてにアノテーションを付けることは不可能です。 なので、段階的にアノテーションが増える予定です。

実際例えば、LINQ to Object (Enumerableクラス(System.Linq 名前空間の各種拡張メソッド)には .NET Core 3.0 (C# 8.0 と同世代)時点ではアノテーション属性が付いていません。

#nullable enable
using System;
using System.Linq;
 
class Program
{
    static void Main()
    {
        // 以下のコードは null 参照例外を起こすんだから、ToDictionary には DisallowNull 属性が付くべき。
        _ = new[] { "", null }.ToDictionary(x => x);
 
        // 以下のコードは null を返してくるんだから、FirstOrDefault には MaybeNull 属性が付くべき。
        string s = new[] { "a", "b" }.FirstOrDefault(x => x.Length > 2);
        Console.WriteLine(s.Length);
    }
}

これらについては、後からアノテーションが増える予定です。

フロー解析の発達にしろアノテーションの追加にしろ、 いずれもあとから警告が増える可能性があるという点に注意してください。 しばらくの間、「移行期だから仕方がない」と受け入れてもらうしかなさそうです。

(通常、C# は警告の追加すらも「破壊的変更になるから」という理由で避ける文化のプログラミング言語です。 opt-inであることと同様、段階移行も苦渋の選択です。)

更新履歴

ブログ