Dartパターンマッチングの深淵:`if-case`と`switch`式における「失敗」の厳密な意味とランタイムの罠
Dart 3におけるパターンマッチングと網羅性検査(Exhaustiveness Checking)の導入は、言語の表現力を飛躍的に高めた。しかし、C#やRustなどの静的言語的背景を持たずにこの機能へ踏み込んだエンジニアは、ランタイムでの「パターン失敗(Pattern Failure)」が引き起こす制御フローの歪みに足元をすくわれる。
本稿では、Dart VMおよびAOTコンパイラがパターンをどのように評価し、マッチしなかった場合の「失敗」をどう処理しているのか、その低レイヤのメカニズムを解き明かす。`if-case`と`switch`式における挙動の根本的な差異を理解し、堅牢なアーキテクチャを構築するための防衛的設計指針を提示する。
—
1. コンパイル時保証とランタイム現実の乖離
Dart 3の静的型システムとパターンマッチングは、コンパイル時に多くの型安全性を保証する。特に `switch` 式における網羅性検査は、代数的データ型(ADT)的なアプローチをとる際に強力だ。しかし、動的型付けの名残(`dynamic`型や不完全なJSONパースなど)が混入するエンタープライズ環境において、静的検査はランタイムの現実の前で無力化することがある。
パターンが一致しなかったとき、Dartランタイムは何を行い、制御フローはどう分岐するのか。ここを曖昧にしたままコードを書くことは、時限爆弾を抱えてイベントループを回すようなものだ。
—
2. `if-case` における「失敗」:静かなスキップとスコープの罠
`if-case` 文(およびガード付きの `if (val case pattern when condition)`)は、条件分岐の一種であり、パターンマッチの失敗は「単なる不成立」として扱われる。
内部挙動とコード例
void processPayload(Object payload) {
// payload が Map
if (payload case {‘status’: int code, ‘data’: String message} when code >= 200) {
print(‘Success [$code]: $message’);
return;
}
// ここに到達したということは、「パターン不一致」または「ガード条件の不成立」
// 例外はスローされず、制御フローは自然にフォールスルーする
print(‘Payload ignored due to pattern mismatch or guard failure.’);
}
アーキテクトの視点:
1. 例外の不在: `if-case` はパターンマッチの失敗時に例外をスローしない。これは意図的な設計だが、「データが届いているはずだ」という暗黙の前提があるシステムにおいて、サイレント・フェイル(沈黙した失敗)を引き起こす最大の原因となる。
2. スコープの限定: パターン内でバインドされた変数(上の例の `code`, `message`)は、その `if` ブロックのスコープ内に厳密に閉じ込められる。Dart VMは、マッチが成功したパスでのみこれらのレジスタ/スタック上のスロットを有効化し、失敗パスでは即座に破棄(あるいは初期化スキップ)するコードを生成する。
—
3. `switch` 式における「失敗」:網羅性と `StateError` の恐怖
一方、`switch` 式(StatementではなくExpression)は、すべての可能な入力が網羅されていることを要求する。網羅されていない場合、コンパイルエラーとなる。
しかし、動的キャストや将来的な型の拡張、あるいは複雑なガード条件の組み合わせによって、コンパイラの網羅性チェッカーをすり抜けた場合(あるいは `switch` 文 を使用している場合)、ランタイムはどう振る舞うか?
String evaluateStatus(Object status) {
// switch 文(式ではない)の場合、網羅性が強制されないことがある
// あるいは複雑なガードによってランタイムでどのケースにもヒットしない場合
var result = switch (status) {
100 => ‘Continue’,
200 => ‘OK’,
// デフォルトケース(_)が欠けているとする
};
return result;
}
もし `status` が `500` の場合、このコードはコンパイルを通過したとしても、実行時に `StateError (No switch case matching the given value)` をスローし、現在のIsolateをクラッシュさせる。
ランタイムの評価メカニズム:
Dart AOTコンパイラ(Native)は、`switch` 式を効率的なジャンプテーブル(Jump Table)あるいは条件分岐のツリー(Decision Tree)にコンパイルする。
マッチするものが見つからなかった場合、ランタイムの型・値ディスパッチ機構はフォールスルー先を見失い、即座に `Dart_ThrowException` を呼び出す。イベントループ上でこれがキャッチされなければ、非同期処理のコンテキスト全体がアボートする。
—
4. 比較:`if-case` vs `switch` 式の挙動マトリクス
| 評価項目 | `if-case` | `switch` 式 (`switch (…)`) |
| :— | :— | :— |
| 網羅性検査 | なし(部分マッチを許容) | 必須(網羅されていない場合はコンパイルエラー) |
| マッチ失敗時の挙動 | ブロックをスキップし、処理継続 | `StateError` をスローし、制御フロー中断 |
| ガベージコレクションへの影響 | スコープが狭いため、不要になったオブジェクトの参照が即座に断たれ、GCの効率が向上 | 式の結果として値を返すため、一時オブジェクトのライフサイクルが外側のスコープに依存する |
| 最適なユースケース | オプショナルなデータ抽出、特定の条件だけをフィルタリングしたい場合 | 閉じたドメインロジックのステートマシン、完全な状態分岐 |
—
5. 防衛的設計指針:意図しないフォールスルーとスキップを防ぐ
大規模なFlutterアプリケーションや高スループットなDartバックエンド(Serverpod等)において、パターンの失敗をコントロールすることはセキュリティと安定性の要諦である。
以下の指針をコードベースに厳格に適用せよ。
指針 A: `if-case` では「デフォルト/フォールバック」を必ず明示する
サイレント・フェイルはバグの温床である。`if-case` を使う際は、必ず `else` 節を記述し、想定外の入力に対するメトリクスのインクリメントやロギングを行え。
void handleNetworkEvent(Object event) {
if (event case {‘type’: ‘ping’, ‘timestamp’: int ts}) {
_processPing(ts);
return;
}
// 【重要】失敗を隠蔽せず、観測可能にする
Telemetry.incrementCounter(‘network.event.unmatched’);
throw FormatException(‘Unexpected event structure: $event’);
}
指針 B: `switch` 式では常にワイルドカード(`_`)で閉じる
どれほど完璧に型を定義したつもりでも、外部APIやシリアライズ境界を跨ぐデータは信用するな。`switch` 式の最後には必ず `_`(ワイルドカードパターン)を置き、予期せぬランタイム例外を防ぐとともに、未知の値に対するフォールバックを強制せよ。
Color resolveTheme(String themeMode) => switch (themeMode) {
‘light’ => Colors.white,
‘dark’ => Colors.black,
// 未知の値が流れ込んでもクラッシュさせず、安全なフォールバックを返す
_ => Colors.systemDefault,
};
指針 C: キャプチャ変数とメモリの局所性
パターンマッチ内で定義される変数(リガードや抽出)は、スタックフレームの効率的な領域を利用する。しかし、巨大なオブジェクトグラフをパターンマッチで分解する際、不要な深いプロパティまでキャプチャすると、メモリのL1/L2キャッシュ効率が低下し、GCプレッシャーが増大する。
「必要な最小限のフィールドだけを抽出する」構造化パターン(Destructuring)を心がけよ。
—
結語
Dart 3のパターンマッチングは単なる「糖衣構文」ではない。それはコンパイラに対する厳密な契約(Contract)の宣言である。
`if-case` がもたらす「静かな失敗」の特性と、`switch` 式が要求する「厳格な網羅性」の特性。この二つのランタイム挙動の差異を骨肉まで染み込ませたエンジニアだけが、予測可能で堅牢な、真にスケーラブルなDartコードベースを構築できる。
妥協のないコードを書け。ランタイムは君の意図ではなく、書かれたバイトコードの通りにしか動かないのだから。