February 2018

Volume 33 Number 2

Essential .NET - C# 8.0 と Null 許容参照型

Mark Michaelis | February 2018

Null 許容参照型とは何でしょう。すべての参照型は Null が許容されるのではないでしょうか。 

筆者は C# がお気に入りで、その綿密な言語設計はすばらしいと感じています。とはいえ、現在、C# が 7 回もバージョンが更新され、確固たる地位を築いているとしても、まだ完ぺきな言語とはいえません。つまり、C# には必ず新機能が追加されるだろうと当然のように予測されますが、残念ながら、いくつか問題もあります。その問題は、バグというよりも、根本的な問題です。最も大きな問題になる分野の 1 つは、参照型が Null になる可能性があることです。実際、参照型の既定値は Null です。この問題は C# 1.0 の頃から存在しています。Null 許容の参照型が理想的とは言えない理由は、次のようにいくつかあります。

  • Null 値のメンバーを呼び出すと System.NullReferenceException 例外が発生します。運用コードでは System.NullReferenceException になるすべての呼び出しはバグになります。しかし、残念ながら、Null 許容の参照型があると、適切なことではなく不適切なことを行う「羽目」になります。このような「羽目」になる操作とは、Null かどうかをチェックせずに参照型を呼び出す操作です。
  • (Nullable<T> が導入されて以降) 参照型と値型の間には一貫性がありません。値型は "?" で修飾 (例、int? number) すると Null 許容になりますが、既定では Null が許容されません。一方、参照型は既定で Null 値が許容されます。長い間 C# でプログラミングを行ってきた開発者にとってこれは「普通」のことです。しかし、すべてを最初からやり直せるなら、参照型の既定を Null 非許容にして、"?" を追加することで Null を明示的に許容できるようにすることを考えるでしょう。
  • ある値を逆参照する場合に、その前に静的フロー解析を実行してすべてのパスを確認し、その値が Null かどうかをチェックすることは不可能です。たとえば、アンマネージ コードの呼び出しがある、マルチスレッドで処理される、または実行時の条件に基づいて Null の代入/置換が行われるといった状況を想像してみてください (呼び出されるすべてのライブラリ API のチェックを解析の対象に含めるなど論外です)。
  • 特定の宣言に対して Null 値になる参照型が無効であることを示す合理的な構文はありません。
  • Null を許容しないようにパラメーターを修飾する方法もありません。

以上のようなことにもかかわらず、前述のとおり、この Null の動作を C# の特異性として素直に受け入れるほど、筆者は C# を気に入っています。ただし、C# 言語チームは C# 8.0 でこの点を改善することを目指しています。具体的には、次のようなことを実現しようとしています。

  • Null 値が想定される構文を用意する: 参照型に Null が格納されると想定される場合を開発者が明示的に特定できるようにします。したがって、参照型に対して明示的に Null を代入している場合はフラグが設定されないようにします。
  • 既定の参照型で Null 値を許容しないように想定する: すべての参照型において既定で Null 値が許容されないことを想定するように変更します。ただし、既存のコードに対する警告が突然増えて開発者が困惑する事態にならないように、オプトイン コンパイル スイッチを用意したうえでそのように対応します。
  • NullReferenceExceptions の発生を減らす: 静的フロー解析を改善することで、NullReferenceException 例外の可能性を減らします。この静的フロー解析は、値のいずれかのメンバーを呼び出す前に、その値に対して明示的に Null チェックが行われていない可能性がある場合にフラグを設定します。
  • 静的フロー解析の警告を抑制できるようにする: 「プログラマを信頼してください」という形の宣言をサポートします。この宣言により、開発者はコンパイラの静的フロー解析をオーバーライドできるようになります。その結果、潜在的な NullReferenceException の警告を抑制できるようになります。

ここからは、これらの各目標と、C# 8.0 で C# 言語内にこれらの基本的なサポートが実装されるしくみについて検討します。

Null を想定する構文を用意する

まず、参照型で Null を想定する必要がある場合と、そうでない場合とを区別する構文が必要です。Null を許容する構文は明らかで、Null 許容宣言として "?" を使用します。これで値型も参照型も同じになります。参照型のサポートが追加されることで、開発者には Null をオプトインする方法が用意されます。以下に例を示します。

string? text = null;

この構文が追加されることで、重要な Null 許容の改善が、「Null 許容参照型」という紛らわしく見える名前でまとめられている理由が分かります。 Null 許容参照データ型が新しく登場するわけではありません。こうしたデータ型に対する明示的な (オプトインの) サポートが追加されるということです。

Null 許容参照型の構文があるなら、Null 非許容参照型の構文はどうなるでしょう。  以下は、

string! text = "Inigo Montoya"

適切な選択のように見えるかもしれません。しかし、これによって、次のような単純な形は何を意味するかという疑問が浮かびます。

string text = GetText();

Null 許容参照型、Null 非許容参照型、まだどちらともわからない参照型という、3 つの宣言が用意されるのでしょうか。そうではありません。

本当に必要なのは以下の 2 つです。

  • Null 許容参照型: string? text = Null;
  • Null 非許容参照型: string text = "Inigo Montoya"

これはもちろん、修飾子のない参照型は既定で Null 非許容になるという重大な言語の変更が加えられることを示します。

既定の参照型で Null 値を許容しないように想定する

(Null 許容修飾子のない) 標準の参照宣言を Null 値を許容しないように切り替えることは、Null 許容の特異性を低減するためのすべての要件の中で、おそらく最も難しいことです。実のところ、現在の string text; は text という参照型になります。この参照型は、text が Null であることを許容し、text が Null になることを想定します。実際、フィールドや配列を使用する場合など、既定の text が Null になることはよくあります。しかし、値型と同じように、Null を許容する参照型は既定ではなく例外にします。Null を text に代入する場合や、text を Null 以外に初期化できなかった場合、コンパイラが text 変数の逆参照に対してフラグを設定するのであれば (コンパイラでは初期化前のローカル変数の逆参照に対して既にフラグが設定されます)、Null を許容する参照型が例外の方が望ましいでしょう。

残念ながらこれは、言語に変更が加えられ、Null を代入する (string text = null など) 場合や、Null 許容参照型を代入する (string? text = null; string moreText = text など) 場合に、警告を発することを意味します。前者 (string text = null) は重大な変更です (これまで警告の対象にならなかったものに警告が発行されるのは重大な変更です)。  C# 8.0 コンパイラを使用し始めた途端に警告が出されて開発者が困惑するという事態を避けるために、実際のところ、Null 値の許容のサポートは既定でオフにされます。そのため、重大な変更にはなりません。したがって、Null 値の許容を利用するには、この機能をオンにするようオプトインする必要があります (ただし、本稿執筆時点で itl.tc/csnrtp (英語) から利用できるプレビューでは Null 値の許容が既定でオンになっているので注意してください)。

当然、この機能をオンにしたら、警告が表示され、選択肢が与えられます。参照型が Null を許容することを意図的なものかどうかを明示的に選ぶ必要があります。意図的ではなければ、Null の代入を削除します。そうすれば、警告も取り除かれます。しかし、こうすることで後になって警告が発生する可能性があります。変数に代入が行わないため、その変数に Null 以外の値を代入することが必要になるためです。または、Null が明確に意図されたものである場合 (たとえば、「不明」を表す場合) は、宣言型を次のように Null 許容に変更します。

string? text = null;

NullReferenceExceptions の発生を減らす

型を Null 許容または Null 非許容として宣言する方法が用意されることで、宣言が違反になる可能性のある状況を特定するのはコンパイラの静的フロー解析の役割になります。参照型を Null 許容として宣言するか、Null 非許容型への Null 代入を避けるか、そのどちらかが機能することになりますが、後になってコードで新たな警告またはエラーが表れる可能性があります。前述のように、ローカル変数に代入が行われなければ、後ほどコードで Null 非許容参照型に起因するエラーが発生することになります (これは C# 8.0 以前のローカル変数に当てはまります)。一方、静的フロー解析は、Null 許容型の逆参照呼び出しのうち、Null についてや、Null 以外の値への Null 許容値の代入について事前チェックを検出できなかった逆参照呼び出しにフラグを設定します。図 1 にいくつかの例を示します。

図 1 静的フロー解析結果の例

string text1 = null;
// Warning: Cannot convert null to non-nullable reference
string? text2 = null;
string text3 = text2;
// Warning: Possible null reference assignment
Console.WriteLine( text2.Length ); 
// Warning: Possible dereference of a null reference
if(text2 != null) { Console.WriteLine( text2.Length); }
// Allowed given check for null

どちらの場合も、静的フロー解析を使用して Null 許容の意図を確認することで、最終結果において NullReference­Exceptions の可能性が減少します。

前に説明したように、静的フロー解析では Null 非許容型に Null が代入される (直接、または Null 許容型が代入される) 可能性がある場合に、フラグを設定します。残念ながら、これは完ぺきな対策ではありません。たとえば、Null 非許容参照型を返すことがメソッドで宣言されている場合 (Null 値の許容修飾子でライブラリが更新されていないなど)、誤ってメソッドが Null を返す場合 (警告が無視されたなど)、または致命的でないエラーが発生し、想定していた代入が実行されない場合に、Null 非許容参照型に Null 値が代入される可能性が残されています。それは残念ですが、Null 許容参照型のサポートにより、NullReferenceException がスローされる可能性は減ることになります。ただし、そうしたことが完全に起こらなくなるわけではありません (これは、変数が代入されている場合、コンパイラによるチェックが誤りやすいことに似ています)。 同様に、静的フロー解析では実のところ、値を逆参照する前にコードで Null のチェックが行われていることを常に認識するわけでもありません。実は、この解析フローではローカルとパラメーターのメソッド本文内において Null 値の許容だけを確認し、メソッド シグネチャと演算子シグネチャを利用して有効性を判断します。たとえば、IsNullOrEmpty というメソッドの本文を詳しく調べることはせずに、追加の Null チェックが不要になるような方法で、Null のチェックがメソッドで正常に行われているかどうかについて解析します。

静的フロー解析の抑制を有効にする

静的フロー解析が誤りやすいことを考えると、(object.ReferenceEquals(s, null) や string.IsNullOrEmpty() などの呼び出しを使用する場合に) Null のチェックがコンパイラによって認識されなかったら、どうなるでしょう。値が Null にならないことをプログラマが適切に把握している場合は、次のような "!" 演算子を後に続けることで逆参照できます (例: text!)。

string? text;...
if(object.ReferenceEquals(text, null))
{  var type = text!.GetType()
}

感嘆符がなければ、Null 呼び出しの可能性についてコンパイラが警告します。同様に、Null 許容値を Null 非許容値に代入する場合は、代入された値を感嘆符で修飾することにより、プログラマが適切に把握しているという情報をコンパイラに伝えることができます。

string moreText = text!;

このようにして、明示的なキャストを使用できるのと同じように、静的フロー解析をオーバーライドできます。当然、実行時にも適切な確認が引き続き行われることになります。

まとめ

参照型用に Null 値の許容修飾子が導入されますが、これは新しい型が導入されることを意味するわけではありません。参照型はまだ Null 許容であり、string? をコンパイルした結果の IL は相変わらず System.String にすぎません。IL レベルで異なるのは、次の属性を使用した Null 許容変更型の修飾です。

System.Runtime.CompilerServices.NullableAttribute

こうすることで、宣言済みの意図を後工程のコンパイルで引き続き利用できるようになります。さらに、この属性を利用できるなら、Null 値の許容の改善を使用せずとも、旧バージョンの C# でも C# 8.0 でコンパイルされたライブラリを参照できます。最も重要なのは、これにより、API の機能を停止することなく Null 許容メタデータを使用して既存の API (.NET API など) を更新できることです。またこれは、Null 値の許容修飾子に基づいたオーバーロードのサポートがないことも意味します。

C# 8.0 における Null 処理の強化の残念な結果が 1 つあります。従来 Null 許容だった宣言が Null 非許容に切り替わることで、最初に数多くの警告が生じることです。これは残念ですが、ストレスとコード改善との間における適度なバランスは保たれていると考えています。

  • Null 非許容型への Null 代入を削除するよう警告されることで、Null になるべきでない値が Null にならなくなるため、バグが排除される可能性があります。
  • また、Null 許容修飾子を追加すると、意図がより明確化されてコードが改善されるでしょう。
  • 時間と共に、Null 許容更新済みコードと過去のコードとの間の障害となる不一致は解消され、以前発生していた NullReferenceException バグは減ることになります。
  • 既存のプロジェクトでは Null 値の許容機能が既定でオフにされるため、自分で選ぶまでこの機能への対応を遅らせることができます。最終的にはコードがより堅牢なものになるでしょう。コンパイラよりも自分の方が適切に把握しているという場合は、キャストのように "!" 演算子 (「プログラマを信頼してください」宣言) を使用できます。

C# 8.0 のさらなる機能強化

C# 8.0 向けに検討されているその他の領域の機能強化は主に 3 つあります。

非同期ストリーム: 非同期ストリームをサポートし、await 構文でタスクのコレクション (Task<bool>) を反復処理できるようになります。たとえば、次を呼び出すことができます。

foreach await (var data in asyncStream)

スレッドは await に続くステートメントをブロックしませんが、反復が完了したらすぐにステートメントを「続行」します。また、反復子は、後に T Current { get; } の呼び出しが続く要求に応じて次のアイテムを生み出します (この要求は、列挙可能なストリームの反復子に対する Task<bool> MoveNextAsync の呼び出しです)。

既定のインターフェイスの実装: C# では、各インターフェイスのシグネチャが継承されるように複数のインターフェイスを実装できます。さらに、基底クラスにメンバーの実装を提供し、すべての派生クラスでそのメンバーの既定の実装が使用されるようにすることができます。残念ながら、複数のインターフェイスを実装して、そのインターフェイスの既定の実装を提供することまでは現在できません。つまり、多重継承はサポートされていません。マイクロソフトは既定のインターフェイスの実装を導入することでこの制約を克服しています。合理的な既定の実装が可能であるという前提で、C# 8.0 では既定のメンバーの実装 (プロパティとメソッドのみ) を含めることが可能になり、インターフェイスを実装するすべてのクラスに既定の実装が用意されることになります。多重継承のメリットは副次的なものかもしれません。これにより提供される本当の改善は、API に重大な変更を加えることなく、追加メンバーを使用してインターフェイスを拡張できることです。たとえば、インターフェイスを実装したすべてのクラスの機能を停止することなく、Count メソッドを IEnumerator<T> に追加できます (ただし、その実装にはコレクションのすべてのアイテムを反復することが必要になるでしょう)。この機能では対応するフレームワークのリリース (C# 2.0 およびジェネリック以降では不要になっていること) が必要になるので注意してください。

すべてを拡張: LINQ には、拡張メソッドが導入されることになります。当時、Anders Hejlsberg 氏と食事をしたときに、プロパティなど、他の拡張の型について尋ねたことを憶えています。Hejlsberg 氏は、チームでは LINQ を実装するために必要なもの以外は検討していないと言いました。10 年が経過した現在、そうした前提が再評価され、プロパティだけでなく、イベント、演算子、場合によってはコンストラクターにも、拡張メソッドを追加することが検討されています (コンストラクターでは、一部の興味深いファクトリ パターン実装が実現する可能性があります)。1 つ重要な注意点は、拡張メソッドは静的クラスに実装され、したがって、導入される拡張型には他のインスタンスの状態がないということです。これは特にプロパティに関係があります。そのような状態が必要な場合は、拡張型のインスタンスでインデックス付けされたコレクションにその状態を格納することが必要でしょう。それは、関連付けられた状態を取得するためです。


Mark Michaelis は、IntelliTect の創設者で、同社でチーフ テクニカル アーキテクト兼トレーナーを務めています。彼は約 20 年間 Microsoft MVP に認定され、2007 年から Microsoft Regional Director を務めています。Michaelis は、C#、Microsoft Azure、SharePoint、Visual Studio ALM など、マイクロソフト ソフトウェアの設計レビュー チームにも所属しています。開発者を対象としたカンファレンスで講演を行い、多数の書籍を執筆しました。最近では、『Essential C# 7.0 (6th Edition)』(Addison-Wesley Professional、2018 年) を執筆しています (itl.tc/EssentialCSharp、英語)。連絡先は、Facebook (facebook.com/Mark.Michaelis、英語)、ブログ (IntelliTect.com/Mark、英語)、Twitter (@markmichaelis、英語)、または電子メール mark@IntelliTect.com (英語のみ) です。

この記事のレビューに協力してくれたマイクロソフト技術スタッフの Kevin Bost、Grant Ericson、Tom Faust、Mads Torgersen に心より感謝いたします。


この記事について MSDN マガジン フォーラムで議論する