コーディング · デザインパターン
State パターンとは?例でわかりやすく解説
結論
State は振る舞いに関するデザインパターンで、内部状態が変わるとオブジェクトの振る舞いが変わり、まるでクラスが変わったように見えるようにします。各状態は同じインターフェースを実装する独立したクラスになり、Context と呼ばれる主役のオブジェクトは、作業を現在の State オブジェクトに委譲します。遷移は、その State オブジェクトを差し替えることで起こり、すべてのメソッドで status フィールドを調べる長い switch や if の連鎖がなくなります。
目的と解決する問題
目的:内部状態が変わったときに、オブジェクトが振る舞いを変えられるようにすることです。オブジェクトは、クラスが変わったように見えます。State は Gang of Four(GoF)の書籍にある振る舞いパターンの 1 つで、古典的な例は、リッスン中、接続確立済み、クローズ済みで振る舞いが異なる TCP 接続です。
オンラインの注文を考えてみましょう。注文は、保留中、支払い済み、発送済み、キャンセル済みのいずれかになります。支払いができるのは保留中のときだけです。発送ができるのは支払い済みのときだけです。キャンセルは保留中か支払い済みのときは許可されますが、発送後はできません。最初の実装では、status 文字列を保持し、すべてのメソッドが、それに対する switch から始まります。4 つの操作と 4 つの状態なら、4 つのメソッドに 16 個の case が散らばります。「返金済み」のような新しい状態を追加するには、すべてのメソッドを編集しなければならず、どれかを見落としやすくなります。
State パターンでは、各ステータスが、そのステータスで各操作が何を意味するかを知っているクラスになります。注文は現在の State オブジェクトへの参照を持ち、すべての操作をそれに転送します。
構造と登場する役割
| 役割 | 責務 |
|---|---|
| Context | クライアントが使うオブジェクト。現在の State オブジェクトを保持し、状態に依存する振る舞いをそれに委譲する |
| State | 状態によって振る舞いが変わる操作を宣言するインターフェース |
| Concrete state | 1 つの状態の振る舞いを実装し、次にどの状態になるかを決める |
遷移は、状態自身が決める(下の例のように、各状態が Context に次の状態を設定する)ことも、Context が遷移表を使って決めることもできます。状態に決めさせると、関連するロジックがまとまります。中央の表を使うと、すべての遷移が 1 か所で見えるようになります。
TypeScript での実装
基底クラスが、デフォルトの振る舞い(操作を拒否する)を提供します。各 Concrete state は、その状態で有効な操作だけをオーバーライドします。
abstract class OrderState {
abstract readonly name: string;
pay(order: Order): void { this.reject("pay"); }
ship(order: Order): void { this.reject("ship"); }
cancel(order: Order): void { this.reject("cancel"); }
protected reject(action: string): never {
throw new Error("Cannot " + action + " an order that is " + this.name);
}
}
class Pending extends OrderState {
readonly name = "pending";
pay(order: Order) { order.setState(new Paid()); }
cancel(order: Order) { order.setState(new Cancelled()); }
}
class Paid extends OrderState {
readonly name = "paid";
ship(order: Order) { order.setState(new Shipped()); }
cancel(order: Order) { order.setState(new Cancelled()); } // refund here
}
class Shipped extends OrderState { readonly name = "shipped"; }
class Cancelled extends OrderState { readonly name = "cancelled"; }
class Order {
private state: OrderState = new Pending();
setState(next: OrderState) { this.state = next; }
get status() { return this.state.name; }
pay() { this.state.pay(this); }
ship() { this.state.ship(this); }
cancel() { this.state.cancel(this); }
}
const order = new Order();
order.pay();
order.ship();
console.log(order.status); // shipped
try { order.cancel(); } catch (e) { console.log((e as Error).message); }
// Cannot cancel an order that is shipped「返金済み」の状態を追加するには、新しいクラスを 1 つ書き、そこへ遷移できる状態だけを変更します。Order 自体は変わりません。
導入前と導入後
同じルールを、status フィールドと switch 文で書いた例です。状態が少ないうちはこれでも動作しますが、すべてのメソッドが、状態の一覧を丸ごと繰り返しています。
type Status = "pending" | "paid" | "shipped" | "cancelled";
class OrderBefore {
status: Status = "pending";
ship() {
switch (this.status) {
case "paid": this.status = "shipped"; break;
default: throw new Error("Cannot ship an order that is " + this.status);
}
}
// pay() and cancel() repeat the same switch
}
const o = new OrderBefore();
try { o.ship(); } catch (e) { console.log((e as Error).message); }
// Cannot ship an order that is pending小さなケースなら、この書き方で全く問題なく、むしろ読みやすいくらいです。TypeScript では、Status を union 型にすれば、すべての case が処理されているかをコンパイラがチェックできます。状態ごとの振る舞いが 1、2 行を超えて増えてきたら、状態クラスに移行しましょう。
使うべき場面と避けるべき場面
- オブジェクトが状態によって大きく異なる振る舞いをし、同じステータスチェックが多くのメソッドに現れるときに使います。
- 状態と遷移が時間とともに変わり、各状態のルールを 1 か所にまとめたいときに使います。
- 注文、文書、チケットのようなワークフロー、プロトコルの接続、ゲームのキャラクター、メディアプレーヤーのような UI コンポーネントに使います。
- 状態が 2、3 個しかなく、振る舞いも単純なら使いません。enum や union 型と
switchのほうが簡単です。 - 入れ子や並列の状態を持つ複雑なマシンには、手書きのクラスの代わりに、ステートマシンのライブラリやステートチャートの記法を検討してください。
実際の利用例
- ネットワークプロトコルの実装は、GoF の書籍で使われている例と同じく、リッスン中、確立済み、クローズ済みのような接続状態をモデル化します。
- XState は、ステートマシンとステートチャート用の JavaScript / TypeScript ライブラリで、UI ロジックやワークフローに使われます。
- W3C の SCXML 仕様は、ステートチャートのための XML 形式を定義しています。
- ゲーム開発では、キャラクター(待機、走る、ジャンプ)に State オブジェクトを使い、各状態が入力を異なる方法で処理します。
State と Strategy などのパターンの違い
| パターン | State パターンとの違い |
|---|---|
| Strategy パターン | クラスの構造はほぼ同じです。Strategy は通常クライアントが選び、自分では変わりません。State は、オブジェクト自身の動作の結果として、互いに入れ替わります。 |
| Flyweight パターン | フィールドを持たない State オブジェクトは、Flyweight や定数として、すべての Context で共有できます。 |
| Singleton パターン | 状態を持たない State オブジェクトは、共有される 1 つのインスタンスであることがよくあります。 |
| Memento パターン | Memento は、後で復元するためにオブジェクトのデータを保存します。State は、オブジェクトがどの振る舞いを使うかを変えます。 |
よくある質問
State と Strategy の違いは何ですか?
どちらも、差し替え可能なオブジェクトに振る舞いを委譲します。Strategy では、通常クライアントがアルゴリズムを選び、それは固定されたままです。State では、State オブジェクトが互いを知っており、通常の動作の一部として Context を新しい状態に切り替えます。
State パターンは有限状態機械と同じですか?
State パターンは、オブジェクト指向のコードで有限状態機械を実装する方法の 1 つです。各状態がクラスになり、各遷移が現在の State オブジェクトを差し替えます。マシンは、遷移表やライブラリを使って実装することもできます。
次の状態は、Context と State のどちらが決めるべきですか?
どちらでも構いません。状態が決める場合は、各状態のロジックがまとまります。Context や遷移表が決める場合は、すべての遷移が 1 か所で見え、状態同士が互いを知る必要がなくなります。
State オブジェクトは共有できますか?
はい、自前のデータを持たなければ可能です。その場合、各状態のインスタンスを 1 つずつ、すべての Context オブジェクトで共有でき、メモリの確保を節約できます。