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

こんにちは!FlutterやDartの開発現場で、日々コードの海に飛び込んでいる皆さん。
今回は、Dart 3で導入された「パターンマッチング」と「`sealed class`(網羅的クラス)」を組み合わせて、最高にクリーンで堅牢な有限ステートマシン(FSM)を作る方法についてお話ししますね。

他の言語(TypeScriptやRust、Swiftなど)からDartにやってきた方なら、「やっとDartもここまで来たか!」と胸が熱くなる領域ですし、初学者の方にとっても「なぜバグが起きないのか」を美しく体感できる最高のトピックです。

ここをクリアすれば、あなたのDartのコードは一段も二段も洗練されたものになりますよ。さあ、一緒に深掘りしていきましょう!

—

1. なぜ「if/else」だらけのステート管理は破綻するのか?

アプリを作っていると、必ずと言っていいほど「状態(State)」の管理に直面しますよね。
例えば、データのローディング画面です。

  • 未取得 (Initial)
  • 読み込み中 (Loading)
  • 成功 (Success)
  • エラー (Error)

これを従来の `enum` と `switch-case` で書こうとすると、こんなコードになりがちです。

// 昔ながらの書き方(バグの温床になりやすい)
enum StateType { initial, loading, success, error }

class OldUiState {
final StateType type;
final String? data;
final String? errorMessage;

OldUiState({required this.type, this.data, this.errorMessage});
}

void render(OldUiState state) {
switch (state.type) {
case StateType.initial:
print(‘画面を開いたよ’);
break;
case StateType.loading:
print(‘読み込み中… データは ${state.data} だっけ?’); // ⚠️エラー! loading なのに data が参照できちゃう矛盾!
break;
case StateType.success:
print(‘データ取得成功: ${state.data}’);
break;
case StateType.error:
// あれ? error なのに errorMessage を書き忘れた!でもコンパイルは通っちゃう……
break;
}
}

お気づきでしょうか? このアプローチの恐ろしいところは、「特定の状態の時にしか存在しないはずのデータ(成功時のデータ、エラー時のメッセージなど)が、すべての状態からアクセスできてしまう」という構造的な欠陥(型安全性の欠如)がある点です。

さらに、新しい状態を追加したときに `switch` 文の `case` を書き忘れても、コンパイラは優しく「ここ忘れてるよ」とは教えてくれません。これが実行時エラー(バグ)の温床になるんですよね。

—

2. Dart 3の `sealed class` とパターンマッチングが世界を救う

この問題を鮮やかに解決するのが、Dart 3の `sealed class` と パターンマッチング(`switch` 式) のコンビネーションです。

まずは頭の中で、次のような「状態の階層構造」をイメージしてみてください。

[ UiState (sealed) ]
├── Initial (データなし)
├── Loading (プログレス情報など)
├── Success (厳格な data: String)
└── Error (厳格な message: String)

`sealed class` を使うと、Dartのコンパイラは「このクラスを継承できるのは、同じファイル内で定義された子たちだけだ」ということを完璧に把握します。つまり、世界が閉じている(=網羅性が保証される)のです。

実際にコードを書いてみましょう。

// 1. sealed classで状態を厳密に定義する
sealed class UiState {}

class Initial extends UiState {}

class Loading extends UiState {
final double progress;
Loading(this.progress);
}

class Success extends UiState {
final String data;
Success(this.data); // Successのときだけ「data」が存在する!
}

class ErrorState extends UiState {
final String message;
ErrorState(this.message); // Errorのときだけ「message」が存在する!
}

どうですか?これだけでもう、「Loadingなのにdataを触ってしまう」というミスが型レベルで不可能になりましたよね。

—

3. パターンマッチングによる極上の状態遷移(ビューの描画)

では、この `UiState` を使って、Dart 3の `switch` 式(文ではなく、値を返す式であることに注目!)によるパターンマッチングを書いてみます。

String renderView(UiState state) {
// Dart 3の switch は「式」なので、そのまま return できる!
return switch (state) {
Initial() => ‘初期状態です。ボタンを押してね。’,

// パターンマッチングで中身のプロパティを直接バインド(抽出)できる!
Loading(:var progress) => ‘読み込み中… (${(progress 100).toInt}%)’,

Success(data: var d) => ‘成功! 取得データ: $d’,

ErrorState(:var message) => ‘エラーが発生しました: $message’,
};
}

ここがプロの技:コンパイラの網羅性チェック

もし将来、機能追加で `Maintenance(String reason)` という新しい状態を `sealed class UiState` の下に増やしたとします。
その瞬間、上記の `switch` 式のコードはコンパイルエラーになります。「おい、`Maintenance`パターンの処理が抜けているぞ!」とDartのコンパイラが怒ってくれるのです。

これにより、「状態を追加したのに、UI側のハンドリングを忘れてアプリがクラッシュする」という開発者最大の恐怖が、コンパイル時に完全に排除されることになります。これが、Dartを掌握するということです。

—

4. 実践:有限ステートマシン(FSM)としてロジックを組む

単なる「画面の出し分け」だけでなく、状態がどう遷移するかという「ルール(ステートマシン)」にもこの恩恵を応用できます。

例えば、リクエストを送る・キャンセルする・やり直すといったイベント(Event)を処理するロジックを考えてみましょう。

// イベントの定義
sealed class UiEvent {}
class StartLoading extends UiEvent {}
class DataReceived(this.data) extends UiEvent {} // 簡易的な書き方
class Fail(this.error) extends UiEvent {}
class Reset extends UiEvent {}

// 状態遷移関数(これがステートマシンのコアエンジン)
UiState transition(UiState currentState, UiEvent event) {
return switch ((currentState, event)) {
// 1. Initialな状態で StartLoading が来たら Loading に移行
(Initial(), StartLoading()) => Loading(0.0),

// 2. Loadingな状態で DataReceived が来たら Success に移行
(Loading(), DataReceived(data: var d)) => Success(d),

// 3. Loadingな状態で Fail が来たら ErrorState に移行
(Loading(), Fail(error: var err)) => ErrorState(err),

// 4. どの状態からでも Reset が来たら Initial に戻る
(_, Reset()) => Initial(),

// 5. それ以外の無効な遷移は、現在の状態をそのまま維持(あるいは例外)
_ => currentState,
};
}

お気づきでしょうか? ここでDart 3のレコード(Records)機能 `(currentState, event)` との組み合わせが爆発的に威力を発揮しています。「現在の状態」と「起きたイベント」の2軸の組み合わせ(直積)を、パターンマッチングで一網打尽に美しく記述できています。

もし `if-else` でこれを書こうとすれば、ネストの深さに頭を抱え、条件の漏れに怯える夜を過ごすことになったはずです。それが、たったこれだけの宣言的なコードで完結してしまうのです。

—

陥りやすい文法エラーと注意点

ここで、初心者がよくハマりがちなポイントをいくつかシェアしておきますね。

1. `switch` “文” と `switch` “式” の混同

  • 従来の `switch (x) { case A: … }` は「文」です。
  • Dart 3のパターンマッチングは `switch (x) { A => …, B => … }` という「式(Expression)」です。アロー構文 (`=>`) を使い、最後にセミコロン `;` が必要になる点に注意してください。

2. 網羅性の罠(`_` の乱用禁止)

  • 最後に `_ => …`(デフォルトマッチ)を書いてしまうと、新しい状態を追加したときにコンパイラがエラーを教えてくれなくなります。`sealed class` を使うときは、原則として `_` に頼らずすべてのサブタイプを個別に列挙するのが、堅牢なコードを保つ秘訣です。

—

まとめ

いかがでしたでしょうか?
Dart 3の `sealed class` とパターンマッチングを使いこなせるようになると、あなたの書くコードは「動けばいいコード」から「構造的にバグが入り込めない高潔なコード」へと生まれ変わります。

  • 状態は `sealed class` で閉じ込める。
  • 状態の分岐・遷移は `switch` 式のパターンマッチングで宣言的に書く。
  • コンパイラの網羅性チェックを味方につける。

ここをクリアできれば、あなたのDartの基礎力・設計力は間違いなくワンランク上のステージに到達していますよ。
ぜひ、実際のプロジェクトのモジュールや状態管理で試してみてくださいね。それではまた!

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