アノテーション属性

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

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

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

.NET Core 3.0 からあるもの
分類属性名概要
事前条件 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 扱い
.NET 5 からあるもの
分類属性名概要
他のメンバー MemberNotNull この属性が付いたメンバーを呼んだ時点で、他のメンバーの非 null が確定する
MemberNotNullWhen この属性が付いたメンバーを呼ばれて、かつ、戻り値が特定の値だった時点で、他のメンバーの非 null が確定する

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

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

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

Array.Resize (NotNull)

まず、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); // 警告なし
    }
}

TextWriter.NewLine (AllowNull)

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

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

ジェネリック型引数に対するアノテーション (MeybeNull)

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

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

Try メソッド (NotNullWhen)

.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);
}

null 伝搬 (NotNullIfNotNull)

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 属性によってそれなりに強い需要が生じてしまったので修正が入る可能性はありますが、破壊的変更になりそうなのであんまり期待はできません。)

FailFast (DoesNotReturn)

一部のメソッドは、そのメソッドを呼んだら最後、もう絶対に正常には戻ってこないものがあります。例えば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);

Assert (DoesNotReturnIf)

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

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

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

特殊扱いされるメソッド

前節で紹介した属性を使うことで、いろいろな状況に対応可能です。 しかし、「属性を使って汎用的に解決するほどの需要がない」ということで、 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);
    }
}

C# バージョン変更なしでのフロー解析の改善

フロー解析の改善は、 C# の文法に追加があるわけではなく単に警告の増減なこともあって、 C# のバージョン変更なし(パッチ バージョンアップ)で機能が増えたりします。

アノテーション属性のメソッド内への影響

C# 8.0 のリリース直後の時点では、 null 許容性に関する属性はメソッドの外に対してだけ影響を及ぼしていました。 以下のように、メソッド内ではフロー解析に寄与していませんでした。

#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);
    }
}

外から見た都合(メソッドを使う側)の方が大事なので優先的に実装された結果です。 当初、null 戻り値のところに ! 演算子を付けて警告を回避するしかありませんでした。

この挙動は Visual Studio 16.6 (2020年5月リリース)で改善されていて、今はもうメソッド M の定義側の警告は出ません (ちゃんと、MaybeNull 属性を解釈して null 戻り値を許す)。 「C# 8.1」になったとかではなく、「C# 8.0」のまま、フロー解析だけ改善されています。

MemberNotNull 属性の追加

MemberNotNullMemberNotNullWhen 属性のフロー解析も Visual Studio 16.6 (2020年5月リリース)で追加されています。

MemberNotNull 属性は、あるメンバー(メソッドやプロパティ)を呼んだ時点で、 別のメンバーが非 null であることを決定するための属性です。

例えば以下のような状況を考えます (実際、標準ライブラリの DeflateStreamクラスに似たようなコードが入っています)。

class DeflateStream
{
    private Stream _stream; // コンストラクターで初期化していないので警告が出る。
 
    public DeflateStream(Stream stream)
    {
        Initialize(stream);
    }
 
    private void Initialize(Stream stream)
    {
        _stream = stream;
    }
}

Initialize メソッドを介して間接的には非 null なフィールドをちゃんと初期化しているんですが、 これまでだとこの状況を正しくフロー解析する手段がありませんでした。 これに対して、MemberNotNull 属性が追加されたことで以下のように書けるようになりました。

class DeflateStream
{
    private Stream _stream; // Initialize 内で初期化される。
 
    public DeflateStream(Stream stream)
    {
        // Initialize 内で _stream が初期化されることがわかるので警告が消える。
        Initialize(stream);
    }
 
    // この属性によって正しくフロー解析できるようになってる。
    [MemberNotNull(nameof(_stream))]
    private void Initialize(Stream stream)
    {
        _stream = stream;
    }
}

移行期間

.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であることと同様、段階移行も苦渋の選択です。)

更新履歴

ブログ