Menu
Coddy logo textTech

ステートパターン

CoddyのSwiftジャーニー「オブジェクト指向プログラミング」セクションの一部。レッスン 49/57。

stateパターンを使うと、オブジェクトは内部状態が変化したときに振る舞いを変えられます。各状態はそれぞれ独自の型であり、何が起こるか、そして次にどの状態へ進むかを決定します:

protocol LightState {
    var name: String { get }
    func next() -> any LightState
}
struct Red: LightState { var name: String { "red" }; func next() -> any LightState { Green() } }
struct Green: LightState { var name: String { "green" }; func next() -> any LightState { Yellow() } }
struct Yellow: LightState { var name: String { "yellow" }; func next() -> any LightState { Red() } }

var light: any LightState = Red()
for _ in 1...4 {
    print(light.name)
    light = light.next()
}

出力:

red
green
yellow
red

状態を所有するオブジェクトは、現在の状態にリクエストを転送します。各状態はイベントをそれぞれ独自の方法で処理し、不正なアクションは、それを許可していない状態によって拒否されます。

protocol OrderState {
    var name: String { get }
    func pay() -> (any OrderState)?
    func ship() -> (any OrderState)?
}
struct New: OrderState { var name: String { "new" }; func pay() -> (any OrderState)? { Paid() }; func ship() -> (any OrderState)? { nil } }
struct Paid: OrderState { var name: String { "paid" }; func pay() -> (any OrderState)? { nil }; func ship() -> (any OrderState)? { Shipped() } }
struct Shipped: OrderState { var name: String { "shipped" }; func pay() -> (any OrderState)? { nil }; func ship() -> (any OrderState)? { nil } }

final class Order {
    private(set) var state: any OrderState = New()
    func handle(_ event: String) -> String {
        guard let next = event == "pay" ? state.pay() : state.ship() else { return "cannot \(event) when \(state.name)" }
        state = next
        return "now \(state.name)"
    }
}

let order = Order()
for e in ["ship", "pay", "pay", "ship"] { print(order.handle(e)) }

出力:

cannot ship when new
now paid
cannot pay when paid
now shipped

独自のデータを持たない状態が少数の場合は、switchを使ったenumのほうが同じ考えをより簡潔に表現できることが多く、コンパイラによってすべての状態が処理されていることもチェックされます。

enum Door {
    case open, closed, locked

    func handle(_ action: String) -> Door? {
        switch (self, action) {
        case (.closed, "open"): return .open
        case (.open, "close"): return .closed
        case (.closed, "lock"): return .locked
        case (.locked, "unlock"): return .closed
        default: return nil
        }
    }
}

var door = Door.closed
for a in ["lock", "open", "unlock", "open"] {
    if let next = door.handle(a) { door = next; print("\(a) -> \(door)") } else { print("cannot \(a)") }
}

出力:

lock -> locked
cannot open
unlock -> closed
open -> open

各状態に複数の振る舞いや独自のデータがある場合、状態型が役立ちます。いずれの場合も、各状態のルールが、オブジェクト全体に散在する if チェックではなく、1か所にまとまります。

// 散在: すべてのメソッドが状態をチェックする
func ship() { if status == "paid" { ... } else if status == "new" { ... } }
// ステートパターン: 各状態が自分自身で応答する
state = state.ship() ?? state
challenge icon

チャレンジ

簡単

Vending という enum の状態を、idle、hasCoin(Int)、soldOut のケースでモデル化します。handle(_ event:) は次の状態を返します。許可されていないイベントの場合は nil を返します。idle での coin <n> は hasCoin(n) に遷移し、hasCoin では金額に加算されます。hasCoin で少なくとも 150 の buy を受けると、idle に遷移します(販売成功)。hasCoin での refund は idle に遷移します。どの状態でも empty を受けると soldOut に遷移し、soldOut での refill は idle に遷移します。

用意されたコードはイベントを順番に適用し、それぞれの後に状態(idle、coin 100、sold out)または not allowed: <event> を出力します。

コードは Vending.swift に記述します。main.swift には用意された入出力コードが含まれており、編集できません。

自分で試してみよう

// 提供された入出力コード: そのままにしておく
var input: [String] = []
while let line = readLine() { input.append(line) }
var machine = Vending.idle
for event in input {
    if let next = machine.handle(event) {
        machine = next
        print(machine.label)
    } else {
        print("not allowed: \(event)")
    }
}
quiz icon腕試し

このレッスンには短いクイズがあります。レッスンを始めて解答し、進捗を記録しましょう。

オブジェクト指向プログラミングのすべてのレッスン

自分で練習してみよう: Swiftプレイグラウンド