はじめに
今回のテーマは「状態遷移」です。
多くのソフトウェアは、ユーザー操作や外部イベントを受け取りながら、内部の状態を変化させることで動作を実現しています。
しかし、状態遷移を設計しようとすると、次のような問いに必ず直面します。
- 状態とは、どこまでの情報を保持すべきか
- 過去の遷移の履歴を捨てても問題ないのか
- 状態は有限か、(事実上)無限か
- 不正な状態遷移を、いつ・どこで防ぐべきか
この記事では、状態を「過去の入力を圧縮したもの」と捉え、これらの判断を体系的に整理します。
その上で、
- 有限状態と無限状態
- 組み合わせ爆発への対処
- 可逆・非可逆な状態設計
- 動的な状態遷移と静的な状態遷移
といった観点から、Rust の型システムを活かした状態遷移の実装方法を具体例とともに解説します。
今回、並行処理や非同期処理を伴う状態遷移は説明しませんが、その基盤となる内容です。
状態とは「過去の入力の圧縮」
通常、システムにおける状態(State)とは、過去のすべての入力履歴を、将来の振る舞いを決定するために必要な最小限の情報へと集約した結果です。
つまり、状態は「これまで何が起きたか」の詳細ではなく、「これから何をすべきか」を決めるのに十分な情報だけを保持します。
現在の状態 $S_n$ は、初期状態 $S_0$ と入力列 $[I_0, I_1, ..., I_{n-1}]$ から導出します。
-
初期状態 $S_{0} \in S$
-
状態遷移関数 $f:S\times I\rightarrow S$
($S$ は状態の集合、$I$ は入力の集合を指します。)
$n$ 個目の入力における状態 $S_n$ は、漸化式 $S_{n}=f(S_{n-1},I_{n-1})$ で表現されます。
これを初期状態から展開すると
\begin{align*}
S_1 &= f(S_0, I_0) \\
S_2 &= f(S_1, I_1) = f(f(S_0, I_0), I_1) \\
S_3 &= f(S_2, I_2) = f(f(f(S_0, I_0), I_1), I_2) \\
&\vdots \\
S_n &= f(...f(f(S_0, I_0), I_1)..., I_{n-1})
\end{align*}
これは Rust では Iterator::fold を使って表現できます。
#[derive(Debug, Clone, Copy)]
enum TrafficLight {
Red,
Yellow,
Green,
}
#[derive(Debug, Clone, Copy)]
enum Input {
TimerExpired,
}
fn transition(state: TrafficLight, _input: Input) -> TrafficLight {
match state {
TrafficLight::Red => TrafficLight::Green,
TrafficLight::Green => TrafficLight::Yellow,
TrafficLight::Yellow => TrafficLight::Red,
}
}
fn main() {
let initial = TrafficLight::Red;
let inputs = [
Input::TimerExpired,
Input::TimerExpired,
Input::TimerExpired,
];
let final_state = inputs.iter().fold(initial, |state, &input| transition(state, input));
println!("{:?}", final_state); // Red (一周した)
}
状態の「有限性」と「無限性」
有限状態
状態の集合 $S$ が列挙可能なケースです。Rust では enum を使うことで、「不正な状態」をコンパイル時に排除できます。
// 状態が列挙可能
#[derive(Clone, Copy)]
enum DoorState {
Closed,
Opening,
Open,
Closing,
}
enum DoorInput {
PressButton,
FullyOpened,
FullyClosed,
}
fn door_transition(state: DoorState, input: DoorInput) -> DoorState {
match (state, input) {
(DoorState::Closed, DoorInput::PressButton) => DoorState::Opening,
(DoorState::Opening, DoorInput::FullyOpened) => DoorState::Open,
(DoorState::Open, DoorInput::PressButton) => DoorState::Closing,
(DoorState::Closing, DoorInput::FullyClosed) => DoorState::Closed,
// ワイルドカード( _ )でその他の入力は無視
_ => state, // Enum に要素を追加する際は注意
}
}
無限状態
座標やカウンタのように、状態のパターンが膨大なものです。Rust では構造体で表現しますが、メモリは有限であるため、厳密には無限状態を表現できません。
#[derive(Debug, Clone, Copy, PartialEq)]
struct PositionState {
// 状態数 = f64の値域 × f64の値域 = 2^64通り × 2^64通り
x: f64,
y: f64,
}
#[derive(Debug, Clone, Copy)]
enum Movement {
Up(f64),
Down(f64),
Left(f64),
Right(f64),
}
fn position_transition(state: PositionState, input: Movement) -> PositionState {
match input {
Movement::Up(distance) => PositionState { x: state.x, y: state.y + distance },
Movement::Down(distance) => PositionState { x: state.x, y: state.y - distance },
Movement::Left(distance) => PositionState { x: state.x - distance, y: state.y },
Movement::Right(distance) => PositionState { x: state.x + distance, y: state.y },
}
}
組み合わせ爆発への対処
状態が増えると、その組み合わせ(デカルト積)は指数関数的に増加します。これを一つの enum で管理しようとするのはアンチパターンです。
例えば、「ドアの状態」に加えて「鍵の状態」と「ライトの状態」を同時に管理しようとすると、システム全体の全状態 $S_{total}$ は各状態のデカルト積となります。
$$S_{total} = S_{door} \times S_{lock} \times S_{light}$$
すべての状態を単一の列挙型(enum)で表現しようとすると、コードが肥大化し、バグの温床となります。
// 状態が増えるたびにバリアントが倍増する
enum BadDoorState {
ClosedLockedLightOff,
ClosedLockedLightOn,
ClosedUnlockedLightOff,
ClosedUnlockedLightOn,
OpeningLightOff, // 鍵は開いているはずだが...
OpeningLightOn,
// ... 4(ドア) * 2(鍵) * 2(ライト) = 16パターンすべて書く必要がある
// さらに状態が増えると管理不能に
}
解決策
-
並列状態の分離
独立した状態(ライトの ON/OFF とドアの開閉と鍵の開閉)は、構造体のフィールドに分けます。
// 各状態を独立したEnumとして定義 enum DoorStep { Open, Closed } enum LockStep { Locked, Unlocked } enum LightStep { On, Off } // それらを組み合わせて構造体(State)を作る struct RoomState { door: DoorStep, lock: LockStep, light: LightStep, }状態が 3 つ(各 2 パターン)の場合、
BadDoorStateでは $2\times 2\times 2=8$ パターン必要ですが、分離後は各enumの定義 $2+2+2=6$ つの定義)だけで済みます。 -
階層状態
「閉まっている時だけ鍵の状態がある」という依存関係のように、ある状態のときだけ意味を持つ「詳細な状態」がある場合、
enumのバリアントにデータを持たせることで表現します。enum LockState { Locked, Unlocked } enum DoorState { Closed { lock: LockState }, // 閉まっている時だけ「鍵」の状態がある Opening, Open, Closing, }この設計により、「開いているのに鍵がかかっている」といった矛盾した状態を型システムの上で物理的に表現不可能にできます。
状態における「可逆圧縮」と「非可逆圧縮」
状態を「情報の圧縮」と捉えると、そのシステムが「過去を振り返る必要があるか」で設計が変わります。
-
非可逆な状態(一般的)
ほとんどのシステムの状態は非可逆圧縮です。現在の状態 $S_n$ から、どの入力 $I_{n-1}$ とどの過去の状態 $S_{n-1}$ から遷移したかを復元することはできません。
(ただし、不要な情報を捨ててメモリを節約できます。)#[derive(Debug, Clone, Copy, PartialEq)] struct Counter { count: i32, } // 入力の種類 #[derive(Debug, Clone, Copy)] enum Event { Increment, Decrement, } fn transition(state: Counter, event: Event) -> Counter { match event { Event::Increment => Counter { count: state.count + 1, }, Event::Decrement => Counter { count: state.count - 1, }, } } fn main() { let initial = Counter { count: 0 }; // 2つの異なる入力列 let history1 = [Event::Increment, Event::Increment]; let history2 = [ Event::Increment, Event::Increment, Event::Increment, Event::Decrement, ]; let state1 = history1.iter().fold(initial, |s, &e| transition(s, e)); let state2 = history2.iter().fold(initial, |s, &e| transition(s, e)); println!("{:?}", state1); // Counter { count: 2 } println!("{:?}", state2); // Counter { count: 2 } // 同じ状態だが、異なる履歴から到達した(非可逆) // `count: 2` という状態から、`[+1, +1]` だったのか `[+1, +1, +1, -1]` だったのか判別不可能 assert_eq!(state1, state2); } -
可逆な状態
入力履歴なしで、状態が可逆であるための条件は、遷移関数 $f$ が以下の性質を持つときです。
-
単射性: もし $(f(s_1,i_1) = f(s_2,i_2))$ ならば $((s_1,i_1) = (s_2,i_2))$。
つまり、異なる(状態, 入力)の組が同じ次状態を生んではいけない。
この単射性があると、現在の状態 ($S_{n}$) を見れば「直前にどの状態 ($S_{n-1}$) で、どの入力 ($I_{n-1}$) が来たか」を一意に復元できます。
先ほどの
TrafficLightの例のように、一方向に循環(Red -> Green -> Yellow -> Red -> ...)する場合も、可逆な圧縮です。 -
非圧縮な状態(履歴保持型)
状態を圧縮せずに管理する方法のひとつにイベントソーシングがあります。
イベントソーシングでは、(状態を変化させた)過去の入力列そのもの($[I_0, I_1, ..., I_{n-1}]$)を保持します。
この場合、過去の任意の時点の状態を完全に再現できますが、入力が増えるたびに状態のサイズは増大し続けます。
特に金融・医療・法律など「なぜそうなったか?」の説明が法的に要求される分野で採用されるパターンです。
#[derive(Debug, Clone, Copy, Default)]
struct Counter {
count: i32,
}
// (現在状態, イベント) -> 次の状態
impl Counter {
fn apply(self, event: Event) -> Self {
match event {
Event::Increment => Self { count: self.count + 1 },
Event::Decrement => Self { count: self.count - 1 },
}
}
}
struct EventStore {
// 過去の入力列
events: Vec<(Timestamp, Event)>,
}
impl EventStore {
// 任意の時点の状態を再構築
fn get_state_at(&self, timestamp: Timestamp) -> Counter {
self.events
.iter()
.filter(|(t, _)| *t <= timestamp)
.map(|(_, e)| e)
.fold(Counter::default(), |state, &event| state.apply(event))
}
}
イベントソーシングは、任意の時点の状態を再現する場合、初期状態からその時点までの遷移を全て適用しなければなりません。そのため、イベント(Input)が数万件になると、毎回最初から計算(fold)するのは重くなります。その場合は、「1000 件ごとのスナップショット(状態の保存)」を併用します。
- 最新のスナップショットをロード(例:9000 件目時点の状態)
- 残りのイベント(9001〜9500 件目)だけを apply する
これで、可逆性を保ちつつパフォーマンスも維持できます。
また、「Increment <--> Decrement」 のように、すべての操作(Input)に「逆操作」が定義できる状態遷移の場合、イベント履歴を用いて、現在の状態から巻き戻す形で任意の状態を再現することができます。
動的な状態遷移 vs 静的な状態遷移
動的な状態遷移とは、「実行時」の条件やデータによって、次の状態が決まる遷移のことです。対して、静的な状態遷移は、「コンパイル時」に遷移の順序が決まっているものを指します。
動的な状態遷移は Enum を、静的な状態遷移は Typestate パターンを使用して実装します。
使い分け
- Enum(動的遷移): ユーザー入力など、「次にどの状態になるか実行時までわからない」場合に使用。
- Typestate(静的遷移): ファイルの
Open → Read → Closeや、API の認証フローなど、「手続きの順序が厳密に決まっている」場合や「順序違反が致命的な」場合に使用。
Typestate パターン(型で状態を表現)
Typestate パターンは「特定の状態にある時だけ実行できるメソッド」を型システムの上で定義する手法です。
use std::marker::PhantomData;
// 各状態を「型」として定義 (ZST: Zero Sized Types)
struct Initial; // 初期状態
struct Running; // 稼働中
struct Finished; // 完了
// 状態を保持する構造体
struct Task<S> {
id: u32,
_state: PhantomData<S>, // メモリ消費なしで型情報だけを保持
}
// 静的な状態遷移の実装
// どの状態でも呼び出せる共通メソッド
impl<S> Task<S> {
fn id(&self) -> u32 { self.id }
}
// Initial 状態の時だけ呼べるメソッド
impl Task<Initial> {
fn new(id: u32) -> Self {
Task { id, _state: PhantomData }
}
// 次の状態 (Running) へ遷移する
fn start(self) -> Task<Running> {
Task { id: self.id, _state: PhantomData }
}
}
// Running 状態の時だけ呼べるメソッド
impl Task<Running> {
fn finish(self) -> Task<Finished> {
Task { id: self.id, _state: PhantomData }
}
}
fn main() {
// 正しい順序
let task = Task::new(101);
let running_task = task.start();
let _finished_task = running_task.finish();
// 【コンパイルエラー】順序違反
// let task = Task::new(102);
// let _ = task.finish(); // error: method `finish` not found in `Task<Initial>`
}
遷移メソッドが self(所有権)を受け取ることで、遷移前の古い状態を持つ変数は使用不能(Move 済み)になり、二重遷移や不正な状態操作をコンパイル時に防止できます。
おわりに
この記事では、状態を「過去の入力の要約(圧縮)」として捉える視点から、状態遷移の設計と実装を整理しました。
状態設計において重要なのは、
- 状態は有限か、事実上無限か
- 情報の圧縮は可逆か、非可逆か
- 状態遷移の制約を実行時に判断するのか、コンパイル時に保証するのか
といった選択を意識的に行うことです。
Rust では、
-
enumによる有限状態の表現 - 構造体による連続的・無限的な状態
-
Typestateパターンによる静的な遷移制約 - イベントソーシングによる履歴保持型の設計
を組み合わせることで、不正な状態を「書けない」設計を実現できます。
おまけ: Stateless は「状態がない」ではない
現代の設計で推奨される「Stateless」は、「状態が存在しない」という意味ではありません。
Stateless とは、状態管理の責任を、オブジェクトや関数の内部から外部(引数と戻り値)へ移す設計を指します。
この記事で扱ってきた状態遷移の多くは、基本的に Stateless な書き方にしています。
// Stateful(状態を内部に隠蔽)
struct StatefulCounter {
count: u32, // 隠れた内部状態
}
impl StatefulCounter {
fn increment(&mut self) -> u32 {
self.count += 1; // 内部状態を変更(副作用)
self.count
}
}
// 使用例
let mut counter = StatefulCounter { count: 0 };
let result1 = counter.increment(); // 1
let result2 = counter.increment(); // 2
// 同じ呼び出しでも結果が異なる(参照透過性がない)
// Stateless(状態を外部化)
struct StatelessCounter(i32); // タプル構造体でさらにシンプルに書くことも可能
fn increment(count: StatelessCounter) -> StatelessCounter {
StatelessCounter(count.0 + 1)
}
let s0 = StatelessCounter(0);
let s1 = increment(s0); // 1
let s2 = increment(s1); // 2
// もしくは、
// impl StatelessCounter {
// // 状態を持っているように見えますが、
// // 状態を消費して新しい状態を返すため、Stateless なアプローチ
// fn next(self) -> Self {
// Self(self.0 + 1)
// }
// }
// let state = StatelessCounter(0).next().next();
Stateless な設計は、今回扱わなかった「並行処理」や「非同期処理」を伴う状態遷移を扱う上で重要です。