Dart 3の代数的データ型(ADT)的アプローチ:sealed classとパターンマッチングの最強タッグ
ランタイムの深淵を覗くとき、私たちが書くコードがいかにコンパイラとVMの挙動に直結しているかを意識せざるを得ない。Dart 3で導入された `sealed class` と網羅的(Exhaustive)な `switch` 式は、単なる「便利なシンタックスシュガー」ではない。これは、コンパイラの型推論エンジンとAOT(Ahead-Of-Time)コンパイラに対して、状態の閉包性を証明し、実行時の安全性をゼロコストで担保するための極限のアーキテクチャ・プリミティブである。
本稿では、Dartにおける代数データ型(ADT)の実現メカニズムを解剖し、メモリレイアウト、コンパイル時最適化、そして非同期イベントループの文脈における堅牢な状態管理の極意を紐解く。
—
1. なぜ `sealed class` なのか? —— 自由度を削ぎ落とし、安全性を錬成する
オブジェクト指向言語における通常のクラス継承は、オープンワールドの思想に基づいている。つまり、将来的にどこでどのようなサブクラスが定義されるか、コンパイラはコンパイル時に知り得ない。そのため、ポリモーフィズムは動的なディスパッチ(vtableを介した仮想メソッド呼び出し)に依存せざるを得ず、パターン分岐においては常に `default` や `else` といった「予期せぬ状態」へのフォールバックを強制されてきた。
一方、`sealed class` はその空間を閉じた領域(Closed World)へと強制的に収斂させる。
// ネットワーク通信の状態を表現するADTの極限設計
sealed class NetworkResult
class Idle
class Loading
class Success
final T data;
const Success(this.data);
}
class Failure
final Object error;
final StackTrace stackTrace;
const Failure(this.error, this.stackTrace);
}
このコードがコンパイルされるとき、Dartのフロントエンドコンパイラ(CFA: Common Front End)は、`NetworkResult` を継承できるのは同一ライブラリ(同一ファイル、あるいは `part` で結ばれたスコープ)内の具象クラス群のみであることを厳密に検証する。
コンパイラの最適化とインライン化
この「閉じた世界」の保証は、Dart VMのAOTコンパイラ(あるいはJITの型フィードバック最適化)にとって強力な武器となる。
サブクラスの総数がコンパイル時に確定しているため、コンパイラは動的なvtableルックアップをバイパスし、型タグに基づくインラインキャッシュや直接ジャンプ(Devirtualization)へ最適化する余地を得る。これにより、高頻度で実行されるステートマシン内部のオーバーヘッドが極限まで削減される。
—
2. switch式とパターンマッチングによる網羅性の強制
Dart 3の `switch` は、文(Statement)から式(Expression)へと昇格し、パターンマッチングの能力を獲得した。ここで重要なのは、`sealed class` と組み合わせた際の網羅性チェック(Exhaustiveness Checking)である。
以下のコードを見てほしい。
String handleResponse
return switch (result) {
Idle() => ‘待機中’,
Loading() => ‘ロード中…’,
Success(data: final d) => ‘成功: $d’,
// Failureを意図的に除外している
};
}
このコードはコンパイルエラーとなる。コンパイラは「`Failure` ケースが処理されていない」ことを検出し、ビルドを即座に弾く。
The type ‘NetworkResult
The following type isn’t covered: ‘Failure
防壁の構築において「エラーハンドリングの漏れ」は致命的な脆弱性の温床となる。従来の `if-else` や網羅性のない `switch` では、新しい状態クラスを追加した際に、開発者がコードベース全体をしらみ潰しに探して修正漏れがないか確認する必要があった。しかし、Dart 3のADT的アプローチでは、データ構造にフィールドを追加・変更した瞬間、コンパイラが未対応の分岐をすべて特定し、修正を強制する。人的ミスが入り込む余地を言語仕様のレイヤで完全に遮断しているのだ。
—
3. 実践:非同期イベントループと厳密な状態遷移の同期
では、この理論を実際のアーキテクチャ、すなわちDartのイベントループ(Event Loop)上で動く非同期ステートマシンに応用してみよう。
UIスレッドやバックグラウンドIsolateで動作するサービス層において、競合状態や不正な状態遷移を防ぐ防壁としてADTを機能させる。
import ‘dart:async’;
// 厳密に定義された認証セッションのADT
sealed class AuthState {}
class Unauthenticated extends AuthState {}
class Authenticating extends AuthState {
final String userId;
Authenticating(this.userId);
}
class Authenticated extends AuthState {
final String token;
final int expiresAt;
Authenticated(this.token, this.expiresAt);
}
class AuthError extends AuthState {
final String message;
AuthError(this.message);
}
class SessionManager {
AuthState _state = Unauthenticated();
// 状態のストリーム公開(外部からは読み取り専用)
final _stateController = StreamController
Stream
AuthState get currentState => _state;
void transition(AuthState newState) {
// 遷移ルールのバリデーション(不変式の強制)
if (!_isValidTransition(_state, newState)) {
throw StateError(‘Invalid state transition from ${_state.runtimeType} to ${newState.runtimeType}’);
}
_state = newState;
_stateController.add(_state);
}
bool Function(AuthState from, AuthState to) get _isValidTransition => (from, to) {
return switch ((from, to)) {
// Unauthenticated からは Authenticating のみに遷移可能
(Unauthenticated(), Authenticating()) => true,
// Authenticating からは Authenticated か AuthError のみに遷移可能
(Authenticating(), Authenticated()) => true,
(Authenticating(), AuthError()) => true,
// Authenticated からは Unauthenticated (ログアウト) のみに遷移可能
(Authenticated(), Unauthenticated()) => true,
// AuthError からはリトライのための Authenticating や Unauthenticated へ
(AuthError(), Authenticating()) => true,
(AuthError(), Unauthenticated()) => true,
// それ以外の遷移はすべて不正(セキュリティ違反/バグ)
_ => false,
};
};
}
レコードパターン(Record Patterns)の活用
上記の `_isValidTransition` 内の `switch ((from, to))` に注目してほしい。ここでは Dart 3のレコード(Records)とパターンマッチングを組み合わせて、2つの変数の直積(Product)に対するパターンマッチを行っている。
従来のCやJavaであれば、ネストした `if` 文の迷宮になり、認知負荷が高くバグの温床になっていた複雑な状態遷移マトリクスが、極めて宣言的かつ一目で全体像が把握できる形で記述されている。
さらに、このコードがイベントループ上で実行される際、`_stateController.add(_state)` はマイクロタスクまたはイベントキューを通じてリスナーに伝播する。コンパイルされたパターンマッチのジャンプテーブルにより、分岐処理のCPUサイクルは最小限に抑えられ、高頻度なイベント処理であってもランタイムのパフォーマンスを犠牲にしない。
—
4. メモリ効率とアロケーションの最適化
シニアエンジニアとして、アロケーション(Heap Allocation)のコストには常に敏感であらねばならない。状態遷移のたびにクラスのインスタンスを生成することは、GC(Garbage Collector)に負荷をかける要因になり得る。
ここで、`const` コンストラクタの活用が極めて重要になる。
// ペイロードを持たない状態はすべてconst化し、インスタンスをシングルトンとして使い回す
sealed class ConnectionState {
const ConnectionState();
}
class Disconnected extends ConnectionState {
const Disconnected();
}
class Connecting extends ConnectionState {
const Connecting();
}
class Connected extends ConnectionState {
const Connected();
}
ペイロードを持たない具象クラスに `const` コンストラクタを付与し、呼び出し側でも `const` を徹底することで、Dart VMはこれらをコンパイル時定数として扱わせ、ヒープ上での新規メモリ割り当てを完全にゼロ(Zero-Allocation)に抑えることができる。
パターンマッチングを行う側でも、定数パターン(Constant Patterns)として評価されるため、ランタイムのオーバーヘッドはポインタ比較レベルまで最適化される。
—
結びにかえて
Dart 3の `sealed class` とパターンマッチングは、単にコードをエレガントにするための装飾ではない。それは、開発者が頭の中で描く「あり得べき状態の宇宙」を、コンパイラという厳格な守護神にそのまま理解させ、実行時の異常系をコンパイル時のエラーへと昇華させるための最強の武器である。
ランタイムの裏側で何が起きているかを知り、メモリレイアウトとコンパイラの思考をトレースしながらコードを書くこと。それこそが、真に堅牢で破綻しないアーキテクチャを構築する唯一の道である。コードの細部にまで意志を宿せ。妥協のない設計こそが、プロダクトの寿命を決定づけるのだから。