null 安全なジェネリクス記法
C# 9.0 からようやく null 安全なジェネリクスがまともに記述できるようになったため nullability について試行錯誤してみた。
C# 9.0 以降の nullable コンテクストはそれなりによくできているが、nullablity 解釈の柔軟さ・厳格さに少し揺らぎもあり、複雑な型パズルに出くわして面食らうことがある。
そこで備忘をここに記録する。
結論
- 一般に
notnull
制限付きT?
記法 (後述 Style 3) を用いるのが望ましい - 任意の型
T
を入力として受け入れて、出力にその既定値を返す可能性がある場合 (defaultable) はnotnull
制限なしT?
記法 (後述 Style 2) を用いる - 変数の型推論は柔軟 -- 引数はメソッド適用時により広い型の方へ拡大解釈される
int
変数はmethod<T>(T? parameter) {}
やmethod<T>(T? parameter) where T : notnull {}
に代入可能- 変数許容に関して
int
は null 非許容値型である一方T?
は null 許容型だ、という型相違は警告・エラー報告されない
- ラムダ式の型推論は厳格 -- ラムダ式のシグニチャは厳格に解釈される
- Source-Typed だけではなく Target-Typed の推論もあるため、
notnull
制約を細かく設定していないと拡大解釈されて矛盾が生じることがある - キャストには、Target-Typed 型推論が誤って連鎖・波及することがないよう、型推論ドミノをブレイクして調整するストッパーの役割がある
メソッドと制約の記法
null 許容も非許容も struct も class にも広く適用できるジェネリクスを記述しようと思ったら、理論上は下記の 4 スタイルが考えられる。
/// Style 0 -- オーバーロード振り分け記法 (NG) void Method<T>(T param) where T : struct {} // null 非許容値型 void Method<T>(T? param) where T : struct {} // null 許容値型 void Method<T>(T param) where T : class {} // null 非許容参照型 void Method<T>(T param) where T : class? {} // null 許容参照型 /// Style 1 -- 制約なし T 記法 void Method<T>(T param) {} /// Style 2 -- 制約なし T? 記法 void Method<T>(T? param) {} /// Style 3 -- notnull 制約付き T? 記法 void method<T>(T? param) where T : notnull {}
Style 0 -- オーバーロード振り分け記法
null 許容 / 非許容 × struct / class のうちの 1 パターンに制限してメソッド定義するならば、この記法が正しい。ただ、複数パターンに広く適用できるジェネリクスには適さない。制約はシグニチャに入らずオーバーロードを許さないことから、メソッドの入口で振り分ける方法は不可能。
Style 1 -- 制約なし T 記法
.NET Core 3.1 (C# 8.0) までの記法。 標準ライブラリも C# 8.0 まではこのように記述されていた。*1
/// C# 8.0 public interface IComparer<in T> { public int Compare (T x, T y); }
nullable enable が既定になった C# 9.0 以降のプロジェクトでは基本的に Style 1 で記述しない方がよいだろう。
Style 2 -- 制約なし T? 記法
.NET 5 (C# 9.0) からの記法。標準ライブラリは C# 9.0 からこのように記述されている。*2
/// C# 9.0 -- Style 2 public interface IComparer<in T> { public int Compare (T? x, T? y); }
しかし、この制約のない ?
付与は、T?
が何でも許容することは疑う余地がない一方で、 T
自体が null 許容型を許容するのかしないのか曖昧で解りにくい。
Style 2 と defaultable
そもそも、制約なし型引数 T
に対する T?
は nullable ではなくて defaultable だという話 *3 がある。
任意の型に対して defaultable で返り値を返したい場合は Style 2 を採用する必要がある。これは入力 T
に対して出力として既定値である default(T)
を返したいが、T
が class の場合の default(T)
が null
になってしまい、型の値域が定義域より広がってしまう (= 入力⊂出力となる) からだ。たとえばこのようなケース。
static TResult? Method<T1, T2, TResult>(this (T1, T2) operands, Func<T1, T2, TResult> func, TResult? defaultValue = default(TResult)) => certainCondition switch { true => func(operands.Item1, operands.Item2), _ => defaultValue, };
このケースで注意すべきは、引数初期値を与えている TResult? defaultValue = default(TResult)
の部分だ。ここをTResult? defaultValue = default
と記述してしまうと TResult
が class
であれ struct
であれ常に null
となってしまい、本意とするところと違ってしまう。このメソッドは "出力" 側に TResult?
という表記を使用しているが、"入力" (操作したい対象) はあくまで TResult
である。既定値を取得すると仮想マシンでは class に対して TResult
と TResult?
の区別がなくどちらも null
を返してきてしまうからコード記述でそれを弁別しつつ受け入れるために TResult?
という表記をする、というのが defaultable の意味であろう。
Style 3 -- notnull 制約付き T? 記法
そこで考えられるのは notnull
制約を付した Style 3 だ。
LINQ 拡張ライブラリの自作において比較演算評価式をストラテジーパターン的に外挿することが頻出するため、null を取りうる 2 値の比較用に拡張版 .CompareTo()
メソッドを記述したが、この際に (プログラムの動作は変わらないものの) スタイルによってプログラマがコード記述に対して意識・懸念することに違いが現れた。Style 3 で記述するとこうなる。
/// Style3 -- nullable 対応版 CompareTo() メソッド static int CompareToEachOther<T>(T? x, T? y) where T : notnull, IComparable<T> => x?.CompareTo(y) ?? - y?.CompareTo(x) ?? 0;
Style3 で記述するとあることに気付く。x
に int?
値ではなく int
値を代入するとどうなるか。
答えは、引数 T? x
に代入されるときに自然にキャストされ、メソッド内部では int?
として扱われるため、 ?.CompareTo()
も ??
も正しくワークする。
Style2 で記述するとどうだろうか。
/// Style2 -- nullable 対応版 CompareTo() メソッド static int CompareToEachOther<T>(T? x, T? y) where T : IComparable<T> => x?.CompareTo(y) ?? - y?.CompareTo(x) ?? 0;
こちらもほとんど変わり映えしないし、同じように動作はする。が、notnull
制約されていない T
自身に null 許容型が入ったらどうなるかコード記述上の曖昧さが少し気になってしまう。int
は IComaparable<int>
準拠であるが、int?
は IComaparable<int?>
準拠ではない。
この null 許容の曖昧さに対する懸念は、単純な例では問題にならないが、後述する Target-Typed 型推論が絡むと大きな混乱を招くリスクとなる。Style 3 にすれば安全だというわけではないが、このリスクを回避するため Style 2 は非推奨だ。
Style 1 で記述するとこうなる。
/// Style1 -- nullable 対応版 CompareTo() メソッド static int CompareToEachOther<T>(T x, T y) where T : IComparable<T> => x?.CompareTo(y) ?? - y?.CompareTo(x) ?? 0;
x
が nullable か否か表明しておらず x
に struct
が入るかもしれないにも関わらず、不思議なことにこれも正しくワークする。おそらく x?.CompareTo()
という表記から x
が null 許容型であることを型推論して Style2 と同じ中間コードを生成しているのだろう。しかし、そうであれば T
は int?
となるはずであり、制約を IComaparable<int>
と解釈していることと矛盾する。
こちらはさすがに値型・参照型の違いを弁別せずにやり過ごすのは心情的に難しく、コード表記上 struct
と class
とを分離したくなる。とはいえ、Style 0 のように制約違いオーバーロードによって外形的に分岐させることはできないため、リフレクションを用いたランタイムの型ガードになる。
static int CompareToEachOther<T>(T x, T y) where T : IComparable<T> => typeof(T) switch { var type when type.IsClass => x?.CompareTo(y) ?? - y?.CompareTo(x) ?? 0, // Class _ => x .CompareTo(y), // ValueType, Enum };
元のコードで動作する (というかジェネリクスで生成される中間コードはおそらく同等コードになっているはずな) のに、プログラマがここまで記述するのはさすがに開発効率が悪い。コード表記の整合性を採るべきか否か、元のコードで本当に正しく動作するか、にいちいち悩みたくない。したがって Style 1 は非推奨となる。
ラムダ式の型推論と Target-Typed 推論
さて、ここまでの議論であれば、スタイルはいずれでも大した問題にはならずこだわる必要はあまりない。が、スタイルを曖昧にしていると混乱して収拾がつかないことがある。
たとえば、このように IComparable<T>
型を入力とするラムダ式を IComparer<T>
実装クラスへラップするコード記述。
/// IComparable<T> 評価ラムダ式を IComparer<T> 準拠に変換するラッパークラス public class WrapperComparer<T> : IComparer<T> where T : IComparable<T> { private Func<T?, T?, int> Comparer { get; set; } public WrapperComparer(Func<T?, T?, int>? comparer = null) => this.Comparer = comparer ?? ((T? x, T? y) => (x, y).CompareToEachOther()); // for Style 2 or 3 #if __Style_1__ public WrapperComparer(Func<T , T , int>? comparer = null) => this.Comparer = (Func<T?, T?, int>)(comparer ?? ((T x, T y) => (x, y).CompareToEachOther())); // for Style 1 : Func<T, T, int> → Func<T?, T?, int> casting required #endif public int Compare(T? x, T? y) => this.Comparer(x, y); }
前述の LINQ に比較演算評価式を外挿する場面において、比較演算評価式である IComparable<T>
型入力のラムダ式を拡張ライブラリ内では IComparer<T>
準拠クラスへラップする設計としたい。なぜなら、LINQ 標準ライブラリは評価戦略として IComparable<T>
型入力のラムダ式ではなく IComparer<T>
準拠クラスを要求するのが既定となっているからだ。IComparer<T>
は応用が利いて悪くないのだが、いちいちクラス定義しなければならず大仰だ。
上記のコードは完成形だが、ここに至るまでに紆余曲折があり、大いに悩まされたことが 1 つある。
当該ラッパークラスは IComparer<T>
準拠であるため、int Compare(T? x, T? y)
を実装する必要がある。すなわち Func<T?, T?, int>
型だ。一方これに充て込むために外挿するラムダ式は IComparable<T>
準拠のFunc<T, T, int>
型である。
ラッパークラスのコンストラクタに Style 1 の T where T : IComparable<T>
型入力のラムダ式を与えると、IComparable<T>
制約に基づく入力 Func<T, T, int>
を IComparer<T>
準拠クラスが保持する評価式プロパティ Func<T?, T?, int>
にマップする際に nullability の違いが浮き彫りになる。
T
型のインスタンスを T?
型の引数やプロパティが受け入れるのとは異なり、Func<T, T, int>
型のインスタンスを、間口がより広いはずの Func<T?, T?, int>
型の引数やプロパティは受け入れない。ラムダ式に対しては、その入出力の型を含めて型の検証が厳格に行われる。
それどころか、Func<T?, T?, int>
型プロパティへ充て込む Func<T, T, int>
型のインスタンスの T
は T?
型だろうとTarget-Typed 推論をし、IComparable<T>
制約へ逆適用してしまう。その結果 int?
は IComparable<int?>
準拠ではないというエラーを発生させる。
Func<T?, T?, int>
におけるT?
のT
がint
だとするとT?
はint?
であり、このint?
が Target-typed 推論でFunc<T, T, int>
のT
にマップされるとIComparable<T>
はIComparable<int?>
だということになってしまう。
このギャップを防ぐには Func<T, T, int>
を Func<T?, T?, int>
へと Func
の入力型違いをキャストする *4 しかない。そしてこれは、アドホックな回避策などではなく、他の制約と不整合を起こさせないために積極的に打つべき抜本策だ。波及していく型推論ドミノが誤っていたら野火の延焼になる。火元から離れて火事になると原因がわからず大きな混乱となる。キャストには延焼を止めるためのドミノ・ストッパーという重要な役割がある。
*1:https://learn.microsoft.com/ja-jp/dotnet/api/system.collections.generic.icomparer-1.compare?view=netcore-3.1
*2:https://learn.microsoft.com/ja-jp/dotnet/api/system.collections.generic.icomparer-1.compare?view=net-5.0
*3:https://ufcpp.net/study/csharp/resource/nullablereferencetype/?p=3#unconstrained-generics
*4:間口のより広い方へのキャストだから問題ない。