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! を初期値として代入しておけばよいです。
| private string _valueNantokaKantoka = default!; |
lateinit とかもそうですが適当に使うと治安が最高に悪くなるので気を付けたほうがいいやつです。
ジェネリクス
class 制約のないジェネリクスの場合 ? をつけて null 許容型であると宣言できません。そのため null を扱うかもしれない、というケースを宣言するには属性を使用することになります。
逆を言えば where T: class や where T: unmanaged のような制約をつけておけば T? と null 許容型を素直に書くことができるので書けるときは書いてしまうのが手です。
AllowNull 属性と DisallowNull 属性 (事前条件)
例えば次のようなメソッドの引数で参照型のときは null 渡したいケースがあります。
| public static void SetValue<T>(T value) | |
| { | |
| // ここでは value は null ではない (と認識される) | |
| } | |
| ... | |
| // CS8625: Cannot convert null literal to non-nullable reference type. | |
| SetValue<object>(null); |
そのままでは非許容なので null が来てもよいということをコンパイラーに伝える必要があるので、 AllowNull 属性を使用します。
| public static void SetValue<T>([AllowNull]T value) | |
| { | |
| // ここでは T が参照型の場合は null 許容型として扱われる (ので null forgiving operator が必要になる) | |
| } | |
| ... | |
| // 何も言われない | |
| SetValue<object>(null); |
逆に null が入ってきてほしくないというのを明示することもできます。例えば先ほどのコードをもう一度。
| public static void SetValue<T>(T value) { ... } | |
| SetValue<string?>(null); // ジェネリック型が nullable なら null が許される |
このような場合は DisallowNull 属性を使用すると null を許さないということを明示できます。
| public static void SetValue<T>([DisallowNull]T value) { ... } | |
| // CS8625: Cannot convert null literal to non-nullable reference type. | |
| SetValue<string?>(null); |
DisallowNull 属性は .NET Core のコードであれば IEqualityComparer<T>.GetHashCode などに見られます。
MaybeNull 属性と NotNull 属性 (事後条件)
次は値型ならデフォルト値、参照型なら null が返るようなメソッドを扱うケースです。GetValueOrDefault や DefaultIfEmpty のようなものでよくありますね。
| public static T GetValueOrDefault<T>() | |
| { | |
| return default!; | |
| } |
このようなケースをカバーするには MaybeNull 属性を使うと戻り値が null になる可能性があることを表せます。なお、戻り値に対しての属性なので return: です。
| [return: MaybeNull] | |
| public static T GetValueOrDefault<T>() | |
| { | |
| return default!; | |
| } | |
| ... | |
| // CS8602: Dereference of a possibly null reference. | |
| GetValueOrDefault<object>().GetType(); | |
| // int は null になりようがないので何も言われない | |
| GetValueOrDefault<int>().GetType(); |
逆に絶対 null を返さないということがわかっている場合には NotNull 属性で宣言できます。例えば次のようなケースです。
| [return: NotNull] | |
| public static T CreateInstance<T>() | |
| { | |
| // インスタンス作るから絶対 null 返さない | |
| return ObjectFactory<T>(); | |
| } | |
| ... | |
| // nullable のジェネリクスだけど絶対何か返ってくるので null にはならない | |
| CreateInstance<string?>().GetType(); |
プロパティ
AllowNull 属性と DisallowNull 属性 (事前条件)
プロパティの setter で null を許可したかったり拒否したかったりというケースがあるかもしれません。例えば setter で null はセットできるけれども null は返ってこないような API です(というのはあまり想像しづらいですが…)。
| [AllowNull] | |
| public static string Value { get; set; } | |
| Value = null; |
ちなみにコンパイラーが中途半端に賢いので Value = null; の後に Value を使用しようとすると怒られます(が、実際は null じゃないはずなのでなんか変な気がします)。
逆は例によって DisallowNull です。null が返るかもしれないけれど null をセットすることは許さないようなケースです。Microsoft のドキュメントのサンプルではこんな感じです。
| [DisallowNull] | |
| public string? ReviewComment | |
| { | |
| get => _comment; | |
| set => _comment = value ?? throw new ArgumentNullException(nameof(value), "Cannot set to null"); | |
| } | |
| string? _comment; |
初期化時は null が返るけど、ユーザーがセットすることは許さないみたいな感じですかね。
その他のケース
NotNullWhen 属性 と MaybeNullWhen 属性 (事後条件)
TryGetValue や TryParse のような、成功時には out 引数で値を返すがそれ以外では null を返し、成否は戻り値にするというケースがあります。例えば次のようなコードがあるとします。
| class MyDictionary | |
| { | |
| public bool TryGetValue(string key, out MyValue value) | |
| { | |
| if (true /* 何か条件 */) | |
| { | |
| value = new MyValue(); | |
| return true; | |
| } | |
| value = default; | |
| return false; | |
| } | |
| } |
この MyDictionary を使う場合は次のようなコードになるわけですが、その際 null の扱いも上手く処理されてほしいわけです。
| var dict = new MyDictionary(); | |
| if (dict.TryGetValue("Hello", out var value)) | |
| { | |
| // ここでは value != null になってほしい (非nullable) | |
| value.ToString(); | |
| } | |
| else | |
| { | |
| // ここでは value == null になってほしい (nullable) | |
| value.ToString(); // "CS8602: Dereference of a possibly null reference." が出てほしい | |
| } |
そこで戻り値によって nullable かどうかを伝える NotNullWhen という属性が用意されています。この属性を付けると「nullable な型が場合によってはその後 non-nullable 確定できるかも」という情報をコンパイラーに伝えられます。
| class MyDictionary | |
| { | |
| public bool TryGetValue(string key, [NotNullWhen(true)] out MyValue? value) | |
| { | |
| if (true /* 何か条件 */) | |
| { | |
| value = new MyValue(); | |
| return true; | |
| } | |
| value = default; | |
| return false; | |
| } | |
| } | |
| ... | |
| var dict = new MyDictionary(); | |
| if (dict.TryGetValue("Hello", out var value)) | |
| { | |
| // ここでは value != null になる | |
| value.ToString(); | |
| } | |
| else | |
| { | |
| // "CS8602: Dereference of a possibly null reference." が出る (nullable 扱い) | |
| value.ToString(); | |
| } | |
| // ここでは nullable (var value == MyValue? value) | |
| value!.ToString(); |
他の使い道としては String.IsNullOrEmpty みたいなもので使えます(使われています)。 IsNullOrEmpty も後のフロー解析に影響を与えてほしい側面を持っているのでピッタリですね。
| public static bool IsNullOrEmpty([NotNullWhen(true)]string? value) | |
| => value == null || value == string.Empty; | |
| ... | |
| string? v = GetValue(); | |
| if (!IsNullOrEmpty(v)) | |
| { | |
| v.Trim(); // ここでは v は null ではないのでことが確定的に明らか | |
| } | |
| else | |
| { | |
| v.Trim(); // ここでは v は null なので CS8602 で怒られる | |
| } |
逆の意味を持つのが MaybeNullWhen 属性で non-nullable につけた MaybeNullWhen(false) は NotNullWhen(true) と同じになります。
| public bool TryGetValue(string key, [MaybeNullWhen(false)] out MyValue value) | |
| { | |
| // `false` を返す時は MyValue は null | |
| } |
これらの属性の使い分けですが
- 引数に渡す/出てくる値 (
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 を使う必要があります。
| class MyDictionary<T> | |
| { | |
| public bool TryGetValue(string key, [MaybeNullWhen(false)] out T value) | |
| { | |
| // class 制約などがない場合は T? とはできないので NotNullWhen は使えない | |
| } | |
| } | |
| ... | |
| var dict = new MyDictionary<string>(); | |
| if (dict.TryGetValue("Hello", out var value)) | |
| { | |
| // ここでは value != null になる | |
| value.ToString(); | |
| } | |
| else | |
| { | |
| // "CS8602: Dereference of a possibly null reference." が出る (nullable 扱い) | |
| value.ToString(); | |
| } | |
| // ここでは non-nullable だけど null 入ってる可能性がある | |
| value.ToString(); |
NotNullIfNotNull 属性 (事後条件)
入力が null ではなかったら絶対に null ではないということがわかっていて、コンパイラーに伝えたいというケースに使えるのが NotNullIfNotNull 属性です。何を言っているのかみたいな名前…。
具体的なケースであれば例えばエスケープ処理のようなもので、文字列を受け取るけれども null が来たら null を返すが、それ以外は絶対値が返るようなメソッドです(その仕様がいいかどうかはさておき)。
| [return: NotNullIfNotNull("value")] | |
| public static string? Escape(string? value) | |
| { | |
| if (value is null) return null; | |
| return value.Replace("&", "&").Replace("<", "<"); | |
| } | |
| var escaped = Escape("<div />"); | |
| escaped.GetType(); | |
| var escaped2 = Escape(null); | |
| // CS8602: Dereference of a possibly null reference. | |
| escaped2.GetType(); |
まとめ
属性を書き始めると書き味が悪くなるのと IntelliSense などでシグネチャヒントを見ただけではわからないという問題が起きやすくなるので、新規に書き起こすものは極力属性を書かなくて済むような API にするほうが望ましいように感じました。
例えば NotNullIfNotNull 属性のようなものなどはそもそも引数で null を受けない、TryGetValue などは戻り値と out を使わないで null 許容型をそのまま返すといった形にできます。
属性は既存のコードを壊さず null 許容型との相互運用のためにアノテーションをつけていくものというぐらいの認識がいいかもしれません。まあジェネリクスが厳しいのですが…。