【実務・中級編】DartのNull安全とジェネリクスの深い関係:ListとListの挙動の違い – Dart コア文法・オブジェクト指向・Null安全解析バイブル

DartのNull安全とジェネリクスの深淵:`List` と `List` の決定的な違い

コードレビューをしていて、もっともゾッとする瞬間の一つが、ジェネリクスとNull安全が交差する境界領域で適当に書かれたコードを見たときだ。

「とりあえず動くから `List` にしておいた」
「コンパイルエラーが出たから `!` で潰した」

そんなプログラミングを続けていれば、プロダクション環境で `TypeError` や `NullPointerException`(Dartの文脈で言えば `Unexpected null value`)の地雷を踏むのは時間の問題だ。

今回は、DartのSound Null Safety(健全なNull安全)がジェネリクス内部でどう振る舞うのか、そして `List` と `List` がコンパイル時および実行時(Dart VM)でどのように扱われるのかを、コアコミッターの視点から徹底的に解説する。

フロントエンド開発やコンポーネント設計、非同期API連携の現場で、一歩先を行く堅牢なコードを書くための知見を持ち帰ってほしい。

—

1. 根本思想:なぜ `List` と `List` は「別物」なのか?

まず大前提として、Dartの型システムにおける `T` と `T?` の関係を正しく認識する必要がある。

  • `T` は 非Null型(Non-nullable type) の上限(Upper Bound)またはその具象型。
  • `T?` は `T` に `null` を許容した Union型(`T | Null`) の糖衣構文。

これをジェネリクスコンテナである `List` に適用した瞬間、型階層の方向性と共変性(Covariance)が絡み合い、直感に反する挙動を生む。

共変性(Covariance)の罠

Dartの `List` は 共変(Covariant) である。つまり、`S` が `T` のサブタイプであれば、`List` は `List` のサブタイプとみなされる。

ここで問題になるのが `null` の存在だ。

  • `String` は `String?` のサブタイプである。
  • したがって、`List` は `List` の…サブタイプではない。

「待て、`String` が `String?` に入るなら、なぜ `List` が `List` のサブタイプにならないのか?」と思った読者は鋭い。

ここがDart VMの型チェックの核心だ。もし `List` が `List` として扱えた場合、以下のような型安全性の崩壊(Type Universeの破壊)が起きる。

List strings = [‘a’, ‘b’];
List nullables = strings; // 仮にこれが許されると…
nullables.add(null); // Listの操作としては合法!

String item = strings[1]; // 崩壊:中身はnullなのに、コンパイラはStringだと信じ込んでいる

コンパイラはこれをコンパイル時に検知しなければならない。そのため、`List` と `List` は、ジェネリック型引数の段階で全く異なるメモリレイアウトと操作契約を持つ別の型として扱われる。

—

2. API連携における設計パターン:境界で型を確定させろ

実務で最もバグが頻発するのが、JSONなどの外部APIと非同期通信を行う境界線(Boundary Layer)だ。JSONのレスポンスは基本的に「何が入っているか分からない(`dynamic` または `Map`)」ため、ここでのハンドリングが生死を分ける。

以下のコードを見てほしい。APIから取得したユーザーリストをパースする、よくある「危険なコード」と「洗練されたコード」の比較だ。

❌ 悪臭を放つアンチパターン

// APIレスポンスのパース:とりあえず全てを許容する怠惰な設計
List parseUsers(List jsonList) {
return jsonList.map((json) {
if (json == null) return null;
return User.fromJson(json);
}).toList();
}

この設計の問題点は、「データが欠損しているのか、パースに失敗したのか、それとも意図的なnullなのか」の区別を放棄し、呼び出し側に責任を押し付けている点にある。結果として、UIコンポーネント側で `user?.name ?? ‘Guest’` のような防御的コードが乱立し、コードベース全体が疲弊する。

✨ 堅牢なプロダクション設計パターン

API境界では、「不正なデータは即座に弾く(Fail-Fast)」 か 「有効なデータだけをフィルタリングして抽出する」 の二択を明確に選ぶべきだ。

以下のコードは、厳格な型安全性を保ちながら、データ損失を防ぐ実用的なコンポーネント設計の例である。

import ‘dart:convert’;

class User {
final String id;
final String name;

const User({required this.id, required this.name});

factory User.fromJson(Map json) {
return User(
id: json[‘id’] as String,
name: json[‘name’] as String,
);
}
}

/// [Repository層]
/// 外部からの曖昧な入力を、完全に制御されたドメイン型へと昇華させる
class UserRepository {

/// パターンA: 厳格モード(1つでも不正なデータがあれば全体を失敗させる)
List parseStrictUsers(List rawData) {
// List を返す。nullは一切許容しない。
return rawData.map((item) {
if (item is! Map) {
throw FormatException(‘Invalid JSON object structure: $item’);
}
return User.fromJson(item);
}).toList();
}

/// パターンB: 堅牢モード(欠損データやパースエラーを安全にフィルタリングする)
List parseResilientUsers(List rawData) {
// 入力は List だが、最終的に保証された List を返す
final List validUsers = [];

for (final item in rawData) {
try {
if (item is Map) {
validUsers.add(User.fromJson(item));
}
} catch (e, stackTrace) {
// ログ基盤へ送信(SentryやFirebase Crashlyticsなど)
// 1件のデータ不備でアプリ全体をクラッシュさせない
print(‘Warning: Failed to parse user item: $e\n$stackTrace’);
}
}

return validUsers; // 呼び出し側は null チェックの呪縛から解放される
}
}

void main() {
// 実行例
final rawJson = [
{‘id’: ‘1’, ‘name’: ‘Alice’},
null, // 混入したノイズ
{‘id’: ‘2’, ‘name’: ‘Bob’},
{‘id’: 3, ‘name’: ‘InvalidType’} // 型崩れ
];

final repository = UserRepository();

// パターンBの実行:安全にフィルタリングされ、純粋な List が手に入る
final users = repository.parseResilientUsers(rawJson);

// 呼び出し側のUIやビジネスロジックには、一切の null チェックが不要になる
for (const user in users) {
print(‘User ID: ${user.id}, Name: ${user.name}’);
}
}

このアプローチの美しさは、「境界の内部でダーティなデータを浄化し、ビジネスロジック層(UI含む)には常に純粋な `List`(非Null型のコレクション)を供給する」という点にある。

—

3. パフォーマンスとメモリレイアウトの深層

Dart VM(JIT / AOT)の内部動作に踏い込もう。
なぜ `List` を使うべきで、無闇に `List` を使うべきではないのか? それは メモリ上の表現とGC(ガベージコレクション)の効率 に直結しているからだ。

ボクシング(Boxing)とアンボクシングのコスト

もし `T` がプリミティブに近い値(`int` や `double`、あるいは特定の構造体)である場合を想像してほしい。

  • `List`: Dart VMは、可能な限り連続したメモリ領域(Int32ListやInt64Listなどの特化型リスト、あるいはそれに準ずる最適化された連続領域)に生の値(Unboxed values)を並べようとする。キャッシュヒット率が極めて高い。
  • `List`: `null` を格納できる必要があるため、値はそのままでは置けない。すべての要素が「ポインタ」または「タグ付きポインタ(Boxed values)」となり、実際の数値実体はヒープ領域の別々の場所に散らばることになる。

結果として何が起きるか?
1. メモリフットプリントの増大: ポインタのオーバーヘッドとヒープ割り当てが増加する。
2. キャッシュミスの頻発: CPUキャッシュ効率が落ち、イテレーション処理(`map`, `where`, `for` ループなど)のパフォーマンスが劣化する。
3. GCプレッシャーの増加: 短命なオブジェクト(ボックス化されたオブジェクト)が大量に生成され、Garbage Collectorの停止時間(Jank)を引き起こす。

フロントエンドのフレームワーク(Flutterなど)において、60fps / 120fpsを維持し続けなければならない状況で、不要な `List` の乱用はパフォーマンス劣化の隠れた温床となる。

—

4. チーフアーキテクトからの実践的プラクティス

最後に、明日からのコードレビューで即座に使える、ジェネリクスとNull安全に関する厳格な指針をまとめる。

1. 「とりあえず `?` をつける」を禁止せよ
迷ったら非Null型 (`T`) から始めよ。`null` を許容する必要があるのは、「値が存在しないこと(Absent)」に明確なビジネス上の意味がある場合だけだ。
2. コレクション自体のNull許容性と、要素のNull許容性を混同するな

  • `List?`: リスト自体が `null` である可能性がある(初期化前など)。
  • `List`: リストは存在するが、中に `null` が含まれている可能性がある。
  • `List?`: その両方(最悪の設計。極力排除すべき)。

実務では、リスト自体が `null` になることを防ぐため、必ず空リスト `[]` で初期化し、型は常に `List` または `List` に固定せよ。
3. 非同期ストリーム (`Stream`) でも同様の思想を貫け
`Stream` を流すのか、イベントの有無を `Stream` で表現するのか(あるいは `Stream>` のように状態をカプセル化するのか)。型を見るだけでデータのライフサイクルが脳内で完全にトレースできる設計を目指せ。

型システムは、開発者の「面倒くささ」を縛るための枷ではない。
「ランタイムエラーという名のバグを、コンパイル時という安全な宇宙で完全に駆逐するための最強の武器」である。

この概念を血肉にし、圧倒的に堅牢で美しいコードをプロダクションに送り出してほしい。

タイトルとURLをコピーしました