【テクニカル・上級編】Dartのswitch式における網羅性チェックを「あえて」回避する設計の是非 – Dart コア文法・オブジェクト指向・Null安全解析バイブル

思考停止の `default` を棄てよ:`sealed class` と Dart 3 パターンマッチングにおける網羅性バイパスの代償と静的評価機構の深層

Dart 3 の登場によって言語構造の核へと導入された Patterns と `sealed class` 階層は、型システムにおける「直和型(Sum Type / Algebraic Data Type)」の表現を強力に推し進めた。

しかし、現場の大規模コードベースやライブラリ開発の最前線において、今なお頻繁に目にするコードパターンが存在する。それが、`sealed class` に対する `switch` 式(または `switch` 文)において、将来の機能拡張や未知の型追加を「防衛的に処理する」という名目で、あえて `default:` やワイルドカードパターン `_ =>` を記述し、静的網羅性チェック(Exhaustiveness Checking)を無効化する設計である。

「将来、新しいサブクラスが追加されたときにアプリがクラッシュしないよう、デフォルトケースを書いておく」——この思考は一見、堅牢なプログラミングに見えるかもしれない。だが、Dart VM の内部構造および Front-End Compiler (CFE) の静的解析アルゴリズムの観点から言えば、これは静的型システムが提供する最大の安全装置を自ら破壊し、AOTコンパイラの最適化パスを著しく阻害するアンチパターンである場合が極めて多い。

本稿では、Dart 3 の Front-End コンパイラがどのように網羅性を静的に評価しているか(Space Matching アルゴリズム)を解剖し、あえて網羅性チェックを回避する設計が及ぼすランタイム・コンパイル時のインパルス、そして真に堅牢なドメイン境界設計について論じる。

—

1. Dart 3 網羅性解析(Exhaustiveness Analysis)の内部構造

Dart 3 の CFE (Common Front End) は、パターンマッチングにおける網羅性を検証するために、型理論における Space Matching アルゴリズム を採用している。

コンパイル時の型空間演算

コンパイラ内部において、評価対象の型 $T$ は「型空間(Space)」として定義される。`sealed class` $S$ が直和の構成要素 $A, B, C$ を持つとき、型空間の和集合は次のように静的に決定される。

$$\text{Space}(S) = \text{Space}(A) \cup \text{Space}(B) \cup \text{Space}(C)$$

`switch` 式の各ケース $P_1, P_2, \dots, P_n$ は、この型空間に対する部分空間(Subspace)の抽出処理に他ならない。コンパイラは以下の判定をコンパイル時に実行する。

$$\text{Space}(S) \setminus \left( \bigcup_{i=1}^{n} \text{Space}(P_i) \right) = \emptyset$$

この差分集合が空集合 $\emptyset$ にならない場合、すなわち「どのケースにもマッチしない型空間の隙間」が存在するとき、CFE は静的エラー(`non_exhaustive_switch_expression`)を発行する。

// ドメインイベントの定義
sealed class IdentityEvent const {
const IdentityEvent();
}

final class LoginRequested extends IdentityEvent {
final String userId;
const LoginRequested(this.userId);
}

final class LogoutRequested extends IdentityEvent {
const LogoutRequested();
}

final class SessionExpired extends IdentityEvent {
final DateTime expiredAt;
const SessionExpired(this.expiredAt);
}

この階層に対し、完全な網羅性を持つ `switch` 式を記述すると以下のようになる。

// CFE による評価: Space(IdentityEvent) \ (Space(LoginRequested) ∪ Space(LogoutRequested) ∪ Space(SessionExpired)) == ∅
// 静的解析は完全にパスする。
String mapEventToLog(IdentityEvent event) {
return switch (event) {
LoginRequested(:final userId) => ‘User login attempt: $userId’,
LogoutRequested() => ‘User logout triggered’,
SessionExpired(:final expiredAt) => ‘Session timed out at $expiredAt’,
};
}

この時、コンパイラは `IdentityEvent` のすべての定義済み判定空間が埋め尽くされていることを完全に証明できる。

—

2. あえて `default` / `_` を配置したときに起きる破壊と AOT 内部挙動

ここで本題に入る。将来の拡張性や未知のサブタイプ追加に対する防衛策として、次のようなコードを書く設計者が存在する。

// 網羅性チェックを「あえて」回避するアンチパターン
String mapEventToLogWithDefault(IdentityEvent event) {
return switch (event) {
LoginRequested(:final userId) => ‘User login attempt: $userId’,
LogoutRequested() => ‘User logout triggered’,
// SessionExpired のハンドリングを失念、または意図的に default へ流す
_ => ‘Unknown identity event detected’,
};
}

このコードを実行・コンパイルした際、Dart の静的解析器および AOT コンパイラ(`dart2native` / GenSnapshot)の内部では何が起きているのだろうか。

① 静的安全網の完全な機能不全(静的検査の無効化)

ワイルドカード `_` や `default` ケースを追加した瞬間、型空間演算は以下のように変化する。

$$\text{Space}(P_{\text{default}}) = \text{Space}(\text{Object?})$$

ワイルドカードは万能の型空間(Top Space)として振る舞うため、差分演算の結果は無条件で空集合 $\emptyset$ になる。

結果として、将来開発者が `IdentityEvent` に新たなサブクラス `MfaRequired` を追加した際、コンパイラは何の警告も発しなくなる。本来であればコンパイルエラーによって「呼び出し側コードの更新忘れ」を全自動で検知できたはずの機会を、設計者が自ら放棄したことを意味する。

② Dart AOT コンパイル時の Intermediate Representation (SSA IR) とディスパッチの劣化

Dart AOT コンパイラ(`pkg/vm/lib/transformations/type_flow/`)は、Kernel AST から SSA (Static Single Assignment) 形式の IR へ変換する際、`switch` 式の網羅性が担保されているか否かで全く異なるコードを生成する。

網羅性が静的に証明されている場合、コンパイラは末尾のフォールバック条件分岐を「到達不能(Unreachable)」として切り捨てる。また、`sealed class` の Class ID (CID) に基づく Direct Jump Table または高度に最適化された Polymorphic Inline Cache (PIC) を構築する。

[ Exhaustive Switch (網羅的) の AOT 概念パイプライン ]
[IdentityEvent Instance]
│ (CID 取得)
▼
┌─────────────────────────────────┐
│ Jump Table (Direct Branch) │
├──────────────┬──────────────────┤
│ CID_Login │ -> Branch_Login │
│ CID_Logout │ -> Branch_Logout │
│ CID_Expired │ -> Branch_Expire │
└──────────────┴──────────────────┤
(Unreachable code is totally pruned)

しかし、`_` (ワイルドカード)が存在する場合、AOT コンパイラは「任意の型がここへ到達する可能性がある」と仮定せざるを得ない。その結果:

1. デオプティマイゼーションの阻害: 未知の CID が流入する可能性に備え、末尾分岐の命令コード(フォールバック用命令ブロック)がバイナリ内に保持され続ける。
2. 分岐予測精度の低下: Jump Table への最適化が崩れ、一連の `if-else` 型 Type Check (`Instanceof`) 命令列へフォールバックされるリスクが増大する。

バイナリサイズおよび CPU L1 キャッシュの命令密度という超低レイヤの観点においても、不要な `default` の存在は明確なコストとなる。

—

3. 網羅性チェック回避が「許容・要求される」極小の限界境界

では、いかなる文脈であっても `default` や `_` の使用は悪なのだろうか。アーキテクチャの境界線において、網羅性チェックをあえてバイパス、あるいは明示的にデフォルトを設けることが議論される極小のパターンが存在する。

動的プラグイン / バイナリ境界 (Package Boundary) を跨ぐ進化

自組織のモノレポ外に存在する外部パッケージ、または動的リフレクションや C-ABI (Dart FFI) 経由でデータが流入する境界線である。

パッケージ A が以下のように `non-sealed` または `seam` のある型定義を提供している場合:

// 外部ライブラリの型構造 (将来バージョンでサブクラスが増える可能性がある)
abstract class NetworkPayload {
const NetworkPayload();
}

class TextPayload extends NetworkPayload {
final String text;
const TextPayload(this.text);
}

class BinaryPayload extends NetworkPayload {
final List bytes;
const BinaryPayload(this.bytes);
}

この場合、`NetworkPayload` は `sealed` ではないため、CFE による網羅的判定は本来適用されない。しかし、型キャストやドメイン境界でのデコード時において「未知の型がプロトコルを渡って届く」リスクが存在する。

このようなシステムバウンダリ(信頼できない境界)においては、網羅性チェックの回避ではなく、「未知の型をドメインモデル内の明示的なエラー状態へ昇華させる」 設計が要求される。

—

4. 防御的プログラミングの正当な解法パターン

「将来の拡張時にアプリを落としたくない」という要求と、「コンパイル時の網羅性チェックを最大化したい」という強固な安全性の追求。この二つの要求を完全かつ厳密に両立させるための設計パターンを示す。

パターン A: 閉じた静的型空間での `UnreachableError` 明示

`sealed class` を使用している以上、すべてのサブタイプは同一ライブラリ(ファイル)内で閉じている。この空間内で「あえて」将来の未知の型に備えるべきではない。

新しい型が追加された際、コンパイルエラーを発生させ、開発者に処理の追加を強制させるのが Dart 3 の正攻法である。どうしてもフォールバックを記述しなければならない特殊なロジック(リフレクション等の低レイヤ層)においては、ワイルドカードで防衛するのではなく、`StateError` または `UnreachableError` を明示して即時クラッシュさせる。

// ドメインのコア状態
sealed class ProcessingState {
const ProcessingState();
}

final class StateIdle extends ProcessingState {}
final class StateRunning extends ProcessingState {
final double progress;
const StateRunning(this.progress);
}
final class StateCompleted extends ProcessingState {}

// 設計指針: 網羅性を絶対に破壊しない
// 新しい StateFailed が追加された場合、ここをコンパイルエラーにするべきである。
String getStatusMessage(ProcessingState state) {
return switch (state) {
StateIdle() => ‘Waiting to start…’,
StateRunning(:final progress) => ‘Processing: ${(progress 100).toStringAsFixed(1)}%’,
StateCompleted() => ‘Done!’,
};
}

パターン B: 境界レイヤにおける Fallback Type のドメイン組み込み

外部APIやシリアライズ層(JSON解析等)からの未知のデータを安全に扱いたい場合、ワイルドカードで闇に葬るのではなく、「未知のデータ」を意味するサブクラスを `sealed class` の静的空間に組み込む。

import ‘package:meta/meta.dart’;

/// 外部APIレスポンスのドメインモデル表現
@immutable
sealed class ApiStatus {
const ApiStatus();

/// JSONデータからの安全なファクトリ
factory ApiStatus.fromJson(Map json) {
return switch (json[‘status’]) {
‘ok’ => ApiStatusSuccess(json[‘payload’]),
‘error’ => ApiStatusFailure(json[‘code’] as int),
// 境界層での未知の値を Known Fallback 型にマッピングする
String unknown => ApiStatusUnknown(unknown),
_ => const ApiStatusUnknown(‘Malformed response’),
};
}
}

final class ApiStatusSuccess extends ApiStatus {
final dynamic payload;
const ApiStatusSuccess(this.payload);
}

final class ApiStatusFailure extends ApiStatus {
final int errorCode;
const ApiStatusFailure(this.errorCode);
}

/// 境界の外側からやってきた「未知の型」を表す静的な型空間の一部
final class ApiStatusUnknown extends ApiStatus {
final String rawStatus;
const ApiStatusUnknown(this.rawStatus);
}

この設計パターンを評価してみよう。

// ドメインロジック側での利用
String renderStatus(ApiStatus status) {
// 網羅的 switch 式。
// default を使わずに「未知のステータス」の処理をコンパイル時に強制する。
return switch (status) {
ApiStatusSuccess() => ‘Operation Succeeded’,
ApiStatusFailure(:final errorCode) => ‘Failed with code: $errorCode’,
ApiStatusUnknown(:final rawStatus) => ‘Received unhandled status: $rawStatus’,
};
}

このパターンにおける優位性は明白である:

1. CFE の Exhaustiveness Check を 100% 維持: `ApiStatus` のサブクラスが増えれば(例: `ApiStatusPending`)、`renderStatus` 内の `switch` 式は即座にコンパイルエラーを起こす。
2. ランタイム例外の排除: 不明なステータスが来ても、型システムの枠組みの中で `ApiStatusUnknown` パスへ安全に誘導される。
3. AOT 最適化の最大化: コンパイラは型空間が完全に評価されていることを静的に証明でき、最適な Jump Table を生成する。

—

5. アーキテクトに求められる決断と静的評価の指針

Dart 3 のパターンマッチングと `sealed class` は、単なるシンタックスシュガーではない。それは、コンパイラのFront-End解析、AOTのコード生成、そして実行時のDart VMの挙動までを一貫して最適化するために言語仕様レベルで統合された静的証明システムである。

「将来の変更に対する防衛策」という曖昧な目的のために `switch` 式へ `default:` や `_` を導入することは、この静的証明システムを途中で破棄することと同義である。

アーキテクトがコードベースに対して課すべき規範は以下の通りである。

1. 内部ドメイン(`sealed class` 空間)における `default` / `_` の完全禁止:
ドメインの型階層が閉じている場合、網羅的エラーの検知こそが最大の防衛策である。コンパイルエラーを恐れてはならない。コンパイルエラーは修正すべき場所を全自動で示すコンパイラからの最も親切なフィードバックである。

2. 境界領域(Boundary Layer)での明示的 Fallback 型の採用:
ネットワーク、DB、FFI などの動的領域から入るデータは、デコード段階で `Unknown` 型を含む `sealed` 空間の型へと変換する。

3. 静的解析ルール(Linter)の厳格化:
プロジェクトの `analysis_options.yaml` において、不必要なワイルドカードやフォールバックを排除・検知するポリシーを徹底する。

Dart VM の静的型システムを信頼し、コンパイラに全ての型空間を演算させよ。それこそが、大規模かつ長期にわたって進化し続けるDart/Flutterアプリケーションにおいて、極限のパフォーマンスと絶対的な型安全性を両立させる唯一の道である。

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