! 演算子

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? の扱いに困ります。

扱いに困るというか、C# 8.0 では制約なしでは T? とは書けませんでした。 以下のコードはコンパイル エラーになります。 (後述しますが、C# 9.0 でもこの書き方には注意が必要です。)

#nullable enable
class Generic<T>
{
    // T? と書くと C# 8.0 ではコンパイル エラー。
    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 制約

また、新たに 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>() みたいに書くと警告が出るようになります。

ただ、C# 8.0 では 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 を渡せる。
    }
}

制約なしジェネリック型引数

Ver. 9

C# 9.0 で、制約なしのジェネリック型引数 T に対して T? と書けるようになりました。 ジェネリクスの話の冒頭で「C# 8.0 ではエラーになる」と説明した以下のコードが C# 9.0 では有効です。

#nullable enable
class Generic<T>
{
    // C# 9.0 では一応 T? と書ける。
    public T? M() => default;
}

「一応」と言っているのは、この T? にはちょっと注意が必要だからです。 前述のとおり、T? は内部実装的に、値型(構造体など)と参照型(クラスなど)とで結構差があって、 その影響で素直に「nullable (null 許容)」と言えるものになっていません。

どちらかというと「defaultable (規定値になる可能性がある)」というべきで、 以下のように、T? であっても null にはならない(規定値の 0 になる)ことがあります。

#nullable enable
 
using System;
 
// この2つに関しては default == null なので変なことにはならない。
Console.WriteLine(M<string?>()); // null
Console.WriteLine(M<string>()); // null
Console.WriteLine(M<int?>()); // null
 
// 問題が非 null 値型で、この場合 default != null なのでちょっと変。
Console.WriteLine(M<int>()); // 0
 
// ジェネリックな T? は nullable じゃなくて defaultable。
// default を渡しても警告にならない。 
static T? M<T>() => default;

これはちょっと罠になるので、検討当初は T?? みたいな文法で「nullable」と「defaultable」を区別しようかという案も出ていました。 ただ、これはこれで、?? 演算子との区別が付かなくて困る場面があるということで断念されました。 他に新しい記号を導入するのも微妙で、結局、「T? で defaultable 扱い」という決定が下りました。

default 制約

Ver. 9

前節の制約なし型引数のせいなんですが、 ちょっと限定的な状況でだけ必要となる制約として、default 制約というものも増えました。

default 制約が必要になるのは以下のような状況です。

#nullable disable
 
// さかのぼること、null 許容参照型導入前にから以下のような書き方ができた。
class Csharp7
{
    // これは Nullable<T> の意味に。
    public T? M<T>(T? x) where T : struct => null;
 
    // T と Nullable<T> は別の型扱いなのでオーバーロード可能。
    public T M<T>(T x) => default;
}
 
#nullable enable
 
// ここで、null 許容参照型を有効化。
// 特に、C# 9.0 では制約なし型引数に対して T? と書けるようになったので…
class Base
{
    // これは Nullable<T> の意味に。
    public virtual T? M<T>(T? t) where T : struct => null;
 
    // これは C# 9.0 の制約なし型引数に対する null 許容(正確には default 許容)アノテーション。
    // T と Nullable<T> 違いのオーバーロードという扱いになる。
    public virtual T? M<T>(T? t) => default;
}
 
// さらに紛らわしいのが↑を override したときで…
class Derived : Base
{
    // これ、実は Nullable<T> の意味。
    // 親クラス側の where T : struct 制約を自動的に引き継いでしまう。
    // こうしないと C# 8.0 以前との整合性が取れないとのこと。
    public override T? M<T>(T? t) => null;
 
    // ということで、制約なし T? の方を参照するために別の制約が必要になったという経緯があり。
    // override 時に限り、where T : struct じゃない方に、逆に where T : default という制約を書く必要がある。
    public override T? M<T>(T? t) where T : default => default;
}

まとめると、

  • 古いバージョンとの互換性のため、ジェネリック型引数に対して TT? は別の型になっている
  • 基底クラス側で where T : struct と書いているものは、派生クラスでは改めて where T : struct と書かなくてもいい仕様だった
  • C# 9.0 で制約なし型引数に対しても T? と書けるようになったことで、派生クラス側の挙動が怪しくなった
  • この問題を回避するため、派生クラス側には where T : default という制約を書く必要がある

という感じです。 前節で説明した通り、制約なしの型引数に対する T? は「null 許容」というよりは「default 許容」(defaultable)なので、where T : default というキーワードを用います。

更新履歴

ブログ