C#のnull許容参照型を使う

はじめに

こんにちは。山本れきです。
今日は、近年のC#の言語仕様である「null許容参照型」をどのように導入するかについて考えます。C#を用いた開発チームのいくつかに長らく在籍していましたが、「null許容参照型の導入は難しい」と考えているチームが多かったと感じます。
確かにnull許容参照型の使用は幾分複雑性を増しますし、長期にわたるプロジェクトの場合レガシーコードに手を加えることのリスクとコストを考えれば当然とも言えます。

しかしnullというものは本当に厄介であり、私たちエンジニアとは切っても切れない関係にあります。その問題に対処しうるnull許容参照型について、.NET6 がリリースされたこのタイミングであえて紹介するのは相応しい事と考えました。

nullが表すものとエンジニアの関係

nullは「参照先が無い」ことを表すための簡潔な方法であり、それ自体は有用です。
しかしnullという状態を考慮せずに扱うとプログラム内で破壊的に作用することがあり、それはソフトウェアの不具合となることがあります。
C/C++、Java、C#などのメジャーな言語の多くでは nullという状態が言語仕様上許容されており、それらが破壊的な動作をするかについては、ほとんどの場合実行するまで判定できません。
これは、不具合が混入したコードがリリースされてしまうリスク、あるいはテストのコスト増大として、長い間エンジニアを悩ませてきました。
nullと呼ばれるものが世に登場して実に数十年になりますが、未だにこの問題に完全な解決はないというのがソフトウェア開発の実情です。
高級言語やスクリプトではnull自体がないこともあります。これはつまり「参照先が無い」状態を最初から認めないことで安全性を確保しています。しかし、nullに纏わる問題を抑止する一方で、言語の表現力やパフォーマンスをある程度犠牲にしています。

nullから守るC#の機能

nullの許容/非許容に関して、.NET Core 3.0(C#8.0)から現実的な仕様が追加されています。
この問題は、実行時ではなくコンパイル時の早い段階で誤りを検出できることが望ましいものです。
それに加えてC#は長期間使われている言語でもあるため、nullに関する既存コードの互換性も保つ必要があります。

null許容参照型

null許容参照型の目的はシンプルで「実装者がnullの許容/非許容をコード上に明示したとき、静的解析でエラーや警告を出して、実行時の NullReferenceExceptionを可能な限り抑止する」ことです。
この機能が有効な場合、変数やフィールドなどについてコンパイル時にnull値になる可能性を判定し、実装者が許容していないnullを用いた処理をエラーや警告として扱うようになります。( null 許容参照型 (C# リファレンス) )
またこの拡張のために null 状態のスタティック分析のための属性などが追加されました。
このうちnull許容参照型は構文上は旧来のnull許容値型(int? など)と同様の記述となります。しかしこれは既存のnull許容値型とは一線を画すものです。null許容値型は struct Nullable<T> として定義される別の型ですが、null許容参照型は型としては元の型と同じものです。
あくまで、処理フロー上でnullになる可能性があるものを解析して警告するための仕掛けであることがポイントです。

なぜ.NET 6 で注目するのか

私たちがこのバージョンであえてnull許容参照型に注目すべき理由はいくつかあると考えます。

  1. .NET 6 は ,NET Core/Framework の統合後、最初のLTS(long-term support / 長期サポート)バージョンで、.NET / C#の新しい起点はこのバージョンになります。
  2. Visual Studio 2019 / .NET 5 までのプロジェクトテンプレートではこのオプションはデフォルト無効でしたが、 Visual Studio 2022 / .NET 6 のプロジェクトテンプレートではこの機能がデフォルト有効になっています。
    (仕様上、この機能がオプションであることは変わっていません。新規作成したプロジェクトで有効になっていますが無効化できますし、既存プロジェクトで勝手に有効化されることはありません。)
  3. この機能が有効な場合、既存のコードのほとんどは多数の警告を発し、そのほとんどはnullに対する防御の不足に関する指摘となります。レガシーコードの潜在的な危険性を把握する良い機会となり得ます。

現実的な対応が必要

null許容参照型の導入がC#という言語にとって非常に大きな変化であることは疑いの余地がありません。
前述のように今までコンパイルできていたコードを.NET6の新しいプロジェクトにコピーしてビルドしてみると、非常に広い範囲で警告が出ることに驚くでしょう。
仮にその警告に対応しはじめたとして、実際のところ既存コードのどの部分で null を許容/非許容とするのか、すぐにわかるとも限りません。それに、null非許容化を推進する場合、クラスやインターフェースなどの仕様変更を伴うことになるかもしれません。仕様を変えずに対応するとしても、多くの部分を書き換える必要があります。
つまり、既存のプロジェクトのすべてのコードにすぐ適用することは大変な作業となります。
(注:もちろんnull許容参照型の機能を無効化すれば、既存コードはそのままビルドできます。しかしこの機能を全く使わないのは勿体ない事です。)

いくつか方針を決めて、小さなところから始めてみるのが良いでしょう。以下に、方針の例と注意点を挙げます。

部分的な適用から始める

できる範囲、狭い範囲から始めることは大切です。 null許容/非許容 がはっきりしている部分を #nullable enable#nullable restore で囲ってください。囲われた範囲に対して、null許容参照型を適用することができます。
メソッド1つ、クラス1つ、ライブラリ1つの単位で始めるのも良いでしょう。小さな範囲の安全性を積み上げて、大きな範囲の安全性を確立するのは現実的です。

この null許容/非許容の判定はあくまで静的なフロー解析に基づいていることを意識しましょう。実のところは正しく null かどうかの判定を記述する必要があるだけです。null非許容の変数がnullのまま使用される流れがあると警告やエラーが出ます。結果的にコードの分岐や判定は少々増えるかもしれませんが、nullの処理を誤らないコードの方がはるかに価値は高いのです。

「空」を定義する

String.Empty EventArgs.Empty のような「空」状態を表す不変値を用意するのは、null非許容で記述する範囲を増やす1つの手法となります。しかし注意が必要です。代表的なものをいくつか列挙します。

  1. 継承される可能性がない(sealed可能な)クラスに限る必要があります。
  2. null / Empty が併存しないことを徹底する必要があります。(場合によっては String.IsNullOrEmpty() のような判定処理の実装が必要となります。)
  3. シリアライズ/デシリアライズする場合は、nullが望ましい/nullを避けられない場合があります。

定義が望ましいのは ValueObject パターンなどで 値型をあえてクラス化している場合や、immutable(不変)オブジェクトの空を表現する場合などです。
例えば、PersonDog などのように具象的で変数の多いものに用いるべきではありません。
Person.Emptyよりはnullの方が「人がいない」ことを正しく表現しているでしょう。しかし、例えばPersonの年齢が未設定であることを Empty と表現することは良いと考えます。

読み取り専用プロパティをnull非許容にする

コンストラクターで値を設定してから変更しないものの多くは null非許容にすることができるでしょう。逆に、書き換え可能なプロパティはnull許容にすべきかもしれませんが、その場合でも後述の [AllowNull] などで null 以外のデフォルト値を検討できるかもしれません。

適切な属性をつける

メソッドや引数などに属性をつけることで、静的解析の精度が向上します。また、属性をつけなければ消えない警告があります。使用頻度が高い例をいくつか列挙しますが、すべてを知るには Microsoftの null 状態のスタティック分析のための属性 を参照してください。

注意:自動生成コード内ではnull許容参照型のチェックをしていない

Blazor(Razor構文)のようなHTMLとC#を混成したコードからは、C#のソースコードが自動的に出力されます。こうした場合に自動生成されたコードでは、null許容参照型の静的解析は行われません。
DI(依存性注入)と自動生成コードとpartial class(コードビハインドを別ファイルに記述すること) の相性は悪く、コンパイル時に本来出ないはずの警告が出ることがあります。

この部分の修正は、当記事執筆時点では .NET 7 の作業マイルストーンに加えられています。
Update ASP.NET Core to use C# 8’s nullable reference types

注意:配列内や構造体内の参照型についてnull許容の判定ができない

これはMicrosoftのドキュメントに書かれている注意点です。
null 許容参照型 #既知の落とし穴
現状では、参照型を含む構造体や、参照型の配列についてnull許容を自動的に判定する機能は用意されていません。こうした落とし穴に注意して実装の精度を上げてゆくことになります。

まとめ

.NET Core/Frameworkの統合が済んでから、メジャーバージョンが1つ上がりLTS対象となりました。統合前であったことやLTSでなかったことなど、移行を躊躇う要因が .NET 6 でようやく本格的に減ったと言えます。

.NET 6 は十分な機能とパフォーマンスを備えており、現在のC#は安全かつ精緻に記述することができるようになっていることが分かります。nullを適切に扱っているか判定してくれる静的解析を活用しないのは実に勿体ない事です。
また、.NET 6 は今後長期的にサポートが続く一方で、.NET Framework などは徐々にサポートが薄れていくことになると考えられます。

私自身はひとまず、オープンソースで扱っている既存コードを .NET 6 に対応している最中です。これについては近々、別の記事で成果を発表できれば幸いです。

最近の記事

  • 関連記事
  • おすすめ記事
  • 特集記事

アーカイブ

カテゴリー

PAGE TOP