C# で null 許容型あれこれ
Created at:
.NET Core 3.0 のリリースとともに C# 8.0 がきて、null 許容型 (nullable reference types) を使えるようになったので使ってみたメモです。
未初期化だけど後で初期化するパターン
コンストラクターではまだ初期化しないのだけど、いずれフレームワークから初期化するので API を利用する側はほぼ null を扱わない、けど内部の初期状態は null なのを許してほしいというケースはまあよくあります。Kotlin の lateinit や TypeScript の definite assignment assertion みたいなやつですね。
C# ではズバリそれっぽいものはないので default! を初期値として代入しておけばよいです。
lateinit とかもそうですが適当に使うと治安が最高に悪くなるので気を付けたほうがいいやつです。
ジェネリクス
class 制約のないジェネリクスの場合 ? をつけて null 許容型であると宣言できません。そのため null を扱うかもしれない、というケースを宣言するには属性を使用することになります。
逆を言えば where T: class や where T: unmanaged のような制約をつけておけば T? と null 許容型を素直に書くことができるので書けるときは書いてしまうのが手です。
AllowNull 属性と DisallowNull 属性 (事前条件)
例えば次のようなメソッドの引数で参照型のときは null 渡したいケースがあります。
そのままでは非許容なので null が来てもよいということをコンパイラーに伝える必要があるので、 AllowNull 属性を使用します。
逆に null が入ってきてほしくないというのを明示することもできます。例えば先ほどのコードをもう一度。
このような場合は DisallowNull 属性を使用すると null を許さないということを明示できます。
DisallowNull 属性は .NET Core のコードであれば IEqualityComparer<T>.GetHashCode などに見られます。
MaybeNull 属性と NotNull 属性 (事後条件)
次は値型ならデフォルト値、参照型なら null が返るようなメソッドを扱うケースです。GetValueOrDefault や DefaultIfEmpty のようなものでよくありますね。
このようなケースをカバーするには MaybeNull 属性を使うと戻り値が null になる可能性があることを表せます。なお、戻り値に対しての属性なので return: です。
逆に絶対 null を返さないということがわかっている場合には NotNull 属性で宣言できます。例えば次のようなケースです。
プロパティ
AllowNull 属性と DisallowNull 属性 (事前条件)
プロパティの setter で null を許可したかったり拒否したかったりというケースがあるかもしれません。例えば setter で null はセットできるけれども null は返ってこないような API です(というのはあまり想像しづらいですが…)。
ちなみにコンパイラーが中途半端に賢いので Value = null; の後に Value を使用しようとすると怒られます(が、実際は null じゃないはずなのでなんか変な気がします)。
逆は例によって DisallowNull です。null が返るかもしれないけれど null をセットすることは許さないようなケースです。Microsoft のドキュメントのサンプルではこんな感じです。
初期化時は null が返るけど、ユーザーがセットすることは許さないみたいな感じですかね。
その他のケース
NotNullWhen 属性 と MaybeNullWhen 属性 (事後条件)
TryGetValue や TryParse のような、成功時には out 引数で値を返すがそれ以外では null を返し、成否は戻り値にするというケースがあります。例えば次のようなコードがあるとします。
この MyDictionary を使う場合は次のようなコードになるわけですが、その際 null の扱いも上手く処理されてほしいわけです。
そこで戻り値によって nullable かどうかを伝える NotNullWhen という属性が用意されています。この属性を付けると「nullable な型が場合によってはその後 non-nullable 確定できるかも」という情報をコンパイラーに伝えられます。
他の使い道としては String.IsNullOrEmpty みたいなもので使えます(使われています)。 IsNullOrEmpty も後のフロー解析に影響を与えてほしい側面を持っているのでピッタリですね。
逆の意味を持つのが MaybeNullWhen 属性で non-nullable につけた MaybeNullWhen(false) は NotNullWhen(true) と同じになります。
これらの属性の使い分けですが
- 引数に渡す/出てくる値 (
out,ref) が前提として null 非許容型 (T) かもしれないならMaybeNullWhen属性 - 引数に渡す/出てくる値 (
out,ref) が前提として null 許容型 (T?) かもしれないならNotNullWhen属性
例えば TryGetValue のようなもので MaybeNullWhen 属性を使った場合、前提として non-nullable になると out var で宣言したローカル変数が non-nullable になり、if のようなフロー解析から外れたときに non-nullable (ただし null が入っている可能性がある) という状況になります。
同様に IsNullOrEmpty のようなもので MaybeNullWhen 属性を使った場合、引数は前提として non-nullable なので String? な値を引数に渡すたびに ! を付けてあげないといけないことになります。
…と、ここまで読むと MaybeNullWhen 属性を使わなくてもほとんどのケースでは NotNullWhen 属性で事足りるのではと思うのですが、制約なしジェネリクスの型パラメータに ? を付けて null 許容型とすることはできないのでその場合には MaybeNullWhen を使う必要があります。
NotNullIfNotNull 属性 (事後条件)
入力が null ではなかったら絶対に null ではないということがわかっていて、コンパイラーに伝えたいというケースに使えるのが NotNullIfNotNull 属性です。何を言っているのかみたいな名前…。
具体的なケースであれば例えばエスケープ処理のようなもので、文字列を受け取るけれども null が来たら null を返すが、それ以外は絶対値が返るようなメソッドです(その仕様がいいかどうかはさておき)。
まとめ
属性を書き始めると書き味が悪くなるのと IntelliSense などでシグネチャヒントを見ただけではわからないという問題が起きやすくなるので、新規に書き起こすものは極力属性を書かなくて済むような API にするほうが望ましいように感じました。
例えば NotNullIfNotNull 属性のようなものなどはそもそも引数で null を受けない、TryGetValue などは戻り値と out を使わないで null 許容型をそのまま返すといった形にできます。
属性は既存のコードを壊さず null 許容型との相互運用のためにアノテーションをつけていくものというぐらいの認識がいいかもしれません。まあジェネリクスが厳しいのですが…。