【入門編】Dartの「パターンマッチング」で実装する有限ステートマシン(FSM)のクリーンアーキテクチャ – Dart コア文法・オブジェクト指向・Null安全解析バイブル

こんにちは!FlutterやDartでの開発を楽しんでいますか?

他のプログラミング言語(TypeScript、Rust、Swiftなど)を経験した方なら、「Dart 3で導入されたパターンマッチングと`sealed class`って、めちゃくちゃ強力だけど、実際の現場ではどう使うの?」と気になっているのではないでしょうか。

今回は、このDart 3の最重要機能である「パターンマッチング」と「sealed class(網羅的クラス)」を組み合わせて、堅牢な有限ステートマシン(FSM)をクリーンに実装する方法を徹底解説します。

ここをクリアすれば、あなたのDartコードは一気にモダンで安全になりますよ。一緒にマスターしていきましょう!

—

なぜ有限ステートマシン(FSM)にパターンマッチングが必要なのか?

アプリケーション開発において、画面の「読み込み中」「成功」「エラー」や、注文の「未決済」「支払い済み」「発送済み」「完了」といった状態の管理(ステートマネジメント)は避けて通れません。

従来のC#やJavaのようなOOP的なアプローチでは、インターフェースを実装したクラスを乱立させたり、`switch-case`の中で`as`キャストの嵐になったりしがちでした。

// 昔ながらの危ういコードのイメージ
if (state is LoadingState) {
// …
} else if (state is ErrorState) {
// messageにアクセスするためにキャストが必要だったり…
}

これでは、新しい状態を追加したときに「処理し忘れたケース」をコンパイラが教えてくれないため、実行時エラー(バグ)の温床になります。

Dart 3の `sealed class` と `switch` 式(パターンマッチング)を使えば、「コンパイラがすべての状態の網羅性を静的に保証してくれる、極めて安全なFSM」が驚くほどスッキリ書けるんです。

—

3ステップで理解する!FSM実装の全体像

今回は、ECサイトの「注文プロセス」を例に、以下の3つの状態を持つFSMを実装してみましょう。

1. Cart(カート内): 商品が入っている状態
2. Paid(支払い完了): 決済が終わり、出荷を待っている状態
3. Shipped(出荷済み): ユーザーに商品が届きつつある状態

これをコードに落とし込みます。

ステップ1: `sealed class` で状態のバリエーションを定義する

まずは、状態の型階層を `sealed` キーワードで定義します。`sealed` にすることで、このライブラリ(ファイル)の外側で勝手に新しい状態を継承・追加できなくなります。これがコンパイラに「状態の全種類はこれだけだよ」と教えるための重要な目印になります。

// 注文のライフサイクルを表すsealed class
sealed class OrderState {}

// 1. カート状態(商品リストを持つ)
class Cart extends OrderState {
final List items;
Cart(this.items);
}

// 2. 支払い済み状態(決済IDを持つ)
class Paid extends OrderState {
final String transactionId;
Paid(this.transactionId);
}

// 3. 出荷済み状態(追跡番号を持つ)
class Shipped extends OrderState {
final String trackingNumber;
Shipped(this.trackingNumber);
}

ステップ2: イベント(トリガー)を定義する

状態を遷移させるための「きっかけ(イベント)」も同様に `sealed class` で定義します。

sealed class OrderEvent {}

class CheckoutEvent extends OrderEvent {} // レジに進む
class ShipEvent extends OrderEvent {
final String trackingNumber;
ShipEvent(this.trackingNumber);
}

ステップ3: パターンマッチングによる状態遷移ロジック(FSMコア)

ここが今回のハイライトです!Dart 3の `switch` 式とオブジェクトパターンを使って、現在の状態とイベントを同時にパターンマッチさせます。

OrderState transition(OrderState currentState, OrderEvent event) {
// Dart 3の switch 式。カンマで区切って「現在の状態」と「イベント」を同時に評価!
return switch ((currentState, event)) {
// パターン1: カート状態のときに CheckoutEvent が来たら Paid に遷移
(Cart(items: var items), CheckoutEvent()) when items.isNotEmpty =>
Paid(‘TXN-${DateTime.now().millisecondsSinceEpoch}’),

// パターン2: 支払い済み状態のときに ShipEvent が来たら Shipped に遷移
(Paid(), ShipEvent(trackingNumber: var tracking)) =>
Shipped(tracking),

// デフォルト(無効な遷移の場合は現在の状態を維持するか、例外を投げる)
_ => throw StateError(‘無効な状態遷移です: $currentState + $event’),
};
}

見てください、この美しさを!
`(currentState, event)` というレコード(タプル)を生成し、それを `switch` 式で一刀両断にパターンマッチングしています。ガード句 (`when`) を使うことで、カートが空のときはチェックアウトさせない、といったビジネスロジックもエレガントに記述できます。

—

実際に動かしてみよう(完全な実行可能コード)

以下のコードをコピーして、DartPadなどでそのまま実行してみてください。

// — 1. 状態とイベントの定義 —
sealed class OrderState {
@override
String toString() => runtimeType.toString();
}

class Cart extends OrderState {
final List items;
Cart(this.items);
@override
String toString() => ‘Cart(items: $items)’;
}

class Paid extends OrderState {
final String transactionId;
Paid(this.transactionId);
@override
String toString() => ‘Paid(txn: $transactionId)’;
}

class Shipped extends OrderState {
final String trackingNumber;
Shipped(this.trackingNumber);
@override
String toString() => ‘Shipped(tracking: $trackingNumber)’;
}

sealed class OrderEvent {}
class CheckoutEvent extends OrderEvent {}
class ShipEvent extends OrderEvent {
final String trackingNumber;
ShipEvent(this.trackingNumber);
}

// — 2. 遷移エンジン —
OrderState transition(OrderState state, OrderEvent event) {
return switch ((state, event)) {
(Cart(items: var items), CheckoutEvent()) =>
items.isEmpty
? throw StateError(‘カートが空です!’)
: Paid(‘TXN-999’),

(Paid(), ShipEvent(trackingNumber: var t)) =>
Shipped(t),

// 無効な遷移
_ => throw StateError(‘エラー: $state からイベント ${event.runtimeType} は処理できません’),
};
}

// — 3. メイン処理(実行デモ) —
void main() {
OrderState state = Cart([‘Dart入門書’, ‘Flutter実践ガイド’]);
print(‘初期状態: $state’);

// イベント1: チェックアウト
state = transition(state, CheckoutEvent());
print(‘遷移後: $state’);

// イベント2: 出荷
state = transition(state, ShipEvent(‘JP-123456789’));
print(‘遷移後: $state’);

// 不正な遷移のテスト(出荷済みなのに再度チェックアウトしようとする)
try {
state = transition(state, CheckoutEvent());
} catch (e) {
print(‘想定されたエラーをキャッチ: $e’);
}
}

実行結果

初期状態: Cart(items: [Dart入門書, Flutter実践ガイド])
遷移後: Paid(txn: TXN-999)
遷移後: Shipped(tracking: JP-123456789)
想定されたエラーをキャッチ: Bad state: エラー: Shipped(tracking: JP-123456789) からイベント CheckoutEvent は処理できません

完璧ですね!意図した通りの状態遷移と、不正な操作に対する堅牢なガードが実現できています。

—

開発現場で陥りやすい罠とアドバイス

初学者の開発者からよくある質問や、ハマりがちなポイントをいくつかシェアしておきますね。

1. 「網羅性のチェック(Exhaustiveness)」が効かない?

`switch` 式の強力な機能として、すべてのパターンを網羅していない場合にコンパイルエラーを出してくれる仕組みがあります。
もし「`The type ‘…’ is not exhaustively matched by the switch cases…`」というエラーが出たら、それは「まだ処理していない状態の組み合わせがあるよ」というコンパイラからの優しい警告です。絶対に `_ =>` で思考停止して逃げず、すべての状態ケースを明示的に書くようにしましょう。保守性が劇的に向上します。

2. オブジェクトパターンの構文ミスに注意

Dartのパターンマッチングでは、プロパティを抽出する際に次のような書き方をします。

  • ❌ 誤り: `(Cart(items), CheckoutEvent())`
  • ⭕️ 正解: `(Cart(items: var items), CheckoutEvent())`

変数名にバインドする場合は `var` や `final` を明示するか、構造化代入の構文に従う必要がある点に注意してください。

—

まとめ

今回は、Dart 3の `sealed class` とパターンマッチングを駆使した、クリーンで堅牢な有限ステートマシン(FSM)の実装方法を解説しました。

  • `sealed class` で状態の種類を閉じ込め、安全な型階層を作る。
  • `switch` 式とオブジェクトパターンで、現在の状態とイベントをスマートに評価する。
  • 網羅性チェックにより、将来の状態追加漏れをコンパイル時に完全に防ぐ。

このパターンをマスターすれば、複雑な画面遷移や非同期処理の状態管理も、怖くないどころか書くのが楽しくなりますよ。

ここをクリアしたあなたなら、もうDartの中級者への階段を確実に登っています。ぜひ実際のプロジェクトのステート管理に取り入れてみてくださいね!

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