null許容参照型

参照型の変数はnullの状態になることができます。
(→値型と参照型)
C#8.0からは「nullを受け付けない参照型」を作ることができます。
従来の(nullを受け付ける)参照型はnull許容参照型、nullを受け付けない参照型はnull非許容参照型と呼びます。

参照型のnull許容/非許容は、null許容値型と同じく既存のデータ型に?記号を付けて切り替えます。


//C#8.0以降(null許容コンテキスト)

int n1 = 0;         //null非許容値型
int? n2 = 1;        //null許容値型

string s1 = "abc";  //null非許容参照型
string? s2 = "def"; //null許容参照型

参照型に?を付けるのはC#8.0から可能な新しい文法です。
?ありが許容型、なしが非許容型で、値型と同じ規則になっています。

ただしこの機能の追加にあたっては重大な問題があります。
従来の参照型は?なしで「null許容型」だったのに、C#8.0からは?なしで「null非許容型」となり、コードの意味が変わってしまいます。
(このような既存のコードの意味や挙動を変えてしまう更新を破壊的変更などと呼びます)


//C#7.3では当たり前だったこのコードが
//C#8.0からは許されない
string s = null;

class Test
{
    //参照型のフィールドは
    //初期化しないとnullが初期値
    //なのでこれもダメ
    string s;
}

言語のバージョンアップで勝手に挙動が変わると困るので、null非許容参照型の機能はオプションで有効/無効を切り替えることで既存のコードとの互換を図っています。
この機能を有効にした状態のコードはnull許容コンテキストと呼びます。

ただし、実際にはnull非許容参照型がnullの状態を持つことは可能で、その場合はコンパイラが警告を出しますがコンパイル自体は可能です。

既存の(以前のバージョンで作成した)プロジェクトでは、デフォルトではnull許容コンテキストは無効になっています。
.NET6(Visual Studio2022)以降で新しいプロジェクトを作成した場合、デフォルトでnull許容コンテキストは有効です。

null許容コンテキストの有効化

プロジェクト全体の有効/無効

プロジェクト全体でのnull許容コンテキストの設定は、プロジェクト設定から変更できます。
Visual Studio上部メニューの「プロジェクト」→「(プロジェクト名)の設定」からプロジェクトの設定画面を開きます。
設定の「ビルド」→「全般」に「Null許容」という項目があるので、これを「有効化」に設定するとnull許容コンテキストが有効になります。
(画像はVisual Studio 2022)
プロジェクト全体のnull許容コンテキストの設定

「無効化」は従来通り(C#7.3まで)の挙動です。
参照型はすべてnull許容で、参照型に?を付けると警告が出ます。
(エラーにはなりません)

「警告」は、参照型はすべてnull許容で、参照型に?を付けると警告が出ます。
中身がnullの可能性がある参照型変数を逆参照すると警告が出ます。
(逆参照=参照先のデータにアクセスすること)
ただし、?のない参照型をメソッドの引数やクラスのメンバに指定した場合、メソッド内ではnullではないとみなされ警告は出ません。

「注釈」は、参照型に?を付けることができるようになります。
従来の?のない参照型はnull非許容になりますが、nullの代入や逆参照をしても警告はでません。

null許容コンテキストの「有効化」は「警告」と「注釈」の両方を有効にした状態で、「無効化」は両方を無効にした状態です。

部分的に有効化

コード上の特定の場所でnull許容コンテキストの状態を切り替えることもできます。
これはnullableディレクティブといい、プリプロセッサの一部です。

nullableディレクティブは、コード上の状態を切り替えたい個所に#nullable 状態を記述します。


static void Main(string[] args)
{
//ここからnull許容コンテキストを有効化
#nullable enable
    string str1 = null; //null代入は警告が出る

//ここからnull許容コンテキストを無効化
#nullable disable
    string str2 = null; //null代入に警告はでない

//ここからnull許容コンテキストをプロジェクト設定に戻す
#nullable restore
    string str3 = null; //警告が出るかどうかはプロジェクト設定次第
}

nullableディレクティブは、次のnullableディレクティブが出現するまでの範囲にその状態を適用します。
restoreはnull許容コンテキストの状態をプロジェクト側で設定したものに戻します。

これらはwarningsまたはannotationsをオプションで指定して、指定の機能だけを有効化/無効化、および元の状態に戻すことができます。


//警告を有効化
#nullable enable warnings

//警告を無効化
#nullable disable warnings

//警告をプロジェクト設定に戻す
#nullable restore warnings

//注釈を有効化
#nullable enable annotations

//注釈を無効化
#nullable disable annotations

//注釈をプロジェクト設定に戻す
#nullable restore annotations

ジェネリック

null許容コンテキストはジェネリックの扱いが少しややこしくなっています。

C#2.0

C#8.0よりも前(null許容参照型が導入される前)では、ジェネリック型引数T型をnull許容のT?型として扱うには、型制約でstructを指定する必要がありました。
つまり値型のみnull許容/非許容を?で切り替えることができます。


class Test<T> where T : struct
{
    public T val1;

    //型制約structが必須
    public T? val2;
}

C#8.0

C#8.0からは、型制約にclassまたは既存のクラスを指定した場合でもT?が使用できるようになります。
この場合のTはnull非許容参照型、T?がnull許容参照型になります。


class Test<T> where T : class //null非許容
{
    //null非許容
    public T val1;

    //null許容
    public T? val2;

    public Test(T t)
    {
        //コンストラクターでnull以外で初期化しないと
        //フィールドval1の個所に警告が出る
        val1 = t;
    }
}

class Test2<T> where T : System.IO.Stream //null非許容
{
    //null許容
    public T? val;
}

class Test3<T> //型制約なし
{
    //C#8.0では型制約のないジェネリックで
    //T?型は使用できない
    //コンパイルエラー
    //public T? val;
}

static void Main(string[] args)
{
    var t1 = new Test<string>("");    //OK
    var t2 = new Test<string?>("");   //警告(型が一致しない)
}

このT型はnull非許容の参照型ですが、フィールド(またはプロパティ)は初期化しない場合は既定値で初期化されます。
null非許容であっても参照型の既定値はnullなので、コンストラクターで別の値で初期化しなければフィールドはnullの状態になってしまうため、警告が出ます。
ここではコンストラクターで初期化することで対応していますが、警告の抑制のためとしてはあまり良い方法ではありません。
これについては後述します。

型制約にclass?(または既存クラス名に?)を指定するとnull許容/非許容は呼び出し側で指定した通りになります。


class Test1<T> where T : class?
{
    public T val1;

    //C#8.0ではコンパイルエラー
    //C#9.0ではOK
    //public T? val2;
}

//structには?はつけられない
//コンパイルエラー
//class Test2<T> where T : struct? {}

class Test2<T> where T : struct
{
    public T val1;
    public T? val2;
}

static void Main(string[] args)
{
    var t1 = new Test1<string>();    //OK
    var t2 = new Test1<string?>();   //OK

    var t3 = new Test2<int>();    //OK
    //var t4 = new Test2<int?>();   //コンパイルエラー
}

型制約のclass?structは値型と参照型の違い以外にも、class?はジェネリッククラス(メソッド)側でT?型が使用できない、structは呼び出し側でnull許容値型(int?型など)が使用できないなどの違いがあります。

notnull制約

C#8.0からはnotnullという型制約が追加されており、null非許容の値型/参照型を受け付ける指定ができます。


class Test where T : notnull
{
    public T val1;

    //T型に?は付けられない
    //コンパイルエラー
    //T? val2;

    public Test(T t)
    {
        //Tが参照型の場合の
        //null初期化による警告対策
        val1 = t;
    }
}

static void Main(string[] args)
{
    //OK
    var t1 = new Test<int>(0);
    var t2 = new Test<string>("");

    //警告
    var t3 = new Test<int?>(0);
    var t4 = new Test<string?>("");
}

C#9.0

C#8.0までは型制約なしではT?型は使用できませんでしたが、C#9.0以降は型制約のないジェネリックでも使用できるようになります。


class Test<T>
{
    //Tが参照型だった場合
    //初期値がnullなので警告は出る
    //(本題ではないので今回は無視する)
    public T val1;

    //型実引数にかかわらず
    //null許容型になる…ように見えるが
    //null非許容値型の場合はnull非許容
    public T? val2;
}

static void Main(string[] args)
{
    //すべてOK
    var t1 = new Test<int>();
    var t2 = new Test<string>();
    var t3 = new Test<int?>();
    var t4 = new Test<string?>();
}

文法的にかなりすっきりしましたが、型制約なしのジェネリッククラスのT?は少し特殊な仕様があります。
T?はnull許容型ですが、ここにnull非許容の値型(int型など)を渡した場合はnull許容型になりません。


//int型
t1.val1 = null; //エラー
t1.val2 = null; //エラー

//string型
t2.val1 = null; //警告(null非許容)
t2.val2 = null; //OK

//int?型
t3.val1 = null; //OK
t3.val2 = null; //OK

//string?型
t4.val1 = null; //OK
t4.val2 = null; //OK

t1.val1は普通のint型なのでnullの代入がエラーになるのは当然ですが、T?型であるt1.val2にもnullは代入できません。
つまりこちらも普通のint型になっているということです。

Tがnull非許容の参照型(例えばstring型)の場合、T?はnull許容の参照型(例えばstring?型)になり、値型とは挙動が異なります。

このあたりの挙動の違いはnull非許容参照型がかなり後になってからC#の仕様に組み込まれたことに関係するらしく、直感的とは言えないので注意が必要です。

null免除演算子

null許容参照型とnull非許容参照型は、既存の(C#7.3までの)参照型と内部的には全く同じデータ型で、新しいデータ型が追加されたわけではありません。
null許容コンテキストが有効な場合、コンパイラがコードを分析してnull非許容参照型がnullの状態になっていたり、null逆参照(「nullの参照先」にアクセスしようとすること)を行ったりしていることを検出すると警告を出す仕組みです。
これをフロー解析フロー分析といいます。
(flow analysis)

例えばif文でnullチェックを行うと、コンパイラはそのif文の範囲は「nullではない」と判断します。


//null許容コンテキストが有効の場合は
//nullの代入に警告が出る
string str = null;

//nullの可能性があることを検出し、
//メンバへのアクセスに対して警告を出す
Console.Write(str.Length);

str = null;
if (str != null)
{
    //nullチェックをした場合は
    //警告は出なくなる
    Console.Write(str.Length);
}
else
{
    //ここではもちろん
    //警告が出る
    Console.Write(str.Length);

    //一度警告を出した後の行では
    //同じ警告は出ない
    Console.Write(str.Length);
}

//null許容コンテキストが無効の場合は
//null分析をしないので警告を出さない

この仕組み上、人間にはnullではないことが明確でもコンパイラがそれを検知できない場合があります。


class Test
{
    public string Str = "";

    //オブジェクトが正常に使用可能なら真を返す
    public static bool IsValid(Test? t)
    {
        return t != null && t.Str != null;
    }
}

static void Func(Test? t)
{
    if(Test.IsValid(t))
    {
        //ここでtに警告が出る
        Console.WriteLine(t.Str);
    }
}

このTestクラスのIsValidメソッドは、Test型オブジェクトが正常な(null逆参照の恐れがない)状態の場合は真を返します。
このメソッドでチェックした後は人間にはnullではないことは明らかですが、コンパイラはそのことを検知できないため警告を出します。

このような場合、null免除演算子を使用してnullではないことをプログラマ自身がコンパイラに知らせることができます。


static void Func(Test? t)
{
    if (Test.IsValid(t))
    {
        //tはnullではないことを
        //コンパイラに知らせる
        Console.WriteLine(t!.Str);
    }
}

null免除演算子は、nullではないことを保証するオブジェクトの後ろに!を付けて使用します。

null免除演算子を使用する場合、中身がnullではないことを保証するのはプログラマの責任です。
コンパイラがnullを検知できる場合でもこの演算子を使用すると警告が抑制されてしまうので注意してください。


//これも警告が出なくなる
string s = null!;

属性によるnullの分析

先ほどのIsValidメソッドが編集可能(自作コード)である場合、属性を使用してコンパイラにnullを検知させることができます。


class Test
{
    public string Str = "";

    //メソッドがtrueを返すと、引数のTestオブジェクトは
    //nullではないことが保証される
    public static bool IsValid([NotNullWhen(true)] Test? t)
    {
        return t != null && t.Str != null;
    }
}

static void Func(Test? t)
{
    if (Test.IsValid(t))
    {
        //tに警告は出ない
        Console.WriteLine(t.Str);
    }
}

IsValidメソッドの引数の手前の[NotNullWhen(true)]が属性です。
メソッドがtrueを返す場合、この属性が付けられた引数は呼び出し元でnullではないことがコンパイラに通知されます。
呼び出し元ではnull免除演算子を使用しなくても警告が出なくなります。

NotNullWhen属性はコンパイラのnullの分析を手助けするものです。
null分析のための属性は以下のものがあります。
全てSystem.Diagnostics.CodeAnalysis名前空間に属するので、usingディレクティブに追加しておいてください。

属性 カテゴリ 説明 対象
[AllowNull] 事前条件 null非許容でもnullを受け付ける フィールド、プロパティ、引数
[DisallowNull] 事前条件 null許容でもnullを受け付けない フィールド、プロパティ、引数
[MaybeNull] 事後条件 null非許容でもnullを出力する可能性がある フィールド、プロパティ、引数、戻り値
[NotNull] 事後条件 null許容でもnullを出力しない フィールド、プロパティ、引数、戻り値
[MaybeNullWhen(bool値)] 条件付き事後条件 戻り値が指定のbool値の場合、引数がnull許容でもnullになる可能性がある 引数
[NotNullWhen(bool値)] 条件付き事後条件 戻り値が指定のbool値の場合、引数がnull非許容でもnullにならない 引数
[NotNullIfNotNull(引数名)] 条件付き事後条件 指定された引数がnullでない場合、nullを出力しない プロパティ、引数、戻り値
[MemberNotNull(メンバ名)] ヘルパーメソッド このメソッドの実行後は、指定のメンバーは非nullである
(メソッド内で指定のメンバーにnull以外が代入されることを保証する)
メソッド、プロパティ
[MemberNotNullWhen(bool値, メンバ名)] ヘルパーメソッド このメソッドの戻り値が指定のbool値を返す場合、指定のメンバーは非nullである メソッド、プロパティ
[DoesNotReturn] null分析停止 このメソッドは制御を返さない
(常に例外がスローされる。これ以降のnull分析は停止される)
メソッド
[DoesNotReturnIf] null分析停止 引数が指定のbool値である場合、このメソッドは制御を返されない 引数

AllowNull、DisallowNull属性

AllowNull属性は、フィールド、プロパティ、引数がnullを受け付けることをコンパイラに通知します。
例えばnull非許容のプロパティにnullを渡した場合に「初期化」の処理を行うことができるようになります。


class Test
{
    private string _name = "no name";
    [AllowNull]
    public string Name { 
        get => _name;
        set => _name = string.IsNullOrEmpty(value) ? "no name" : value;
    }
}

プロパティ自体はnull非許容型なので、nullが渡された場合はnull以外の初期値としたい値を代入します。

なお、この属性はプロパティ全体に適用されますが、入力に対する属性なのでsetアクセサにのみ影響します。

反対に、DisallowNull属性は入力にnullを受け付けないことをコンパイラに通知します。
例えば初期値はnullで良いが初期化後にnullを代入させたくない場合に使用できます。


class Test
{
    private string? _name;
    [DisallowNull]
    public string? Name { 
        get => _name;
        set => _name = value ?? throw new ArgumentNullException(nameof(value));
    }
}

このコードでは、nullを受け付けないプロパティにnullを代入すると例外(ArgumentNullException)を送出するようにしています。

nameofは指定した変数やフィールド等の名前の文字列を返すキーワード(式)です。


int num = 0;
string str = "";

Console.WriteLine(nameof(num));
Console.WriteLine(nameof(str));
Console.WriteLine(nameof(str.Length));
num
str
Length

変数名をそのまま文字列で書くのと意味は同じですが、変数名を変更した場合に変更漏れによるミスを減らすことができます。

MaybeNull、NotNull属性

MaybeNull属性は、フィールド、プロパティ、引数、および戻り値がnull非許容型でもnullを返す可能性があることをコンパイラに通知します。
例えばC#8.0では型制約のないジェネリックでT?とは書けないので、この属性でnullになる可能性を通知できます。


[return: MaybeNull]
T Find<T>(IEnumerable<T> sequence, Func<T, bool> predicate)
{
    foreach (T t in sequence)
        if (predicate(t)) return t;
    return default; //参照型のdefault値はnull
}

MaybeNull属性を戻り値に適用する場合、記述する位置はメソッドの手前ですが、そのままだとメソッドへの適用と見分けが付けられないので[return: MaybeNull]という記述戻り値への適用を指定します。

属性の適用先の指定は、他にmethodparamfieldpropertyなどがあります。

反対に、NotNull属性はnullを出力しないことをコンパイラに通知します。


bool ReverseString([NotNull]ref string? s)
{
    if (s == null)
    {
        s = string.Empty;
        return false;
    }
    s = new string(s.Reverse().ToArray());
    return true;
}

このメソッドの実行後は呼び出し側の実引数は必ずnull以外の値が入っていることを保証するためにNotNull属性を使用しています。

通常の引数やin修飾子による参照渡しでは、nullが渡された場合に「nullを出力しない」ことは不可能なので、例外を送出してそれ以上の処理を行わないようにします。


void Func([NotNull]string? s)
{
    if (s == null)
        throw new ArgumentNullException(nameof(s));
    //省略
}

MaybeNullWhen、NotNullWhen、NotNullIfNotNull属性

MaybeNullWhen属性は、メソッドの戻り値が属性のbool型引数と一致する場合にMaybeNull属性の働きをします。
NotNullWhen属性は、メソッドの戻り値が属性のbool型引数と一致する場合にNotNull属性の働きをします。


bool Copy(
    [NotNullWhen(true)] string? s,
    [MaybeNullWhen(false)] out string result)
{
    result = s;
    return s != null;
}

これらの属性は属性の引数を使用します。
(メソッドの引数ではなく、属性自体に引数を指定する)
どちらもreturnValueという引数名なので、以下のように名前付き引数を使用することもできます。


bool Copy(
    [NotNullWhen(returnValue: true)] string? s,
    [MaybeNullWhen(returnValue: false)] out string result);

NotNullIfNotNull属性は、属性引数でメソッドの引数名を指定し、その引数がnullでない場合にnull以外を出力することをコンパイラに通知します。
この属性はプロパティ、引数、戻り値に適用できます。


Dictionary<string, string> _dct = new Dictionary<string, string>();

[return: NotNullIfNotNull("key")]
public string? GetOrAdd(string? key, string val)
{
    if (key == null)
        return null;
    if(_dct.ContainsKey(key))
        return _dct[key];
    _dct.Add(key, val);
    return val;
}

C#11以降は属性の引数にもnameofを使用できるようになったので、以下のように書けます。


[return: NotNullIfNotNull(nameof(key))]
public string? GetOrAdd(string? key, string val)
{/* 省略 */}

MemberNotNull、MemberNotNullWhen属性

MemberNotNull属性は、この属性が指定されたメソッドまたはプロパティの実行後は、属性の引数で指定されたメンバーはnullではないことをコンパイラに通知します。
つまりメソッド内で適切に初期化処理が行われることを保証するもので、主にコンストラクター内で別の初期化用のメソッドを呼び出す場合に使用されます。


class Test
{
    //null非許容のメンバーは
    //メンバー初期化子で初期化するか
    //コンストラクター内で初期化しなければならない
    //(コンストラクターに警告が出る)
    private string _str;

    public Test(string s)
    {
        //このメソッド内でメンバーは初期化されるが
        //そのままではコンパイラは検知できない
        MemberInitializer(s);
    }

    //MemberNotNull属性でフィールド_strは
    //非nullになることをコンパイラに通知する
    [MemberNotNull(nameof(_str))]
    private void MemberInitializer(string s)
    {
        _str = s;
    }
}

なお属性引数のメンバー名の指定はコンマで区切って複数同時に指定できます。
(可変長引数)

MemberNotNullWhen属性は、メソッドの戻り値が属性の第一引数(bool型)と一致する場合にMemberNotNull属性の働きをします。


//メソッドがtrueを返す場合にメンバー_strは非null
[MemberNotNullWhen(true, nameof(_str))]
private bool MemberInitializer(string? s)
{
    if(s == null)
        return false;
    _str = s;
    return true;
}

DoesNotReturn、DoesNotReturnIf属性

DoesNotReturn属性は、そのメソッドの実行以降のフロー解析を停止します。


class Test
{
    string _str = "";

    [DoesNotReturn]
    private void FailFast()
    {
        throw new InvalidOperationException();
    }

    public void Func(string? s)
    {
        //引数sがnullならこれ以上メソッドは実行されない
        if (s == null)
        {                    
            FailFast();
        }

        //引数sがnullの状態でこの行に到達することはないので
        //null警告が出ない
        _str = s;
    }
}

DoesNotReturnIf属性は、bool型の引数の値が属性引数の値と一致する場合はそれ以降のフロー解析を停止します。


class Test
{
    string _str = "";

    private void FailFast([DoesNotReturnIf(true)] bool isNull)
    {
        if (isNull)
        {
            throw new InvalidOperationException();
        }
    }

    public void Func(string? s)
    {
        //引数sがnullならこれ以上メソッドは実行されない
        FailFast(s == null);

        //引数sがnullの状態でこの行に到達することはないので
        //null警告が出ない
        _str = s;
    }
}