Dart 3 Sealed Classesとパターンマッチング:コンパイラ保証によるゼロコスト・状態防壁の構築
Dart 3の導入により、言語仕様は大きなパラダイムシフトを迎えた。その最たるものが、`sealed`修飾子による代数データ型(ADT)の完全なサポートと、網羅性(Exhaustiveness)を伴うパターンマッチングの統合である。
多くのプログラマは、これを「switch文の糖衣構文」や「安全な列挙型の拡張」程度に捉えている。だが、ランタイムの深層を知るエンジニアであれば、これがコンパイル時における状態爆発の完全な封殺であり、不変性(Immutability)とメモリ局所性を極限まで高めるための強力な武器であることを理解しているはずだ。
本稿では、`sealed`クラスと変数宣言、そしてパターンマッチングをいかに組み合わせて堅牢な状態管理機構を構築するか、Dart VMの挙動やコンパイラの型推論メカニズムを交えながら徹底的に解剖する。
—
1. なぜ `sealed` なのか? コンパイラが敷く鉄壁の防壁
オブジェクト指向言語における従来のポリモーフィズム(例えば通常の抽象クラスと継承)は、オープンワールドの思想に基づいている。つまり、ライブラリの外部でいつ、どこで新しいサブクラスが生み出されるか、コンパイル時には予測できない。
そのため、`switch`文による分岐では常に `default` 節や `else` が必要となり、新しい状態を追加した際に「ハンドリングの漏れ」が実行時エラー(あるいは予期せぬサイレントバグ)として表面化することになる。
`sealed` がコンパイラに与える「全知」
`sealed`修飾子が指定されたクラスは、同一ライブラリ内でのみサブクラス化が許可される。
// 同一ライブラリ(ファイル、またはpartファイル群)内でしか継承できない
sealed class NetworkState
class NetworkIdle
class NetworkLoading
class NetworkSuccess
final T data;
const NetworkSuccess(this.data);
}
class NetworkFailure
final Object error;
const NetworkFailure(this.error);
}
この制約があるため、DartのAOT(Ahead-Of-Time)コンパイラおよびCFA(Control Flow Analysis)は、コンパイル時点で `NetworkState` のサブクラスの「全集合」を完全に把握できる。結果として、パターンマッチングを用いた `switch` 式において、すべてのケースが網羅されているかをコンパイラが厳密に検証し、漏れがあればコンパイルエラーを吐き出すことが可能になるのだ。
—
2. 変数宣言(`var`, `final`, `const`)との極限の融合
網羅的な状態管理の真価は、それを「どのように変数へバインドするか」によって決まる。Dart 3では、パターンマッチングを `switch` 式としてだけでなく、変数宣言(`var`, `final`, `const`)の代入側としても直接利用できる(Destructuring Patterns)。
ここでランタイムのメモリ効率を意識した変数修飾子の選択が重要になる。
`const` とアロケーションゼロの追求
状態管理において、ローディングやアイドル状態などのペイロードを持たないオブジェクトは、ヒープアロケーションを発生させるべきではない。`const` コンストラクタを持つ `sealed` サブクラスをパターンと組み合わせることで、Dart VMは定数プール上でインスタンスを共有(Canonicalization)し、GC(ガベージコレクション)の負荷を実質的にゼロに抑え込める。
以下の実装パターンを見てほしい。
sealed class AuthState {
const AuthState();
}
class Unauthenticated extends AuthState {
const Unauthenticated();
}
class Authenticated extends AuthState {
final String userId;
final Set
const Authenticated(this.userId, this.permissions);
}
class AuthLocked extends AuthState {
final DateTime until;
const AuthLocked(this.until);
}
パターンマッチングによる変数バインドの実装
この `AuthState` を受け取り、安全かつゼロコストに型を剥ぎ取りながら変数を安全領域にバインドするコードは以下のようになる。
String resolveUserRole(AuthState state) {
// switch式の結果を final 変数に直接バインド
// コンパイラが網羅性を強制するため default は不要
return switch (state) {
Unauthenticated() => ‘guest’,
AuthLocked(:var until) => throw SecurityException(‘Locked until $until’),
Authenticated(permissions: var perms) when perms.contains(‘ADMIN’) => ‘administrator’,
Authenticated() => ‘standard_user’,
};
}
このコードの優れている点は、ダウンキャスト(`as` キャスト)やランタイムの `is` チェックを一切排除している点にある。CFAはパターンがマッチした瞬間に、その分岐スコープ内における変数の型を厳密に狭め(Type Promotion)、余分なランタイムオーバーヘッドを完全に削ぎ落とす。
—
3. 実践:イベント駆動アーキテクチャにおける「状態防壁」
大規模な非同期処理やイベントループ(Event Loop)を流れるストリーム制御において、不正な状態遷移はクラッシュやデータ破損の原因となる。
ここでは、シニアエンジニアが現場で即座に採用すべき、`sealed` とパターンマッチングを駆使した厳密なステートマシン(State Machine)の設計を示す。
堅牢なステートマシンの実装
import ‘async’;
// 1. 状態の定義
sealed class PipelineState {
const PipelineState();
}
class PipelineIdle extends PipelineState {
const PipelineIdle();
}
class PipelineProcessing extends PipelineState {
final int step;
final int totalSteps;
const PipelineProcessing(this.step, this.totalSteps);
}
class PipelineCompleted
final R result;
const PipelineCompleted(this.result);
}
class PipelineFailed extends PipelineState {
final StackTrace stackTrace;
final Object cause;
const PipelineFailed(this.cause, this.stackTrace);
}
// 2. 状態遷移を強制するプロセッサ
class PipelineEngine {
PipelineState _state = const PipelineIdle();
PipelineState get state => _state;
// イベント駆動の処理系
void dispatchEvent(PipelineEvent event) {
// 現在の状態とイベントの組み合わせをパターンマッチングで制御
_state = switch ((_state, event)) {
// Idle 状態から Start イベントが来た場合
(PipelineIdle(), StartEvent(:var total)) =>
PipelineProcessing(1, total),
// Processing 状態から Progress イベントが来た場合
(PipelineProcessing(step: var current, totalSteps: var total), ProgressEvent()) when current < total =>
PipelineProcessing(current + 1, total),
// Processing の最終ステップ完了
(PipelineProcessing(step: var current, totalSteps: var total), ProgressEvent()) when current >= total =>
PipelineCompleted
// どの状態であれ、Abort イベントは受け付ける
(_, AbortEvent()) =>
const PipelineFailed(‘User Aborted’, StackTrace.empty),
// それ以外の不正な遷移は、コンパイル時ではなくとも論理的矛盾として防御
// (sealed と組み合わせることで網羅的定義が検証される)
_ => _state, // 無効な遷移は状態を維持、または例外を送出
};
}
}
sealed class PipelineEvent {}
class StartEvent extends PipelineEvent {
final int total;
StartEvent(this.total);
}
class ProgressEvent extends PipelineEvent {}
class AbortEvent extends PipelineEvent {}
アーキテクチャ上の極限知見:網羅性とタプルパターンの罠
上記のコードでは、`(_state, event)` というレコード(Records)のパターンマッチングを行っている。
Dart 3のレコードと `sealed` クラスの組み合わせは非常に強力だが、ここでエンジニアが注意しなければならないのは、「無限の組み合わせ(状態 × イベントの直積)」の網羅性である。
もし `switch` 式の網羅性を完全にコンパイラに保証させたい場合、すべての組み合わせを列挙するか、あるいはワイルドカード `_` に頼らざるを得なくなるケースがある。しかし、コアな状態遷移ロジックにおいて、状態側(`_state`)を `sealed` にしているため、少なくとも「あり得るすべての状態」に対するハンドリングが漏れている場合は、コンパイラが容赦なくエラーを叩き出す。
この性質により、将来的に `PipelinePaused` などの新しい状態クラスを追加した瞬間、プロジェクト内のすべてのステートマシン実装箇所でコンパイルエラーが発生し、「実装漏れを物理的に見逃さない防壁」が完成する。
—
4. ランタイム最適化とメモリレイアウトの深層
Dart VM(特にAOTコンパイル時)において、`sealed` クラスの階層構造は、コンパイラに対して「Devirtualization(仮想メソッド呼び出しの静的解決)」の強力なヒントを与える。
通常、通常のクラス継承におけるメソッド呼び出しは、VMT(Virtual Method Table)を介したディスパッチ(動的ディスパッチ)となり、わずかなCPUサイクルのペルティ(分岐予測ミス等)を生む可能性がある。しかし、`sealed` によってサブクラスが完全に閉じられている場合、コンパイラは型が確定している文脈において、VMT参照をバイパスして直接関数をインライン展開(Inlining)することが可能になる。
さらに、パターンマッチングを用いたプロパティの抽出(Destructuring)は、内部的にレジスタやスタックフレーム上でのポインタ操作にコンパイルされ、不必要なオブジェクト生成(Allocations)を一切伴わない。
// コンパイル後、メモリ上のフィールドアクセスは直接インライン化される可能性が高い
switch (state) {
case Authenticated(userId: var id):
print(id);
}
この効率性は、ミリ秒単位の応答速度が求められるFlutterのUIレンダリングスレッド(UI Thread)や、高スループットなサーバーサイドDartアプリケーションにおいて、GCプレッシャーを劇的に軽減する決定的な要因となる。
—
5. 結言
Dart 3の `sealed` クラスとパターンマッチングは、単なる「モダンなシンタックスの追加」ではない。それは、不確実なランタイムの状態という泥沼に対し、コンパイラの厳格な論理でコンクリートの防壁を築くためのエンジニアリング手法である。
`var`, `final`, `const` との適切な組み合わせ、イミュータブルなデータ設計、そして網羅性の強制。これらを極限まで突き詰めたコードベースには、もはや「うっかりミス」が入り込む隙間は存在しない。
言語の仕様の奥底にあるランタイムの挙動を掌握し、コンパイラを味方につけた者だけが、真に堅牢でスケーラブルなシステムを構築できる。今日のコードから `default` 節や無駄なキャストを排除し、完全なる網羅性の美学を実装に宿してほしい。