Dart Advent Calendar 2022 の 22 日目の記事です。
ジェネリクスを使うときの型のややこしさについて「<T> と <T extends Object?> と <T extends Object>」の記事に書きましたが、それに関係する話です。
何をしたいか
class Foo<T extends Object?> {
Foo(this.value);
T value;
}
Foo<T extends Object?>
としているので T value
の T
は定義としては null があり得ますが、実行時には non-null になることもあります。
Foo の生成時に明示的に型を示して Foo<int>
とすれば T
は non-null です。
明示されない場合はコンストラクタに渡された値によって決まります。
final foo1 = Foo<int?>(123);
final foo2 = Foo<int>(123);
final foo3 = Foo(123);
foo1.value = null; // 可能
foo2.value = null; // 不可
foo3.value = null; // 不可
そのようなクラスで、実行時に T
が nullable な型かどうかを判定したいということです。
なぜ判定が必要か
アプリではジェネリクスを複雑に使うことは多くないですが、パッケージを作るときには汎用性のために多用して複雑になりやすくて、次のような危険や不安が生じます。
- 扱いが難しくて、気をつけながらいじってもミスが起こりやすい
- ミスにすぐに気づけないかもしれない
- デグラデーションでパッケージの利用者に迷惑を掛けたくない
- 不安に思いながら触るのは辛い
これらの対策としてテストは有効で、安心して開発を継続するためにも大事だと考えています。
判定方法
イメージしやすいように先ほどのコードにメソッドを加えます。
※検討時点でのイメージなので実用時の使い方とは異なります。
class Foo<T extends Object?> {
const Foo(this.value);
final T value;
void printNullability() {
final isNullable = ...; // ここで判定してbool値をprintする
print(isNullable);
}
}
使えない方法
プロパティの値が null かどうかは value == null
か value is! Object
で確認できますが、判定したいのはそれではなくて value の 型が nullable かどうか です。
同じ方法では判定できません。
正しい方法
final isNullable = null is T;
Stack Overflow で見つけました。
この回答者はたぶん Dart チームの Lasse さんです。
Try:
bool isNullable<T>() => null is T;
初見では奇妙に思えましたが、T
を具体的な型に置き換えると理解できました。
print(null is int); // false
print(null is int?); // true
null は int
でもそのサブクラスでもないので is int
は false です。
一方、null 許容型である int?
は当然 null を含むので is int?
は true ということですね。
is
演算子を使うとき、value is int
のように判定対象をいつも左辺にするので今回も判定したい T
が左辺だと思い込んでいて、右辺に置く null is T
の形は自分で考えつきませんでした。
実用
Stack Overflow の回答の関数をそのまま使うなら、テストでは次の書き方になります。
expect(isNullable<T>(), isTrue);
しかしクラス内で使うわけではないので T
にアクセスできません。
工夫すれば使えるかもしれませんがやりにくいです。
そこで T
型のプロパティ(の値)を引数で受け取るようにしました。
bool isNullable<T>(T value) => null is T;
...
final foo1 = Foo<int?>(123);
final foo2 = Foo<int>(123);
expect(isNullable(foo1.value), isTrue);
expect(isNullable(foo2.value), isFalse);
Matcher 化は断念
できれば Matcher を作って次のように書きたいと考えていました。
expect(foo.value, isNullable);
作るとしたら次のような感じかと思います。
const Matcher isNullable = _IsNullable();
class _IsNullable<T> extends Matcher {
const _IsNullable();
@override
bool matches(covariant T item, Map matchState) => null is T;
@override
Description describe(Description description) => description.add('nullable');
}
ところがこれを使うと、expect()
で失敗するはずの場合でも常に通ってしまいました。
おそらく、親である Matcher クラスで matches()
の引数が dynamic
になっていることが原因です。
⇒ 該当箇所
残念ですが、Matcher にせずに関数のままでも使えるのでそれで妥協しました。