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
for (var animal in animals) {
animal.speak();
}
}
void main() {
List
// ここ、一見通しそうだが…?
processAnimals(dogs);
}
「おや、`List
今回は、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
しかし、Dartは実用性と開発生産性を重視し、ジェネリッククラスの型パラメータをデフォルトで共変(Covariant)として扱っている。これにより、次のようなコードで煩雑なキャストを書かずに済むというメリットがある。
List
しかし、この「利便性」は、オブジェクト指向の根幹であるリスコフの置換原則(LSP)をいとも簡単に破壊する諸刃の剣なのだ。
—
2. 実行時エラーを爆発させる「共変性の罠」
次のコードを見てほしい。コンパイルは完璧に通る。しかし、実行時にクラッシュする。
void addAnimal(List
// Animalのリストなのだから、Catを追加しても文法上は正しいはず…?
animals.add(Cat());
}
void main() {
List
// List
addAnimal(dogs);
// 💥 実行時エラー! List
// Dart VMは Dog 型を期待したメモリ領域で Cat を読み込もうとしてクラッシュする
dogs[0].speak();
}
Dart VMの裏側で何が起きているか?
Dart AOTコンパイラやVMのメモリ管理において、`List
これが、フロントエンドの状態管理(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
// ❌ 危険な設計:呼び出し側で勝手に要素を追加・改変されるリスクがある
void renderList(List
for (var a in animals) { a.speak(); }
}
// ⭕ 堅牢な設計:Iterableを要求することで、共変によるリスト汚染を防ぐ
void renderImmutableList(Iterable
for (var a in animals) { a.speak(); }
// animals.add(Cat()); // コンパイルエラー! Iterableには add が存在しない
}
パターンB:書き込みを伴う場合は「不変(Invariant)」を強制するインターフェース設計
もしあなたがデザインシステムや汎用的なデータコレクターを設計しているなら、型パラメータの共変性に頼らず、明確に型を固定するか、ジェネリクスを用いたカスタムラッパーで不変性を担保する必要がある。
// 安全なイミュータブル・コンテナの例
class ReadOnlyRepository
final List
ReadOnlyRepository(List
T getAt(int index) => _items[index];
int get length => _items.length;
}
void main() {
var dogRepo = ReadOnlyRepository
// ReadOnlyRepository
// 型の安全性が完全に保たれる(不変性の担保)。
// ReadOnlyRepository
}
—
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 を不変に近く扱い、不正な型混入をコンパイルタイムでブロックする
class EntityStore
final List
// 読み取り専用ビューを外部に公開
UnmodifiableListView
// 安全な追加処理(T以外の型は一切受け付けない)
void ingest(List
// — 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 ` を使え。 - 状態を持ち、かつミュータブルなコレクションやコールバックを設計するときは、型の変性がどこで発生しているかを常に意識しろ。
型システムを味方につける者だけが、保守性という名の強固な城壁を持つプロダクトを書き上げることができる。明日のコードレビューでは、後輩の書いたリストの型を見逃さないでほしい。