【実務・中級編】Dartの型システムにおける共変性(Covariance)と反変性(Contravariance)の理解 – Dart コア文法・オブジェクト指向・Null安全解析バイブル

Dartの型システムにおける「共変性」の罠:なぜそのリスコフ置換原則違反はコンパイルをすり抜けるのか

コードレビューをしていて、次のようなコードに遭遇したことはないだろうか。

abstract class Animal {
void speak();
}

class Dog extends Animal {
@override
void speak() => print(‘Woof!’);
}

class Cat extends Animal {
@override
void speak() => print(‘Meow!’);
}

void processAnimals(List animals) {
for (var animal in animals) {
animal.speak();
}
}

void main() {
List dogs = [Dog(), Dog()];
// ここ、一見通しそうだが…?
processAnimals(dogs);
}

「おや、`List`を`List`の引数に渡しているのに、なぜDartはエラーを出さないんだ?」と思ったなら、あなたはDartの型システムの深淵に一歩足を踏み入れている。Javaの配列と同様の直感的な代入を許すこの挙動こそが、共変性(Covariance)の影であり、フロントエンドのコンポーネント設計や大規模なAPI連携レイヤーにおいて、頭を抱えるような実行時エラー(`TypeError`)を引き起こす温床となる。

今回は、Dartコアコミッターの視点から、型システムの変性(Variance)のメカニズムを丸裸にし、実務で絶対にバグを踏まないための堅牢な設計パターンを授けよう。

—

1. 変性(Variance)の基本概念とDartの設計思想

型システムにおける変性とは、「ある型 `A` と `B` の間にサブタイプ関係(`A <: B`)があるとき、それらをラップした複雑な型(`List` や `Function(A)` など)の間で、どのようなサブタイプ関係が成立するか」を定義するルールだ。

専門用語を整理しておこう。

  • 共変性(Covariance): 元の型の方向を維持する。(`Dog <: Animal` ならば `Box <: Box`)
  • 反変性(Contravariance): 元の型の方向が逆転する。(`Dog <: Animal` ならば `Consumer <: Consumer`)
  • 不変性(Invariance): どちらの方向の代入も許さない。(`Box` と `Box` に親子関係はない)

なぜDartはコレクションを「共変」にしたのか?

C#やScalaのような厳格な言語では、ジェネリックなコンテナはデフォルトで「不変(Invariant)」である。つまり、`List`を`List`の引数に渡すことはコンパイルエラーになる。

しかし、Dartは実用性と開発生産性を重視し、ジェネリッククラスの型パラメータをデフォルトで共変(Covariant)として扱っている。これにより、次のようなコードで煩雑なキャストを書かずに済むというメリットがある。

List animals = [Dog()]; // Dartでは許容される

しかし、この「利便性」は、オブジェクト指向の根幹であるリスコフの置換原則(LSP)をいとも簡単に破壊する諸刃の剣なのだ。

—

2. 実行時エラーを爆発させる「共変性の罠」

次のコードを見てほしい。コンパイルは完璧に通る。しかし、実行時にクラッシュする。

void addAnimal(List animals) {
// Animalのリストなのだから、Catを追加しても文法上は正しいはず…?
animals.add(Cat());
}

void main() {
List dogs = [Dog()];

// Listを List として渡す(共変性による暗黙のキャスト)
addAnimal(dogs);

// 💥 実行時エラー! List の中に Cat が混入しているため、
// Dart VMは Dog 型を期待したメモリ領域で Cat を読み込もうとしてクラッシュする
dogs[0].speak();
}

Dart VMの裏側で何が起きているか?

Dart AOTコンパイラやVMのメモリ管理において、`List` は「`Dog` 型のポインタが連続して並んだバッファ」として最適化されてallocされることがある。ここに `List` という緩い型制約の窓口から `Cat` インスタンスがねじ込まれると、メモリ上の型安全性(Type Soundness)が完全に崩壊する。

これが、フロントエンドの状態管理(State Management)や、多様なAPIレスポンスをマッピングするデータレイヤーで発生すると、原因の特定が極めて困難なバグとなる。

—

3. 反変性(Contravariance)の理解:関数型における罠

逆に、関数(Function)の引数の世界では、型は「反変(Contravariant)」でなければならない。

// Animalを受け取って処理できるハンドラー
void handleAnimal(Animal animal) {
animal.speak();
}

// Dog専用の処理を期待する高階関数
void processCallback(void Function(Dog) callback) {
callback(Dog());
}

void main() {
// handleAnimal は「Animal全般」を扱える。
// つまり、Dogが来ても問題なく処理できる(DogはAnimalの一種だから)。
// したがって、void Function(Animal) は void Function(Dog) の代わりになり得る!
processCallback(handleAnimal);
}

関数型言語やモダンなフロントエンドのイベント駆動設計(RxDartやBlocなど)では、このコールバックのシグネチャ設計を誤ると、「渡すべきデータが受け取れない」「コンパイルは通るのにデータがロストする」という現象に悩まされることになる。

—

4. 【実践】バグをゼロにする堅牢な設計パターン

では、実務の現場でこの型システムの動的性質をコントロールし、保守性の高いコードを書くにはどうすればよいか。

パターンA:読み取り専用(Read-only)には `List` ではなく `Iterable` を使え

コンポーネントのプロパティや関数の引数が「データを読み取るだけ(イミュータブル)」であるならば、ミュータブルなメソッド(`add`, `remove`, `[]=`)を持つ `List` を公開してはならない。`Iterable` や `UnmodifiableListView` を強制すべきだ。

// ❌ 危険な設計:呼び出し側で勝手に要素を追加・改変されるリスクがある
void renderList(List animals) {
for (var a in animals) { a.speak(); }
}

// ⭕ 堅牢な設計:Iterableを要求することで、共変によるリスト汚染を防ぐ
void renderImmutableList(Iterable animals) {
for (var a in animals) { a.speak(); }
// animals.add(Cat()); // コンパイルエラー! Iterableには add が存在しない
}

パターンB:書き込みを伴う場合は「不変(Invariant)」を強制するインターフェース設計

もしあなたがデザインシステムや汎用的なデータコレクターを設計しているなら、型パラメータの共変性に頼らず、明確に型を固定するか、ジェネリクスを用いたカスタムラッパーで不変性を担保する必要がある。

// 安全なイミュータブル・コンテナの例
class ReadOnlyRepository {
final List _items;

ReadOnlyRepository(List items) : _items = List.unmodifiable(items);

T getAt(int index) => _items[index];
int get length => _items.length;
}

void main() {
var dogRepo = ReadOnlyRepository([Dog()]);

// ReadOnlyRepository を ReadOnlyRepository に直接代入することはできないため、
// 型の安全性が完全に保たれる(不変性の担保)。
// ReadOnlyRepository animalRepo = dogRepo; // コンパイルエラー
}

—

5. プロダクションコード例:安全なAPIレスポンス・マッパーの構築

最後に、Web APIから受け取ったJSONをドメインモデルに変換する、実務さながらの堅牢なレイヤー設計を見てほしい。ここでは、共変性による型汚染を完全に排除しつつ、柔軟なポリモーフィズムを実現している。

import ‘dart:collection’;

// — Domain Models —
abstract class ApiEntity {
String get id;
}

class UserEntity extends ApiEntity {
@override
final String id;
final String name;
UserEntity(this.id, this.name);
}

class AdminEntity extends UserEntity {
final String permissionsLevel;
AdminEntity(String id, String name, this.permissionsLevel) : super(id, name);
}

// — Mapper / Parser —
typedef EntityParser = T Function(Map json);

/// 堅牢なエンティティ・コレクター
/// 型パラメータ T を不変に近く扱い、不正な型混入をコンパイルタイムでブロックする
class EntityStore {
final List _entities = [];

// 読み取り専用ビューを外部に公開
UnmodifiableListView get entities => UnmodifiableListView(_entities);

// 安全な追加処理(T以外の型は一切受け付けない)
void ingest(List> rawData, EntityParser parser) {
for (var json in rawData) {
try {
final entity = parser(json);
_entities.add(entity);
} catch (e) {
// ログ出力などのエラーハンドリング
print(‘Failed to parse entity: $e’);
}
}
}
}

// — Execution —
void main() {
// 管理者データのモック
final rawAdminData = [
{‘id’: ‘A01’, ‘name’: ‘Alice’, ‘level’: ‘super’},
{‘id’: ‘A02’, ‘name’: ‘Bob’, ‘level’: ‘moderator’},
];

// AdminEntity専用のストアを初期化
final adminStore = EntityStore();

// データのインジェスト
adminStore.ingest(rawAdminData, (json) {
return AdminEntity(
json[‘id’] as String,
json[‘name’] as String,
json[‘level’] as String,
);
});

// 安全に利用する
for (var admin in adminStore.entities) {
print(‘Admin: ${admin.name}, Level: ${admin.permissionsLevel}’);
}

// 下記のような誤った代入や操作は、すべてDartの静的解析とコンパイラが未然に防ぐ。
// adminStore._entities.add(UserEntity(‘U01’, ‘Charlie’)); // コンパイルエラー (型不一致)
}

—

チーフアーキテクトからの最終提言

Dartの共変性は、言語の歴史的経緯と実用性のバランスの上に成り立っている。しかし、その甘えは大規模なフロントエンドや複雑な非同期パイプラインを構築する際、必ず牙を剥く。

  • データを流し込む(Read)だけなら `Iterable` や `UnmodifiableListView` を使え。
  • 状態を持ち、かつミュータブルなコレクションやコールバックを設計するときは、型の変性がどこで発生しているかを常に意識しろ。

型システムを味方につける者だけが、保守性という名の強固な城壁を持つプロダクトを書き上げることができる。明日のコードレビューでは、後輩の書いたリストの型を見逃さないでほしい。

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