! 演算子

null 許容なものを、is null== null などによるチェック抜きで、 強制的に非 null 扱いしたい場合があります。 原因としては2つあって、以下のような場面で「強制非 null 扱い」が必要になります。

  • コンストラクターの時点では非 null 保証が絶対にできない(後からの初期化が必須になる)場合がある
  • フロー解析の未熟さからコンパイラーが判定しきれない場合がある

前者のわかりやすい例は循環参照がある場合です。 お互いにインスタンスを持ち合う必要がある場面では、どちらか片方は絶対にコンストラクターよりも後でないとインスタンスを渡せません。

class PairedNode
{
    // このプロパティに対する警告が消せない。
    public PairedNode Pairing { get; private set; }
 
    public static (PairedNode a, PairedNode b) Create()
    {
        var a = new PairedNode();
 
        // 後から作る方は new の時点でインスタンスを受け取れる。
        // なのでやろうと思えばコンストラクターにも渡せる。
        var b = new PairedNode { Pairing = a };
 
        // でも、先に作った方にはどうしても後からの指しなおしが必要。
        a.Pairing = b;
 
        return (a, b);
    }
}

後者の例は、例えば ReferenceEquals とかです。 null に関するフロー解析は結構ぎりぎりまで作業をしているようで、 ReferenceEquals に関する解析は Visual Studio 16.3 Preview 1 (2019年7月)時点では未対応、 Preview 2 (同8月) 時点で初めて対応しました。

void M(string x, string? y)
{
    if (ReferenceEquals(x, y))
    {
        // x == y なら警告が消えるのに、ReferenceEquals だと残ってた。
        // 16.3 Preview 1 の時点では警告あり、Preview 2 から消える。
        Console.WriteLine(y.Length);
    }
}

この例はまだ需要もあって対処も楽な類なので対応されましたが、 もっとレアだったり、対処にコストがかかりすぎる場合は対応してもらえない可能性が高いです。

要するに、null がらみのフロー解析には無理なもの・やっても割に合わないものがざらにあるので、 フロー解析をあえて抑止するような手段が必要になります。

そこで用意されているのが後置き ! 演算子です。 a! というように、式の後ろに ! を付けると、式 a の null 許容性は無視して常に非 null 扱いになります。

#nullable enable
using System;
 
class PairedNode
{
    // null を無理やり非 null 扱いにして警告を消す。
    // (省略したものの前述の) Create の中で自己責任で非 null を保証してるので大丈夫。
    public PairedNode Pairing { get; private set; } = null!;
}
class Program
{
    void M(string x, string? y)
    {
        if (ReferenceEquals(x, y))
        {
            // string? だけども気にせずメンバー アクセスする。
            // コンパイラーにはわからないかもしれないけども、人間はこの時点で y が非 null なことを知っている。
            Console.WriteLine(y!.Length);
        }
    }
}

この ! 演算子は null forgiving (null に寛大)演算子とか、 null suppression (null 抑止) 演算子などと呼ばれています。 コンパイラーが厳しく(ただ、過剰に)チェックしてくれているものを、あえて緩めておおらかにコードを書く「回避策」的なものなのでこんな呼び名になっています。

(ただ、最近、C# のドキュメントは結構ぎりぎりになるまで正式な用語決定をしないので、 この呼称も最後までこのままかどうかはわかりません。「通称」になる可能性あり。)

ちなみに、! 演算子は英語で口頭だと bang operator とか言ったりもするみたいです。 (bang は破裂音の擬音語。「バンと音を立ててびっくりさせる」から、ビックリマークのことを bang と読んだりするそうです。)

(他のプログラミング言語では、「(コンパイラーには無理な) null 判定をプログラマーが明示する」という意味で not-null assertion (非 null 表明)と言ったり、 「強制的に非 null にしてしまう」という意味で force unwrap (強制アンラップ)と言ったりします。)

! 演算子を使うと本当に自己責任になります。 フロー解析の対象から外れて、NullReferenceException を起こす可能性が出てきます。 また、! を書いた地点には特に何も実行時チェックが入りません。 実際に NullReferenceException を起こすのはメンバー アクセスした瞬間です。 問題の真の原因と、例外が発生する場所がずれるので注意が必要です。

#nullable enable
using System;
 
class Program
{
    static void Main()
    {
        // 悪用して、本当に null を渡してはいけないところに null を渡す。
        // この時点では例外が出ない。
        M(null!);
    }
 
    static void M(string x)
    {
        // 実際に NullReferenceException を起こすのは以下の行。
        Console.WriteLine(x.Length);
    }
}

ちなみに、2重に ! を付けようとするとコンパイル エラーになります。 例えば以下のコードはx!! のところでコンパイル エラーが出ます。

static void M(string? x)
{
    var y = x!!;
}

ジェネリクス

前述の通り、 null 許容型の T? は参照型と値型でだいぶ実装方法が違います。 これで特に問題になるのはジェネリクスです。 型引数には参照型が渡される場合も値型が渡される場合もあって、 そういうときに T? の扱いに困ります。

扱いに困るというか、実際、制約なしでは T? とは書けません。 以下のコードはコンパイル エラーになります。

#nullable enable
class Generic<T>
{
    // T? と書くとコンパイル エラー。
    public T? M() => default;
}

一方、struct 制約や class 制約、基底クラス制約を付けると T? と書けるようになります。 struct 制約は null 許容値型の仕様によるもので、C# 2.0 の頃から書けます。 「制約に単に class と書くと非 null の意味になる」というのが新仕様になります。

#nullable enable
using System;
 
// struct 制約を付けると null 許容"値型"を使えるようになる。
class StructConstraint<T> where T : struct
{
    public T? M() => default;
}
 
// class 制約は「非 null 参照型」の意味の制約になる。
// なので T? と書いて null 許容"参照"型を作れるようになる。
class ClassConstraint<T> where T : class
{
    public T? M() => null;
}
 
// 基底クラス制約も「非 null」扱い。
class BaseTypeConstarint<T> where T : Exception
{
    public T? M() => null;
}
 
class Program
{
    static void Main()
    {
        // class 制約を満たしてる。
        var x = new ClassConstraint<string>();
 
        // class 制約は「非 null」扱いなので以下のコードには警告あり。
        var y = new ClassConstraint<string?>();
    }
}

その代わり、class、基底クラス制約に ? を付けることで null 許容参照型を受け付けることができます。

#nullable enable
using System;
 
// class? 制約で「null 許容参照型」を表す。
class ClassConstraint<T> where T : class?
{
    // class? な型 T をさらに T? にはできず、コンパイル エラーになる。
    public T? M() => null;
}
 
// 基底クラス制約でも ? を使って null 許容にできる。
class BaseTypeConstarint<T> where T : Exception?
{
    // この行がコンパイル エラーになるのは class? 制約と同じ。
    public T? M() => null;
}
 
class Program
{
    static void Main()
    {
        // class? 制約なので特に警告なし。
        var y = new ClassConstraint<string?>();
    }
}

また、新たに notnull 制約というものが追加されて、 非 null 参照型もしくは非 null 値型のみを受け付けることができます。

#nullable enable
 
class NotNullConstraint<T>
    where T : notnull
{
}
 
class Program
{
 
    static void Main()
    {
        // この2行は OK。
        var ok1 = new NotNullConstraint<int>();
        var ok2 = new NotNullConstraint<string>();
 
        // この2行には警告が出る。
        var ng1 = new NotNullConstraint<int?>();
        var ng2 = new NotNullConstraint<string?>();
    }
}

例えば、Dictionary<TKey, TValue> (System.Collections.Generic名前空間)のキーは元々 null を受け付けていません。d[null] = 0 みたいな書き方をすると null 参照例外が発生します。 なので、.NET Core 3.0 の DictionaryTKey には notnull 制約が付いています。 new Dicitionary<int?, string>() みたいに書くと警告が出るようになります。

ただ、notnull 制約を付けてもなお、T? とは書けません。 (参照型と値型での null 許容の仕様の差が大きすぎてちょっと難しいようです。 もし実現しようと思うなら、C# コンパイラーのレベルでは無理で、.NET ランタイムの型システム レベルでの改修が必要。)

#nullable enable
 
class NotNullConstraint<T>
    where T : notnull
{
    // 以下の2行はコンパイル エラーになる。
    T? M() => null;
    int M(T? x) => x is null ? 0 : x.GetHashCode();
}

一応、次節で説明する属性を使ってある程度の問題回避はできます。

#nullable enable
using System.Diagnostics.CodeAnalysis;
 
class NotNullConstraint<T>
    where T : notnull
{
    // T? と書けないことに対する代替手段。
    [return: MaybeNull] public T M() => default!;
    public int M([AllowNull] T x) => x is null ? 0 : x.GetHashCode();
}
 
class Program
{
    static void Main()
    {
        var x = new NotNullConstraint<string>();
        string? nullable = x.M(); // string M() だけど null が返ってくる。
        x.M(null); // M(string) だけど null を渡せる。
    }
}

更新履歴

ブログ