【実務・中級編】Null安全における『共変性(Covariance)』の罠:ListとListの代入可能性 – Dart コア文法・オブジェクト指向・Null安全解析バイブル

皆さん、Dartの健全なNull安全型システムがもたらす恩恵を日々享受していることと思います。しかし、その強固な保証の裏側には、時に我々の直感とは異なる挙動が潜んでいます。特に、ジェネリクスと組み合わせた際の『共変性』は、かつてのDart開発者にとって頭痛の種であり、健全なNull安全が導入された今でも、その深淵を理解していなければ、予期せぬ実行時エラーや設計の脆さにつながる「罠」となり得ます。

今回は、Null安全下における`List`と`List`の代入可能性という具体的なテーマを通して、Dartの型システムがどのように動作し、いかに堅牢なコードを構築すべきかを、コアコミッターとしての視点から深く掘り下げていきましょう。

Dartの型システムを掌握せよ:Null安全下における共変性の『罠』と堅牢な`List`型設計

導入:Null安全の恩恵と、その深奥に潜む型システムの真実

DartのSound Null Safetyは、コンパイル時にNull関連のエラーを排除し、開発者がより安全で予測可能なコードを書くことを可能にしました。これは、我々が長年待ち望んだ、言語設計における画期的な進化です。しかし、この強固な型システムの恩恵を最大限に引き出すためには、その根底にある原理、特にジェネリクスと共変性の関係を深く理解する必要があります。

今回のテーマは、一見すると単純に見える`List`と`List`の代入可能性に関する疑問です。「`Object`は`Object?`のサブタイプなのだから、`List`は`List`に代入できるのではないか?」――そう考えた方もいるかもしれません。しかし、健全なNull安全下においては、この直感は通用しません。なぜなら、Dartの`List`は、`T`に対して共変ではないからです。

この「罠」を理解し、回避することは、大規模なアプリケーション開発において、特にAPI連携やコンポーネント間でのデータ受け渡しにおいて、バグのない堅牢なシステムを構築する上で不可欠です。

過去の遺産:非健全な共変性が生んだ実行時エラーの悪夢

健全なNull安全が導入される以前のDartでは、`List`を`List`に代入できるという、いわゆる「非健全な共変性 (unsound covariance)」が許容されていました。

例えば、次のようなコードはコンパイルエラーになりませんでした。

// 非Null安全時代のDartの挙動(現在ではコンパイルエラー)
void legacyUnsoundCovariance() {
List intList = [1, 2, 3];
List numList = intList; // コンパイルOK (非健全な共変性)

// ここで問題が発生
numList.add(3.14); // numListは実際にはintListを参照しているため、これはOK
numList.add(‘hello’); // ★実行時エラー: TypeError! Stringはintに代入できない

print(intList); // [1, 2, 3, 3.14, ‘hello’] となりうる(実際は追加時にエラー)
}

この挙動は、開発者にとっては「便利」に見えるかもしれませんが、型システムがコンパイル時に約束する安全性を実行時に裏切るものです。`List`を通して`String`を追加しようとすると、基底の`intList`には`String`を格納できないため、プログラムは`TypeError`でクラッシュします。これは、「健全な型システム」とは言えません。健全な型システムとは、コンパイル時に型チェックが成功すれば、実行時に型エラーが発生しないことを保証するものです。

Null安全の衝撃:`List`はもはや共変ではない(不変性へ)

健全なNull安全が導入された際、Dartチームはこの非健全な共変性による実行時エラーの問題に真剣に取り組みました。その結果、変更可能なコレクション型(特に`List`)は、その型引数`T`に対して共変ではない、という設計が採用されました。より正確には、`List`は`T`に対して不変(invariant)であると考えるべきです。

これはどういうことか?
`List`を`List`に代入することはできなくなり、その逆もできません。`List`と`List`は、`T`と`U`が完全に一致しない限り、互いに代入不可能な異なる型として扱われます。

では、今回のテーマである`List`と`List`の関係を見てみましょう。

  • `Object`は、Nullを許容しない全てのDartオブジェクトの基底型です。
  • `Object?`は、`Object`と`null`の両方を含む型です。つまり、`Object`は`Object?`のサブタイプです。

もし`List`が共変であれば、`List`は`List`のサブタイプとして扱えるはずです。しかし、Dartの健全なNull安全では、`List`と`List`は全く異なる型として扱われます。

void soundNullSafetyAndCovariance() {
List objectList = [‘hello’, 123, true];
List nullableObjectList = [‘world’, null, 456];

// 1. List を List に代入しようとする
// これはコンパイルエラーにはならないが、実際には推奨されない
// しかし、もし共変性が適用されるなら本来は OK
// 現実には、Dartの型推論が List を List に自動的にアップキャストすることは稀
// 明示的に代入しようとすると、多くの場合エラーになるか、警告が出る
// 例: List list1 = objectList; // これはOK (昇格)
// しかし、list1.add(null); はOKだが、objectListはnullを含まない保証があるため安全

// 2. List を List に代入しようとする (これが「罠」の本質)
// List anotherObjectList = nullableObjectList; // ★コンパイルエラー!
// A value of type ‘List‘ can’t be assigned to a variable of type ‘List‘.

// なぜエラーになるのか?
// nullableObjectList は null を含む可能性がある (例: [null, ‘item’])
// もしこれが objectList に代入できてしまうと、
// objectList は null を許容しないはずなのに、null を含むリストを参照してしまうことになる。
// これは型安全性を破綻させるため、Dartはこれを厳しく禁止します。
}

この厳格なルールこそが、DartのNull安全が「健全」である所以です。コンパイル時に型エラーを検出することで、実行時の`TypeError`を未然に防ぎます。

具体的な「罠」のシナリオ:APIレスポンスのパース

この共変性の制約が、実務で最も顕著に現れるのは、非同期APIからJSONデータを受け取り、それをDartオブジェクトのリストに変換する際です。

APIからのJSONデータは、通常`Map`のリスト、または`List`として受け取られます。これを厳密な型を持つ`List`に変換しようとする際に、安易なキャストは危険な罠となります。

class Product {
final String id;
final String name;
final double price;

Product({required this.id, required this.name, required this.price});

factory Product.fromJson(Map json) {
// ここで厳密な型チェックとNullチェックが必須
final id = json[‘id’];
final name = json[‘name’];
final price = json[‘price’];

if (id is! String) {
throw FormatException(‘Invalid product ID: $id’);
}
if (name is! String) {
throw FormatException(‘Invalid product name: $name’);
}
if (price is! num) { // numを許容し、後でdoubleに変換
throw FormatException(‘Invalid product price: $price’);
}

return Product(id: id, name: name, price: price.toDouble());
}

@override
String toString() => ‘Product(id: $id, name: $name, price: $price)’;
}

Future> fetchProductsUnsafely() async {
// 模擬APIレスポンス (現実では List や List> となる)
final List rawApiResponse = [
{‘id’: ‘p001’, ‘name’: ‘Laptop’, ‘price’: 1200.0},
{‘id’: ‘p002’, ‘name’: ‘Mouse’, ‘price’: 25}, // priceがint
null, // APIがnull要素を返す可能性
{‘id’: ‘p003’, ‘name’: null, ‘price’: 50.0}, // nameがnull
{‘id’: ‘p004’, ‘name’: ‘Keyboard’, ‘price’: ‘invalid’}, // priceが不正な型
];

// 誤った変換アプローチ:一括キャストや不十分なチェック
try {
// List は List> ではないため、
// 以下のような安易なキャストはコンパイルエラーか、実行時エラーを引き起こす
// List> productMaps = rawApiResponse as List>; // ★実行時エラーの可能性高
// または、コンパイルエラー: A value of type ‘List‘ can’t be assigned to a variable of type ‘List>’.

// nullが含まれる可能性のあるリストに対して、mapで直接変換しようとすると…
// rawApiResponse.map((e) => Product.fromJson(e as Map)).toList();
// これは、e が null の場合や Map ではない場合に実行時エラー (Null check operator used on a null value / TypeError)

print(‘— 危険な変換アプローチ —‘);
final List products = [];
for (final item in rawApiResponse) {
if (item is Map) {
// ここでProduct.fromJson()が例外を投げる可能性がある
products.add(Product.fromJson(item));
} else {
print(‘Skipping non-map or null item: $item’);
}
}
return products; // このリストは、不正なデータがスキップされた後のもの
} catch (e) {
print(‘Failed to parse products: $e’);
return [];
}
}

void main() async {
print(‘=== 不安全なプロダクトデータ取得 ===’);
List products = await fetchProductsUnsafely();
print(‘パースされたプロダクト数: ${products.length}’);
products.forEach(print);
// 想定される出力:
// Skipping null item: null
// Skipping non-map or null item: {id: p003, name: null, price: 50.0} (Product.fromJsonでFormatException)
// Skipping non-map or null item: {id: p004, name: Keyboard, price: invalid} (Product.fromJsonでFormatException)
// …など、エラーハンドリングによってスキップされる項目がわかる
}

この例では、`List`(実際のAPIレスポンスはもっと複雑な`List`になりがちですが、本質は同じです)を`List`に直接代入しようとすると、コンパイルエラーになるか、実行時に型エラーでクラッシュします。`Object?`は`Product`のスーパータイプではないからです。

堅牢な設計パターン:罠を回避し、安全な型変換を実現する

では、このような「共変性の罠」を回避し、Null安全なDartアプリケーションで堅牢なデータ変換を実現するにはどうすれば良いでしょうか?

1. 明示的な型変換と厳密な検証

最も基本的なアプローチは、`map`メソッドと`toList`を組み合わせて新しいリストを生成し、その過程で各要素のNullチェックと型チェックを厳密に行うことです。

// List から List への安全な変換関数
Future> fetchProductsSafely() async {
final List rawApiResponse = [
{‘id’: ‘p001’, ‘name’: ‘Laptop’, ‘price’: 1200.0},
{‘id’: ‘p002’, ‘name’: ‘Mouse’, ‘price’: 25},
null,
{‘id’: ‘p003’, ‘name’: null, ‘price’: 50.0},
{‘id’: ‘p004’, ‘name’: ‘Keyboard’, ‘price’: ‘invalid’},
];

final List products = [];
for (final item in rawApiResponse) {
// null要素をスキップ
if (item == null) {
print(‘Skipping null item in rawApiResponse.’);
continue;
}

// Map ではない要素をスキップ
if (item is! Map) {
print(‘Skipping non-map item: $item’);
continue;
}

// Product.fromJson() で例外が発生する可能性があるので try-catch
try {
products.add(Product.fromJson(item));
} on FormatException catch (e) {
print(‘Failed to parse product from data: $item. Error: $e’);
} catch (e) {
print(‘Unexpected error parsing product from data: $item. Error: $e’);
}
}
return products;
}

このアプローチは非常に堅牢ですが、`for`ループと複数の`if`文、`try-catch`ブロックが必要となり、やや冗長になる可能性があります。

2. `Iterable`と`whereType()`の活用

Dartの`Iterable`は、`List`とは異なり、その型引数に関して共変です(戻り値の型として扱う場合)。また、`whereType()`メソッドは、特定の型の要素のみを抽出し、それ以外の要素(`null`を含む)を自動的に除外してくれる非常に便利な機能です。

// List から List へ、null要素を安全に除外して変換する
List filterNonNullElements(List source) {
// whereType() は、Iterable から T 型の要素のみを抽出し、
// それ以外の型(nullを含む)を無視して Iterable を返す。
// その後 toList() で List に変換する。
return source.whereType().toList();
}

// 例:
void demonstrateWhereType() {
List nullableInts = [1, 2, null, 4, null, 6];
List nonNullableInts = filterNonNullElements(nullableInts);
print(‘Original: $nullableInts’); // Original: [1, 2, null, 4, null, 6]
print(‘Filtered: $nonNullableInts’); // Filtered: [1, 2, 4, 6]

List mixedList = [1, ‘hello’, null, 3.14, true];
List nonNullMixedList = filterNonNullElements(mixedList);
print(‘Original mixed: $mixedList’); // Original mixed: [1, hello, null, 3.14, true]
print(‘Filtered mixed: $nonNullMixedList’); // Filtered mixed: [1, hello, 3.14, true]
}

これは、Null要素を単純に除外したい場合に非常に強力で簡潔なパターンです。ただし、`Product`のようなカスタムオブジェクトへの変換には、`Product.fromJson`のようなファクトリコンストラクタと組み合わせる必要があります。

3. `map().where().toList()`による柔軟な変換とフィルタリング

カスタムオブジェクトへの安全な変換には、`map`と`where`を組み合わせるのが一般的かつ強力なパターンです。

Future> fetchProductsFlexible() async {
final List rawApiResponse = [
{‘id’: ‘p001’, ‘name’: ‘Laptop’, ‘price’: 1200.0},
{‘id’: ‘p002’, ‘name’: ‘Mouse’, ‘price’: 25},
null,
{‘id’: ‘p003’, ‘name’: null, ‘price’: 50.0},
{‘id’: ‘p004’, ‘name’: ‘Keyboard’, ‘price’: ‘invalid’},
];

// 1. まず、各要素をProductオブジェクトに変換しようと試みる。
// 変換できない場合は null を返す (またはエラーをログに記録し null を返す)
final Iterable potentiallyNullProducts = rawApiResponse.map((item) {
if (item is Map) {
try {
return Product.fromJson(item);
} on FormatException catch (e) {
print(‘Warning: Failed to parse product from data: $item. Error: $e’);
return null;
}
}
print(‘Warning: Skipping non-map or null item in rawApiResponse: $item’);
return null;
});

// 2. 次に、変換に成功した (nullではない) Product オブジェクトのみを抽出する
// whereType() は Iterable から null を除外して Iterable を返す
final List products = potentiallyNullProducts.whereType().toList();

return products;
}

void main() async {
print(‘=== 柔軟なプロダクトデータ取得 ===’);
List products = await fetchProductsFlexible();
print(‘パースされたプロダクト数: ${products.length}’);
products.forEach(print);
// 想定される出力:
// Warning: Failed to parse product from data: {id: p003, name: null, price: 50.0}. Error: FormatException: Invalid product name: null
// Warning: Failed to parse product from data: {id: p004, name: Keyboard, price: invalid}. Error: FormatException: Invalid product price: invalid
// Warning: Skipping non-map or null item in rawApiResponse: null
// パースされたプロダクト数: 2
// Product(id: p001, name: Laptop, price: 1200.0)
// Product(id: p002, name: Mouse, price: 25.0)
}

このパターンは、エラーが発生した要素をスキップしつつ、有効なデータのみを抽出する非常に保守性の高い方法です。

パフォーマンス上の注意点

上記の`map().toList()`や`whereType().toList()`といったパターンは、新しいリストを生成します。大規模なデータセットに対して頻繁にこれらの操作を行う場合、メモリとCPUのオーバーヘッドが発生する可能性があります。

  • 大規模データセットの場合: `Iterable`チェーンを長くして、`toList()`の呼び出しを最後の1回に留めることで、中間リストの生成を避けることができます。必要な場合にのみ`toList()`を呼び出し、それまでは遅延評価される`Iterable`として扱うことで、パフォーマンスを最適化できます。
  • `as`キャストと`is`チェック: `as`キャストは、コンパイル時には型安全性を保証しませんが、実行時に型が一致しない場合に`TypeError`を発生させます。`is`チェックは、実行時に安全に型を検証し、その後のコードブロックで型を絞り込む(Promote)ことができます。基本的には`is`チェックを優先し、それが難しい場合にのみ、リスクを理解した上で`as`キャストを使用すべきです。

まとめ:Null安全と共変性の深い理解がもたらす設計の力

Dartの健全なNull安全型システムは、我々にこれまでにない堅牢性を提供しますが、その特性を深く理解することが不可欠です。