Movatterモバイル変換


[0]ホーム

URL:


Logo

目次

キーワード

概要

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;classProgram{staticvoidMain()    {// NRT を opt-in した時点で警告が出るようになるstrings =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と似たような使い方をします。)

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

#nullableenable|disable|restore[warnings|annotations]

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

publicclassProgram{staticvoidMain()    {#nullableenableE1(null);// 警告が出る#nullabledisableE1(null);// 警告が出ない    }#nullableenable// 有効化したのでここでは string で非 null、string? で null 許容。staticintE1(strings) =>s.Length;staticint?E2(string?s) =>s?.Length;#nullabledisable// 無効化したので string に null が入っている可能性あり。// string? とは書けない(書くだけで警告になる)。staticintD1(strings) =>s.Length;#nullablerestore// 1つ前のコンテキストに戻す。// この場合、disable から enable に戻る。staticint?R1(string?s) =>s?.Length;}

Nullable オプション

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

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

cscsource.cs/nullable:enable /langversion:8

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

<ProjectSdk="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 です。

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

stringNotNull() =>"";stringMaybeNull() =>null;intM(strings){vars1 =NotNull();vars2 =MaybeNull();returns.Length +s1.Length +s2.Length;}

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

#nullableenablestringNotNull() =>"";string?MaybeNull() =>null;// 戻りに ? を追加intM(strings)// この s は非 null の意味になる{vars1 =NotNull();vars2 =MaybeNull();returns.Length +s1.Length +s2.Length;// s2 のところに警告が出る}

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

// 警告のみ有効化#nullableenablewarningsintM(strings)// この s は null 許容かどうか「未指定」{vars1 =NotNull();vars2 =MaybeNull();returns.Length +s1.Length +s2.Length;// s2 のところに警告が出る}

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

// アノテーションのみ有効化#nullableenableannotationsintM(strings)// この s は非 null{vars1 =NotNull();vars2 =MaybeNull();returns.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);

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

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

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

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

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

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

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

voidM(stringx,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;#nullableenableclassProgram{publicstring? S;publicvoidSetNull()    {        S =null;    }publicvoidSetNonNull()    {if (Sisnull) S ="";Thread.Sleep(200);// 警告はでない。 S = "" しているので非 null 扱い。// 単一スレッド実行の場合はおかしくはない。// でも、Sleep 中に SetNull を呼ばれると null 参照例外になる。Console.WriteLine(S.Length);    }staticvoidMain()    {varp =newProgram();Task.Run(p.SetNonNull);Thread.Sleep(100);Task.Run(p.SetNull);Thread.Sleep(300);    }}

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

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

classA{publicstringX;publicstringY {get;set; }}

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

classA{publicstring X;publicstring Y {get;set; }publicA(stringx,stringy) => (X, Y) = (x,y);}

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

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

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

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

oblivious

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

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

using System;publicclassProgram{#nullabledisable// C# 7.3 以前でコンパイルされたものや、disable なコンテキストで定義されると// アノテーション「未指定」(oblivious)という扱いになる。stringOblivious(strings) =>s;#nullableenablevoidM()    {// 未指定。// null チェックの対象にならない(警告出ない)。varo =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? でオーバーロードできるのは値型だけです。

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

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

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

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

vart =typeof(string?);

更新履歴

更新:言語バージョンの指定

[C#]

ファイル ベース実行

[C#]

C# 14.0 の新機能

[C#]

更新:[雑記] オーバーロード解決

[C#]

型の分割定義 (partial)

[C#]

ブログ

C# 14 の破壊的変更点(First-class Span)

ファイナライザー

文字列リテラルを data セクションに UTF-8 で書き込む案

nameof(T<>)

First-class な Span 型


誤字等を見つけた場合や、ご意見・ご要望がございましたら、GitHub の Issues まで気兼ねなくご連絡ください。

[8]ページ先頭

©2009-2025 Movatter.jp