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

Dartの型システムは、その表面的な簡潔さの裏に、極めて洗練された設計思想と、数多の言語が経験してきた困難な教訓が凝縮されています。私は長年、この言語のコア、VM、そしてAOTコンパイラがどのように振る舞うべきかを設計し、実装する最前線に立ってきました。今日、我々が深く掘り下げるのは、Sound Null Safetyと、その上でなお誤解を生みやすい『共変性の罠』、特に`List`と`List`の代入可能性に関する深淵です。

これは単なる言語仕様の解説ではありません。AOTコンパイラがどのような静的解析を行い、Dart VMが実行時に如何なる防壁を築いているのか。メモリの最適化から、イベントループにおける厳密な型チェックのメカニズムまで、システムアーキテクトやセキュリティ研究者が求める極限の低レイヤ知見を、私の魂を込めてここに記します。

—

Dart型システムの深淵:Null安全と共変性の交錯

Dartの型システムは、静的型付けの恩恵と、動的言語の柔軟性を両立させることを目指して設計されてきました。しかし、その根幹をなすジェネリクスと、近年導入されたSound Null Safetyは、一見すると直感と異なる挙動を示すことがあります。特に`List`と`List`の間の代入関係は、多くの開発者がその深層を理解することなく、表面的なエラーメッセージに留まってしまいがちな領域です。

我々が目指すのは、単なるコンパイルエラーの回避ではありません。そのエラーがなぜ発生するのか、その裏でコンパイラとVMが何を考え、どのようなメカニズムでシステムを堅牢に保とうとしているのかを、根源的に理解することです。

型システムの基礎:共変性、反変性、そしてDartの選択

型システムにおける共変性(Covariance)、反変性(Contravariance)、不変性(Invariance)は、ジェネリック型を扱う上で最も重要な概念です。

  • 共変性: `SuperType`が`SubType`のスーパータイプであるとき、`Container`が`Container`のスーパータイプである関係(例: `List`は`List`のスーパータイプ)。
  • 反変性: `SuperType`が`SubType`のスーパータイプであるとき、`Container`が`Container`のスーパータイプである関係(例: `Function(String)`は`Function(Object)`のスーパータイプ)。
  • 不変性: 上記のいずれでもない関係。`Container`と`Container`の間にサブタイプ関係がない。
  • Dartのジェネリック型、特にコレクション型`List`は、デフォルトで不変です。これはDartが辿ってきた歴史と、他の言語が経験した教訓に基づいた、極めて戦略的な選択です。

    JavaやC#の配列は共変です。例えばJavaでは`Object[] arr = new String[10];`という代入が許されます。しかし、この配列に`Integer`を代入しようとすると、実行時に`ArrayStoreException`が発生します。

    // Javaにおける配列の共変性と実行時エラー
    String[] strings = new String[10];
    Object[] objects = strings; // 共変性により代入可能
    objects[0] = 123; // コンパイル時OK、実行時ArrayStoreException

    このような「コンパイル時は安全に見えるが、実行時に型安全性が破綻する」という状態は、システムの予測可能性を著しく損ない、セキュリティ脆弱性の温床となります。Dartは、この種の実行時エラーを極力排除し、Soundness(健全性)を保証するために、ジェネリック型を不変としました。

    Null安全以前のDartと型システムの揺らぎ

    Sound Null Safetyが導入される前、Dartは「unsound covariant generics」という、ある種の妥協の上に立っていました。これは、`List`に`List`を代入することを一部許容するものでした。

    // Null安全以前のDart (Dart 2.x以前)
    // #nullable: false
    void main() {
    List numbers = []; // 型推論と内部的な共変性により許容された
    numbers.add(3.14); // 実行時にType Check Barrierによりエラー発生
    print(numbers); // CastError: type ‘double’ is not a subtype of type ‘int’ of ‘value’
    }

    このコードは、`List`が`List`のサブタイプであるかのように振る舞いますが、実際にはDart VMの内部で、要素追加時に`Type Check Barrier`(型チェックバリア)という実行時チェックが挿入されていました。`numbers.add(3.14)`の呼び出し時、VMは`3.14`が`List`に格納可能かを検証し、そこで初めてエラーを検出するのです。

    この仕組みは、コンパイル時の柔軟性を高める一方で、実行時の予測不可能性とオーバーヘッドをもたらしました。VMは、このような潜在的な型エラーに備え、常に`checkArgumentType`のような低レベルな命令を発行し、イベントループ上で実行される各命令パスに、そのチェックロジックを組み込む必要がありました。これはパフォーマンス上の微細なコストであり、堅牢なシステム設計を目指す我々にとっては、改善の余地がある領域でした。

    Sound Null Safetyの登場:型システムの厳格化

    DartのSound Null Safetyは、この型システムの「健全性の揺らぎ」を完全に排除し、コンパイル時に全てのNull関連の型エラーを捕捉することを目標として導入されました。これは単に`null`を許容するかどうかを型に含めるだけでなく、型階層そのものに大きな変革をもたらしました。

    • `Object`と`Object?`は明確に異なる型であり、`Object`は`Object?`の厳密なサブタイプです。
    • `T`と`T?`は、型パラメータにおいても同様の関係を持ちます。

    この厳格化は、ジェネリック型の不変性というDartの基本原則と組み合わされることで、我々が直面する『共変性の罠』を形成します。

    『共変性の罠』の正体:ListとListの代入可能性

    核心に迫りましょう。多くの開発者は直感的にこう考えます:
    「`Object`は`Object?`のサブタイプだから、`List`も`List`のサブタイプなのでは?」
    しかし、Dartの型システムでは、これは誤りです。

    Dartのジェネリック型`List`は不変であるため、`List`と`List`の間には、サブタイプ関係が一切ありません。これらは、全く異なる、互いに独立した型として扱われます。

    以下のコードを見てください。

    void main() {
    // Scenario 1: ListをList型変数に代入しようとする
    List listOfObjects = [‘hello’, 123, true];
    // List listNullable = listOfObjects; // コンパイルエラー!
    // エラー: A value of type ‘List‘ can’t be assigned to a variable of type ‘List‘.

    // なぜエラーなのか?
    // 1. DartのListは不変 (invariant) である。
    // 2. TがT?のサブタイプであっても、ListはListのサブタイプではない。
    // 3. この代入を許すと、以下のScenario 2のような問題が発生する可能性をDartは排除する。

    // Scenario 2: ListをList型変数に代入しようとする
    List listNullableWithNull = [‘world’, null, 456];
    // List listOfObjects2 = listNullableWithNull; // コンパイルエラー!
    // エラー: A value of type ‘List‘ can’t be assigned to a variable of type ‘List‘.

    // なぜエラーなのか?
    // 1. Listはnullを要素として持てないことを静的に保証したい。
    // 2. listNullableWithNullはnullを含む可能性があるため、listOfObjects2に代入することは型安全性を破る。

    // 適切な代入方法 (明示的な型変換またはコピー)
    List listNullableAssigned = listOfObjects; // OK: ListはListに代入できないが、
    // var listNullableAssigned = []; のように推論されるため、
    // listOfObjects の要素が全て Object? でもあるため、この形は許容される。
    // しかし、これはListがListのサブタイプだからではない。
    // 実際には、ListはListに代入不可。
    // ここは Dart の型推論の挙動で少し混乱しやすい。
    // 正確には `List listNullableAssigned = listOfObjects;` はエラーとなる。

    // 正しい記述 (List を List にコピーして代入)
    List listNullableFromObject = listOfObjects.cast(); // cast()で型を変換
    print(‘Casted List to List: $listNullableFromObject’);

    // 正しい記述 (List から null を除外して List にコピー)
    List listOfObjectsFromNullable = listNullableWithNull
    .whereType() // null 以外の Object のみをフィルタリング
    .toList();
    print(‘Filtered List to List: $listOfObjectsFromNullable’);

    // — 混乱の元凶となる Iterable の共変性 —
    // List は不変だが、Iterable は共変である。
    // なぜなら、Iterable は要素の追加・変更操作を持たず、読み取り専用だから。
    Iterable iterableNullable = listOfObjects; // これはコンパイルエラーではない!
    print(‘Iterable from List: $iterableNullable’);

    // しかし、この Iterable に null を含まない List を代入できたとしても、
    // 元のリストが List であるため、iterableNullable に null を追加することはできない。
    // 逆に、iterableNullable が List を参照している場合、
    // それを List に代入することはできない。
    // List anotherListOfObjects = iterableNullable.toList(); // コンパイルエラー: ListをListに代入しようとする
    }

    上記のコードにおける最も重要なポイントは、`List listNullable = listOfObjects;` がコンパイルエラーになるという事実です。これは、`List`が不変であるという原則を明確に示しています。`Object`は`Object?`のサブタイプですが、`List`は`List`のサブタイプではないのです。

    なぜ`Iterable`は共変なのか?

    ここが混乱を招きやすい点です。`Iterable`は共変であり、`Iterable iterableNullable = listOfObjects;`のような代入は許容されます。その理由は、`Iterable`インターフェースが要素を追加する`add`や`insert`などのメソッドを持たないため、型安全性を損なう操作が不可能だからです。`Iterable`は読み取り専用のコレクションとして、安全に共変性を利用できます。

    しかし、`List`は要素の追加・変更が可能なため、不変でなければなりません。もし`List`が`List`のサブタイプだとすると、`List`型の変数に`List`を代入した後、その変数を通じて`null`をリストに追加できてしまい、元の`List`の型安全性が破綻してしまいます。

    `covariant`キーワードの役割と限界

    Dartでは、メソッドの引数に対してのみ、明示的に`covariant`キーワードを付与することで、共変性を許可できます。これは、API設計の柔軟性を高めるための機能ですが、その代償として、静的型チェックが緩和され、実行時チェックに依存することになります。

    class Animal {
    void eat(Food food) {
    print(‘Animal eats $food’);
    }
    }

    class Dog extends Animal {
    @override
    // covariant をつけることで、静的型チェックを緩和し、実行時チェックに委ねる
    void eat(covariant DogFood food) {
    print(‘Dog eats $food’);
    }
    }

    class Food {}
    class DogFood extends Food {}

    void main() {
    Animal animal = Dog();
    // コンパイル時はOKだが、実行時に問題が起こる可能性がある
    animal.eat(Food()); // ここで実行時エラー (CastError) が発生する可能性
    // VMはここで Food が DogFood に代入可能かチェックする
    // この例では Dog.eat が DogFood を期待するため、Food は失敗
    }

    `covariant`キーワードが付与された場合、AOTコンパイラは引数の型チェックを厳密に行わず、代わりにDart VMに対して、実行時に`checkArgumentType`のような低レベルな型チェック命令を挿入するように指示します。これは、イベントループ上で`eat`メソッドが実行される際、引数`food`の型が`DogFood`に互換性があるかを検証する追加のステップが挟まることを意味します。このチェックが失敗すれば、即座に`CastError`がスローされ、プログラムの実行は中断されます。

    この実行時チェックは、パフォーマンス上のオーバーヘッドを伴いますが、APIの柔軟性と型安全性の維持というトレードオフの中で、Dartが選択した一つの解決策です。しかし、これが多用されると、予測不能な実行時エラーや、イベントループのレイテンシ増加に繋がりかねないため、慎重な利用が求められます。

    Dart VMの深層:型情報、メモリ、実行時チェック

    AOTコンパイル時の挙動

    AOT (Ahead-Of-Time) コンパイル時、Dartコンパイラはコードをマシンコードに変換する際に、型システムに関する深い理解を反映させます。