目次

キーワード

概要

Ver. 8.0

C# くらいの世代(1990年代後半~2000年代前半)のプログラミング言語では、 参照型には null が「つきもの」で、不可避なものでした。 (参考: 「null参照問題」。)

ただ、2010年代ともなると、「つきもの」として惰性で null を認めるのはよくないとされています。 C# でも、少なくとも「意図して null を使っているかどうか」を区別できる必要性が生まれました。

そこで C# 8.0 では、以下のような機能を提供することにしました。

  • 参照型でも単に型 T と書くと null を認めない型になる
  • T? と書くと null を代入できる型になる

C# 7.X の頃と 8.0 で何が変わったかというと、 「参照型でも null を拒否できるようになった」ということになります。 ただ、「T? と書いたときに null 許容」という方式なのと、値型との対比として、 この機能はnull許容参照型(nullable reference type)と呼びます(略してNRTと言うことも)。

構文的には C# 2.0 からあったnull許容値型と極力そろうように作られています。

ただ、後入りな機能なので、以下のような制約が掛かります。

  • opt-in (オプションを明示しないと有効にならない)方式
    • T の意味が変わるので、opt-in にしないと既存のコードがコンパイルできなくなる
  • 警告のみ
    • T 型の変数に null を代入しても警告だけで、エラーにはならない
  • 値型と参照型で、T? の挙動が違う
    • 参照型の TT? はアノテーションだけの差で、内部的には差がない
    • 値型の場合は T? と書くと実体は Nullable<T> という T と明確に異なる型になる
    • 特に、ジェネリクスを使うときに困る

annotation。「単なる注釈」という意味で、この場合は「コンパイラーがソースコード解析するために使うヒントとなる情報」くらいの意味合い。

null許容参照型の有効化

無条件に「参照型でも null を拒否する」としてしまうと、既存の C# コードの挙動を壊します。

using System;
 
class Program
{
    static void Main()
    {
        // NRT を opt-in した時点で警告が出るようになる
        string s = null; // string (非 null)に null を入れちゃダメ
        Console.WriteLine(s.Length); // null の可能性があるものを null チェックせずに使っちゃダメ
    }
}

警告だから追加してもいいということにはなりません。 警告を残すのは作法的によくないことですし、 なので、C# には「警告をすべてエラー扱いする」というオプションもあります。 警告の追加も破壊的変更の一種になります。

C# は「既存のソースコードがコンパイルできなくなる」というのをかなり慎重に避けている言語なので、null許容参照型は無条件に入れられる機能ではありません。 そのため、明示的な有効化(opt-in)が必要になります。

有効化された状態かどうかを指して、null 許容コンテキスト(nullable context)と言います。 (有効・無効を切り替えることを「null 許容コンテキストの切り替え」とか言ったりします。)

null 許容コンテキストの切り替え方は2通りあります。

  • ソースコード中の行単位での切り替え … #nullable ディレクティブ
  • プロジェクト全体での切り替え … Nullable オプション

また、単純な有効・無効以外に、後述する warnings/annotations (それぞれ警告のみ、アノテーションのみの有効・無効化)というモードもあります。

ちなみに、C# は本来、オプションでのオン/オフ切り替えなど、 「文法の分岐」に対してもかなり消極的な言語です。 opt-in 方式で T の意味が変わるnull許容参照型もだいぶ悩んだ末の苦渋の決断で、 それだけnull参照問題が深刻だということです。 おそらく、C# 史上最初で最後の大きな「分岐」になると思われます。

#nullable ディレクティブ

それなりの規模のソースコードを保守している場合、いきなりnull許容参照型を全面的に有効化してしまうと結構大変なことになります。 (筆者の経験的な話で言うと、少なくとも50行に1個くらいは警告が出ます。何万行ものソースコードを持っている場合、とてもじゃないけど直して回れるものではありません。)

そのため、プリプロセッサー的に、書いたその行以降の opt-in/opt-out をする #nullable ディレクティブが用意されています。 (#pragma warningと似たような使い方をします。)

以下のような書き方をします。

#nullable enable|disable|restore [warnings|annotations]

null 許容参照型を有効にしたければ#nullable enable、 無効にしたければ#nullable disableと書きます。 #nullable restoreは「1つ前のコンテキストに戻す」という処理になります。 warningsannotationsについては後述しますが、省略可能で、省略した場合は「両方をオン・オフ」になります。

public class Program
{
    static void Main()
    {
#nullable enable
        E1(null); // 警告が出る
 
#nullable disable
        E1(null); // 警告が出ない
    }
 
#nullable enable
    // 有効化したのでここでは string で非 null、string? で null 許容。
    static int E1(string s) => s.Length;
    static int? E2(string? s) => s?.Length;
 
#nullable disable
    // 無効化したので string に null が入っている可能性あり。
    // string? とは書けない(書くだけで警告になる)。
    static int D1(string s) => s.Length;
 
#nullable restore
    // 1つ前のコンテキストに戻す。
    // この場合、disable から enable に戻る。
    static int? R1(string? s) => s?.Length;
}

Nullable オプション

一方で、これから新規に作成するプログラムの場合、最初から全部null許容参照型を有効化してしまう方がいいでしょう。 そのくらい、null参照問題は避けたいものです。

プロジェクト全体で null 許容コンテキストを切り替えるには、コンパイラー オプションを指定します。 csc (C# コンパイラー)コマンドを直接使う場合は /nullable オプションで指定します。

csc source.cs /nullable:enable /langversion:8

csproj (C# プロジェクト)ファイル中でオプション指定する場合、<Nullable> タグを使います。

<Project Sdk="Microsoft.NET.Sdk">
 
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>netcoreapp3.0</TargetFramework>
    <Nullable>enable</Nullable>
  </PropertyGroup>
 
</Project>

指定できる値は enable(有効)、disable (無効)、warnings (警告のみ有効)、annotations (アノテーションのみ有効)の4種類です。 warningsannotations については次節で説明します。

warnings/annotations

null 許容参照型には以下の2つの側面があります。

  • アノテーション: 型に ? を付けて null 許容か非 null かを明示する
  • 警告: アノテーションを見て、適切な null チェックが行われてるかどうかを調べて警告を出す

warnings/annotations

既存コードを null 許容参照型に段階的に対応させていくにあたって、 これら2つは別々に有効化・無効化できます。 以下のような状況を想定しています。

  • 差し当たってアノテーションだけは付けたいけど、中身の警告を全部消す作業まで手が回らない
  • 差し当たって警告は出してほしいけど、自分が公開している API にまでは責任を持てないのでアノテーションは付けたくない

アノテーションを付けるかどうかだけを切り替えるのが annotations で、 警告の有無だけを切り替えるのが warnings です。

例えば、元々以下のようなコードがあったとします。

string NotNull() => "";
string MaybeNull() => null;
 
int M(string s)
{
    var s1 = NotNull();
    var s2 = MaybeNull();
    return s.Length + s1.Length + s2.Length;
}

これに対して、単に #nullable enable を付けるとアノテーションも警告も有効になります。

#nullable enable
string NotNull() => "";
string? MaybeNull() => null; // 戻りに ? を追加
 
int M(string s) // この s は非 null の意味になる
{
    var s1 = NotNull();
    var s2 = MaybeNull();
    return s.Length + s1.Length + s2.Length; // s2 のところに警告が出る
}

#nullable enable warnings とすると警告のみ有効化できます。 この場合、引数の string は「C# 7.3 以前と同じ扱い」で、null 許容かどうか「未指定」になります。

// 警告のみ有効化
#nullable enable warnings
int M(string s) // この s は null 許容かどうか「未指定」
{
    var s1 = NotNull();
    var s2 = MaybeNull();
    return s.Length + s1.Length + s2.Length; // s2 のところに警告が出る
}

一方、#nullable enable annotations とするとアノテーションのみが有効化されます。 null のチェック漏れがあっても警告は出ない状態です。

// アノテーションのみ有効化
#nullable enable annotations
int M(string s) // この s は非 null
{
    var s1 = NotNull();
    var s2 = MaybeNull();
    return s.Length + s1.Length + s2.Length; // 警告は出ない
}

フロー解析

null 許容参照型は、フロー解析(flow analysis)で成り立っています。 フロー解析というのは、コードの流れ(flow)を追って、 「使っている場所より前で正しく代入・チェックが行われるか」を C# コンパイラーが調べるものです。

例えば以下のように、変数 s に何を代入したかによって、それ以降、s.Length というようなメンバー アクセス時に警告が出たり出なかったりします。

// null 許容で宣言されていても、
string? s;
 
// ちゃんと有効な値を代入すれば
s = "abc";
 
// 警告は出なくなる。
Console.WriteLine(s.Length);
 
// 逆に null を代入すると、
s = null;
 
// それ以降警告が出る。
Console.WriteLine(s.Length);

分岐などもきっちり調べられます。

private static void M(bool flag)
{
    string? s;
 
    // 分岐の1つでも null があれば、その後ろでは警告が出る。
    if (flag) s = "abc";
    else s = null;
 
    Console.WriteLine(s.Length);
 
    // 分岐の全部で非 null なら、その後ろでは警告が出ない。
    if (flag) s = "abc";
    else s = "123";
 
    Console.WriteLine(s.Length);
}

非 null (? が付いていない)変数・引数には null を渡した時点で警告が出て、 null 許容(? が付いてる)変数・引数の場合はメンバー アクセスの時点で警告が出ます。 また、null 代入の有無の他、is null== null での null チェックをすれば、それ以降の警告は消えます。

using System;
 
public class Program
{
#nullable enable
    // enable なコンテキストでは、string と書くと非 null、string? と書くと null 許容。
    string NotNull(string s) => s;
    string? MaybeNull(string? s) => s;
 
    void M()
    {
        // 非 null。
        var n = NotNull(null); // 引数に null を渡した時点で警告。
        Console.WriteLine(n.Length);
 
        // null 許容。
        var m = MaybeNull(null);
        Console.WriteLine(m.Length); // 戻り値の null チェックをしなかった時点で警告。
 
        if (m is null) return;
        Console.WriteLine(m.Length); // 前の行で null チェックしたのでもう警告にならない。
    }
}

ちなみに、一度何らかのメンバー アクセスをした時点で「null チェックした」扱いを受けます。 「null 許容型を null チェックなしで使ってる」警告が出るのは最初の1個だけになります。

#nullable enable
void M(string? x)
{
    // null チェックせずに使ったので警告。
    Console.WriteLine(x[0]);
 
    // ただ、2重には警告がでない。警告が出るのは↑の行だけ。
    Console.WriteLine(x.Length);
}

他の変数との比較でも null チェックになることがあります。 例えば以下のように、非 null な変数 x と一致したら null 許容な変数 y も null ではないことが確定します。 これもちゃんとフロー解析の対象になっています。

void M(string x, string? y)
{
    // 非 null な x との比較で y が null じゃないことがわかる。
    if (x == y)
    {
        // こっちは y が非 null なことがわかるので警告が出ない。
        Console.WriteLine(y.Length);
    }
    else
    {
        // こっちは null な可能性が残るので警告が出る。
        Console.WriteLine(y.Length);
    }
}

注意: 別スレッドでの書き換え

フィールドやプロパティに対するフロー解析では、利便性を優先して、シングルスレッド動作を前提としたフロー解析をしています。 例えば、以下のように、マルチスレッド動作をしていて、他のスレッドで書き換えられてしまうと、本来 null が来るはずがなく警告も出ない場面で null 参照例外が起こることがあります。

using System;
using System.Threading;
using System.Threading.Tasks;
 
#nullable enable
 
class Program
{
    public string? S;
 
    public void SetNull()
    {
        S = null;
    }
 
    public void SetNonNull()
    {
        if (S is null) S = "";
 
        Thread.Sleep(200);
 
        // 警告はでない。 S = "" しているので非 null 扱い。
        // 単一スレッド実行の場合はおかしくはない。
        // でも、Sleep 中に SetNull を呼ばれると null 参照例外になる。
        Console.WriteLine(S.Length);
    }
 
    static void Main()
    {
        var p = new Program();
        Task.Run(p.SetNonNull);
        Thread.Sleep(100);
        Task.Run(p.SetNull);
 
        Thread.Sleep(300);
    }
}

フィールドやプロパティの初期化

非 null 型のフィールドやプロパティは、コンストラクター内で必ず初期化しなければなりません。 例えば以下のコードはフィールド X、プロパティ Y のところに警告が出ます。

class A
{
    public string X;
    public string Y { get; set; }
}

以下のように、コンストラクターを追加すれば警告が消えます。

class A
{
    public string X;
    public string Y { get; set; }
    public A(string x, string y) => (X, Y) = (x, y);
}

ちなみに、コンストラクターは書いたものの初期化を忘れると、 フィールド・プロパティの方だけではなく、コンストラクターの方にも警告が出ます。

class A
{
    public string X;
 
    // X を初期化していないのでコンストラクターにも警告が出る
    public A() { }
}

ちなみに、最終的には非 null になるものの、コンストラクターの時点ではどうしても一時的に null を入れておかないといけない場面というものもあったりします。 そういうときの回避策として、後述する ! 演算子というものもあります。

class A
{
    // 一時的に null になってしまうことを強制的に容認
    public string X = null!;
}

oblivious

opt-in にしたので、null 許容(nullable)、非 null (non-nullable, not null)の他に、 「アノテーションが付いていない、未指定」という状態があり得ます。 この未指定状態を oblivious (忘れてる、気づかない)と呼びます。

要するに、C# 7.3 以前で書かれたコードや、#nullable enable annotationsになっていない場所で書かれたコードの型が oblivious です。 oblivious な型の変数は一切フロー解析の対象になりません。

using System;
 
public class Program
{
#nullable disable
    // C# 7.3 以前でコンパイルされたものや、disable なコンテキストで定義されると
    // アノテーション「未指定」(oblivious)という扱いになる。
    string Oblivious(string s) => s;
 
#nullable enable
    void M()
    {
        // 未指定。
        // null チェックの対象にならない(警告出ない)。
        var o = Oblivious(null);
        Console.WriteLine(o.Length);
 
        // たとえ明示的な型で受けても、もうこの変数は oblivious 扱いでチェック対象にならない(警告出ない)。
        string? o1 = Oblivious(null);
        Console.WriteLine(o1.Length);
    }
}

null 許容値型との違い

null 許容参照型は、 ? を使う文法こそnull 許容と同じですが、 内部的にはだいぶ違う実装になっています。 null 許容参照型の ? は単なるアノテーション(フロー解析のためのヒント)で、実装上、TT?が本質的には同じ型です。 一方で、null 許容値型の ? は明確に別の型になります(T? と書くとNullable<T>型になります)。

この実装上の差から、使い勝手にも差が出てきます。 まず、以下のように、TT? でオーバーロードできるのは値型だけです。

#nullable enable
// 参照型の場合、アノテーションだけが違うオーバーロードは作れない。
void M(string x) { }
void M(string? x) { }
 
// 値型の場合、? が付くと別の型なのでオーバーロードできる。
void M(int x) { }
void M(int? x) { }

また、null チェック後の挙動が違います。 参照型の場合は null チェックさえ挟めば以後「null ではない」という扱いを受けますが、 値型の場合は null チェックを挟んでも Nullable<T>Nullable<T> のままです。

#nullable enable
// 参照型の場合
void M(string? x)
{
    // null チェックさえすれば
    if (x is null) return;
    // 警告が消える。
    Console.WriteLine(x.Length);
}
 
// 値型の場合
void M(DateTime? x)
{
    // null チェックしても
    if (x is null) return;
    // こういう書き方はできない(x?.Minute や x.Value.Minute なら大丈夫)。
    Console.WriteLine(x.Minute);
}

null 許容参照型は typeof 演算子に対しても使えません。 TT? が内部的には同じ型なのに、typeof(T?) を認めると混乱の元です。 以下のコードはコンパイル エラーになります。

var t = typeof(string?);

更新履歴

ブログ