【テクニカル・上級編】Dartのsealed classと網羅性チェック:switch式で「未知のサブクラス」をどう扱うか – Dart コア文法・オブジェクト指向・Null安全解析バイブル

網羅性の呪縛と拡張性:Dart 3 `sealed` クラスにおける安全な「未知」のハンドリング

Dart 3で導入されたパターンマッチングと `sealed` 修飾子は、私たちのコードベースから「実行時まで発覚しない `TypeError`」を劇的に駆逐した。CFA(Class Hierarchy Analysis)と静的解析器が連携し、コンパイル時にすべてのサブクラスのハンドリングを強制する網羅性チェック(Exhaustiveness Checking)は、型安全性の極致と言える。

しかし、シニアエンジニアであれば誰もがこのパラダイムの裏にある「トレードオフ」に直面するはずだ。

「ライブラリやモジュールの将来的な拡張性(Open-Closed Principle)を担保しつつ、コンパイラの網羅性チェックの恩恵をどう維持するか?」

`switch` 式ですべてのサブクラスを列挙した瞬間、将来新しいサブクラスを追加した際に、コンパイラはコンパイルエラー(あるいは警告)を吐く。これは意図通りの挙動だが、巨大なエコシステムやプラグインアーキテクチャにおいて、未知のサブクラスが流れ込んできた瞬間にランタイムクラッシュを引き起こすリスクと表裏一体だ。

本稿では、Dart VMの型システムとコンパイラの挙動を解剖し、網羅性を維持したまま「未知のサブクラス」を安全に包摂する設計パターンを、ランタイムの最適化レイヤから紐解いていく。

—

1. コンパイラは `sealed` をどう評価しているのか

まず、Dartの `sealed` クラスがコンパイル時にどのように扱われるかを正確に把握する必要がある。

`sealed` 修飾子は、そのクラスが「同一ライブラリ内(library-private)」でのみサブクラス化を許可することをコンパイラに強制する。これにより、Dartのフロントエンドコンパイラ(CFE: Common Front-End)は、そのクラスの型階層(Type Hierarchy)がコンパイル時に完全に閉じている(Closed)と断定できる。

[sealed Command]
├── Subclass A (同一ライブラリ)
├── Subclass B (同一ライブラリ)
└── Subclass C (同一ライブラリ)

CFEはこの閉じられた木構造をもとに、`switch` 式の網羅性を検証する。もし `A`, `B`, `C` のみが定義されている状態で `C` のハンドリングが漏れていれば、コンパイラは即座にエラーをスローする。

ここで重要なのは、「同一ライブラリ内」というスコープの制限だ。もしライブラリの利用者が外部から勝手にサブクラスを作れない(作ろうとしてもコンパイルエラーになる)のであれば、なぜ「未知のサブクラス」を考慮する必要があるのか?

答えは 「バイナリの非同期デプロイ、プラグイン機構、あるいはJSON等の外部シリアライゼーション境界」 である。コンパイル時には存在しなかった、あるいは型システムが追跡しきれない動的なデータ構造がランタイムに流入する境界において、厳格な網羅性チェックは時に開発者の足かせとなる。

—

2. 伝統的なアンチパターンと、その静的解析上の破綻

「未知のケース」を処理しようとして、多くの開発者が陥るアンチパターンがこれだ。

// 良くない例:default句の多用による網羅性チェックの放棄
sealed class NetworkEvent {}
class Connect extends NetworkEvent {}
class Disconnect extends NetworkEvent {}

void handleEvent(NetworkEvent event) {
switch (event) {
is Connect => // …
is Disconnect => // …
default:
// ここに逃げると、新しいサブクラスを追加した時にコンパイラが教えてくれない
log(‘Unknown event’);
}
}

`default` や `_`(ワイルドカード)を置いてしまうと、Dart 3の網羅性チェッカーは「網羅されている」とみなしてしまう。結果として、将来 `Reconnecting` というサブクラスを追加した際、開発者がこの `switch` 文を修正し忘れても、コンパイラは黙殺し、すべて `default` へ流れてしまう。これは静的型の恩恵を自ら放棄する行為に他ならない。

—

3. 解決策:Unknown/Fallbackパターンの設計と実装

網羅性チェックの安全性を1ミリも妥協せず、かつ「未知のサブクラス(あるいは将来追加されるかもしれない拡張)」を安全にハンドリングするためには、「未知を表すサブクラスを型階層の契約(Contract)としてあらかじめ組み込んでおく」必要がある。

以下の実装を見てほしい。

// — library_core.dart —
sealed class NetworkResponse {
const NetworkResponse();
}

class Success extends NetworkResponse {
final T data;
const Success(this.data);
}

class Failure extends NetworkResponse {
final Object error;
const Failure(this.error);
}

/// 【極限の知見】型階層にあらかじめ「未知・フォールバック」の窓口を用意する
/// これにより、コンパイル時の網羅性を維持しつつ、ランタイムの拡張性を担保する
class UnknownResponse extends NetworkResponse {
final String rawType;
final Map? rawPayload;
const UnknownResponse({required this.rawType, this.rawPayload});
}

この設計において、消費者側のコードは以下のように記述される。

// — consumer.dart —
void processResponse(NetworkResponse response) {
// Dart 3のswitch式による完全な網羅性チェック
// すべてのサブクラスを明示することが強制される
final result = switch (response) {
Success(:final data) => ‘Data received: $data’,
Failure(:final error) => ‘Error occurred: $error’,
UnknownResponse(:final rawType) => ‘Fallback for unknown type: $rawType’,
};

print(result);
}

このパターンの優位性

1. コンパイラの網羅性チェックが完全機能する
`Success`, `Failure`, `UnknownResponse` の3つが網羅されているため、コンパイラは警告やエラーを出さない。もし将来 `Loading` という新しい状態を追加し、それを `UnknownResponse` に含め忘れたり個別に処理したかったりする場合、コンパイラが未処理のケースを正確に指摘してくれる。
2. ランタイムの安全なデグレ防御
外部APIの仕様変更や、動的なプラグインロードによって予期せぬレスポンス型構造が流入した場合でも、デシリアライゼーション層で `UnknownResponse` にフォールバックさせることで、アプリ全体が `type ‘X’ is not a subtype of type ‘Y’ in type cast` のような致命的なランタイムクラッシュを起こすのを防ぐことができる。

—

4. パフォーマンスとメモリレイアウトの考察(Dart VMの内部視点)

シニアエンジニアとして、この設計がランタイムに与える影響についても言及しておかねばならない。

Dart VMは、オブジェクトの型情報をクラスID(`ClassID`)としてインラインキャッシュ(IC)やオブジェクトヘッダに保持している。`switch` 式(内部的にはタイプテストやジャンプテーブル、あるいは最適化された型ディスパッチ)を実行する際、閉じられた `sealed` クラスの階層であれば、VMはディスパッチのコストを極限まで削減できる。

`UnknownResponse` を設けることで、インスタンス化のオーバーヘッド(わずかなヒープ割り当て)が発生するが、これは「未定義の型による例外クラッシュ」という甚大なコストと比較すれば、無視できるほどの代償である。

さらに、`final` フィールドを持つイミュータブルなクラスとして設計することで、Dart VMのジェネ世代別GC(Generational GC)における新規空間(New Generation)でのライフサイクルを最小限に抑え、ポインタ参照の局所性を高めることができる。

—

結び:静的型安全と動的堅牢性の融合

Dart 3の `sealed` クラスとパターンマッチングは、単なるシンタックスシュガーではない。それは、コンパイラと開発者の間での「厳格な契約」の締結である。

「すべてを網羅せよ」というコンパイラの厳格な要求と、「未知の環境変化に耐えなければならない」という現実世界のシステム要件。この一見矛盾する2つの要件を調停するのが、型階層に意図的な「未知の安全弁(`UnknownResponse` 等)」を組み込むアーキテクチャパターンである。

型安全性を信仰するあまり、現実の変更耐性を殺してはならない。逆に、動的な柔軟性を求めて型安全性を骨抜きにしてもならない。その境界線を見極め、美しくコンパイルを通すことこそが、真にDartを掌握したアーキテクトの仕事である。

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