【テクニカル・上級編】Dartの「switch式」で網羅性チェックを強制し、将来のバグを防ぐ – Dart コア文法・オブジェクト指向・Null安全解析バイブル

Dart 3 パターンマッチングの真髄:コンパイラによる網羅性強制と静的安全性の極限

Dart 3の導入により、この言語は単なる「安全なオブジェクト指向言語」から、代数的データ型(ADT)とパターンマッチングを完備した表現力の高いモダン言語へと昇華した。

とりわけ、`switch` 式(StatementではなくExpression)における網羅性チェック(Exhaustiveness Checking)は、大規模アーキテクチャの保守性を担保する上で極めて強力な防壁となる。

本稿では、一般的な入門記事が語るような表面的な使い方ではなく、DartのCFE(Common Front End)とAOTコンパイラが裏側でどのように型を検証し、実行時コードへ落とし込んでいるのか、その低レイヤの挙動に踏み込んで解説する。

—

1. なぜ「switch文」ではなく「switch式」なのか?

従来の `switch` 文 は、あくまで命令型プログラミングの制御構文であり、`break` の書き忘れ(フォールスルー)という人間工学的な欠陥を抱えていた。また、C言語系ランタイムの伝統を引き継ぐジャンピングテーブル(Jump Table)の最適化に囚われていたため、型の網羅性を静的に強制する能力が欠けていた。

一方、Dart 3の `switch` 式 は、関数型言語におけるパターンマッチングと同等のセマンティクスを持つ。これは式であるため、必ず何らかの値を評価して返さなければならない。この「値を返す義務」こそが、コンパイラに網羅性の証明を強いる原動力となる。

網羅性チェックの破綻とコンパイル時防御

例えば、次のようなドメインモデルを考える。

enum ConnectionState { connecting, connected, disconnected }

// switch文(網羅性が強制されない)
String legacyHandle(ConnectionState state) {
switch (state) {
case ConnectionState.connecting:
return ‘Connecting…’;
case ConnectionState.connected:
return ‘Connected!’;
// disconnected が漏れていても、Dart 2モードでは静的エラーにならない(nullを返すか落ちる)
}
}

このコードにおいて、将来的に `ConnectionState.reconnecting` が追加された瞬間、`legacyHandle` は暗黙的に `null` を返すか、ランタイムエラー(`TypeError`)を引き起こす爆弾と化す。

これを `switch` 式 で記述すると、状況は一変する。

String robustHandle(ConnectionState state) => switch (state) {
ConnectionState.connecting => ‘Connecting…’,
ConnectionState.connected => ‘Connected!’,
// コンパイルエラー: The switch is exhaustive, but does not explicitly handle ‘disconnected’.
};

C言語の歴史的遺物であるフォールスルーの恐怖から解放されるだけでなく、「ドメインモデルの変更が、コンパイルエラーという形でコードベース全体へ即座に伝播する」という、堅牢なアーキテクチャの防壁が構築される。

—

2. コンパイラは網羅性をどう証明しているのか?(CFEの内部動作)

Dartのコンパイラフロントエンド(CFE)は、ソースコードをAST(抽象構文木)に変換する際、`switch` 式のターゲット型(この場合は `ConnectionState`)の取りうるすべての値(Space)を計算する。

密封型(Sealed Class)や `enum` の場合、CFEはそれらの「有限な値の集合」を完全に把握している。各 `case` パターンが消費していく空間(Space Exhaustion)を数学的にマッピングし、評価終了時に未達の空間(Uncovered Space)が存在する場合、IR(Intermediate Representation)の生成を即座に中断し、コンパイルエラーを投げる。

この処理は完全に静的(Static Analysis時)に行われるため、ランタイムのオーバーヘッドは一切発生しない。

シールドクラス(Sealed Class)による複雑な階層の網羅

`enum` だけでなく、Dart 3の `sealed` 修飾子と組み合わせることで、直和型(Sum Types)に対する厳密な網羅性チェックが可能になる。

sealed class NetworkResult {}

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

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

class Loading extends NetworkResult {}

// すべてのサブタイプが網羅されているかをコンパイラが検証する
String processResult(NetworkResult result) => switch (result) {
Success(data: var d) => ‘Success: $d’,
Failure(error: var e) => ‘Error: $e’,
Loading() => ‘Loading…’,
// もし NetworkResult に Timeoutクラスが追加されたら、ここで即座にコンパイルエラーになる
};

このパターンの美しさは、構造的サブタイピングとパターンマッチングが完全に統合されている点にある。CFEは `NetworkResult` のサブクラスが同一ライブラリ内(あるいはシールされた境界内)にしか存在しないことを知っているため、取りうるすべての派生型を網羅しているかを完璧に追跡できる。

—

3. 実践:厳密な型安全性を維持するための設計パターン

大規模なFlutterアプリケーションやDartバックエンドにおいて、この網羅性チェックを武器として最大限に活かすための設計アプローチを提示する。

アンチパターン:`default` や `_` の安易な使用

網羅性チェックの恩恵を自らドブに捨てる最悪の行為が、`switch` の末尾に `_ =>` や `default:` を置くことである。

// 避けるべき実装
int calculateScore(UserRole role) => switch (role) {
UserRole.admin => 100,
UserRole.moderator => 50,
_ => 10, // 新しいロール(例: UserRole.superAdmin)が追加されても、ここに落ちてしまい検知できない!
};

ワイルドカードパターン(`_`)は、「取りうる値が無限に存在する(例:`int` や `String` など)」場合を除き、有限の `enum` や `sealed class` に対して使用してはならない。

`_` を排除することで、新しいドメインロジックを追加した際、コンパイラが「お前、この新しい状態の処理を書き忘れているぞ」とコードベースの修正箇所を正確に指し示してくれるようになる。

網羅的switch式を用いた堅牢なState Reducerの例

以下は、FlutterのBLoCやRiverpod、あるいはプレーンな状態管理において、状態の遷移を完全に制御する実用的なコードだ。

sealed class AppState {}
class Unauthenticated extends AppState {}
class Authenticated extends AppState { final String userId; Authenticated(this.userId); }
class TokenExpired extends AppState {}

sealed class AppEvent {}
class LoginRequested extends AppEvent { final String user; LoginRequested(this.user); }
class LogoutRequested extends AppEvent {}
class SessionTimeout extends AppEvent {}

// 状態遷移関数:全てのイベントと状態の組み合わせをコンパイル時に強制
AppState appReducer(AppState state, AppEvent event) => switch ((state, event)) {
// 未認証状態でログイン要求があった場合
(Unauthenticated(), LoginRequested(user: var u)) => Authenticated(u),

// 認証済み状態でログアウト要求があった場合
(Authenticated(), LogoutRequested()) => Unauthenticated(),

// 認証済み状態でセッションが切れた場合
(Authenticated(), SessionTimeout()) => TokenExpired(),

// トークン期限切れ状態で再ログインがあった場合
(TokenExpired(), LoginRequested(user: var u)) => Authenticated(u),

// — 防御的ガード(不正な状態遷移をコンパイルレベルまたは明確に弾く) —
// 例外的な組み合わせを許容しない場合、網羅性により「書き忘れ」を防ぎつつ、
// 意図的な無効遷移を明示的にハンドリングする
(_, _) => state, // 無効な遷移の場合は現在の状態を維持
};

(※注:最後の `(_, _)` は、すべてのタプル空間をカバーするためのワイルドカードだが、ドメインの厳密性を極限まで高める場合は、無効な組み合わせを個別に列挙し、ワイルドカードを排除してすべての有効/無効パスをコンパイラに検証させることも可能である。)

—

4. チーフアーキテクトからの提言:コンパイラを味方につけた者だけが生き残る

ソフトウェアの複雑性が増大するにつれて、「人間がすべてを記憶し、変更漏れを防ぐ」というアプローチは完全に破綻する。

Dart 3の `switch` 式と網羅性チェックは、単なる「便利なシンタックスシュガー」ではない。それは、「ドメインの変更がコードベースの整合性を破壊することを、コンパイラという最強の番人に防いでもらうための数理的メカニズム」である。

  • `enum` や `sealed class` を定義したら、絶対に `_` や `default` で網羅性を骨抜きにしないこと。
  • 新しい状態を追加した際にコンパイラが赤くエラーを吐く、その状態こそが、堅牢なアーキテクチャが正常に機能している証拠である。

言語のランタイム仕様、コンパイラの静的解析器の挙動を深く理解し、コードの重みをコントロール下におくこと。それこそが、真にスケーラブルなシステムを構築するシニアエンジニアの流儀である。

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