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 許容型との相互運用のためにアノテーションをつけていくものというぐらいの認識がいいかもしれません。まあジェネリクスが厳しいのですが…。