🎯 什麼情境該想到我

當你「同一個方法的行為,取決於物件目前處在哪個狀態」的時候——
訂單(待付款→已付款→已出貨→已完成/已取消)、連線(斷線→連線中→已連線)、工作流審批、播放器。

⚙️ 怎麼用

意圖

允許物件在內部狀態改變時改變它的行為。物件看起來好像修改了它的類別。

它要消滅的是這個:

// ❌ 每個方法都重複一整套狀態判斷
func (o *Order) Pay() error {
    switch o.status {
    case "pending":   o.status = "paid"; return nil
    case "paid":      return ErrAlreadyPaid
    case "shipped":   return ErrAlreadyShipped
    case "cancelled": return ErrCancelled
    }
}
func (o *Order) Ship() error {
    switch o.status { /* ★ 又一整套 */ }
}
func (o *Order) Cancel() error {
    switch o.status { /* ★ 再一整套 */ }
}
// 加一個新狀態 → 要改「每一個方法」,而且很容易漏

★ 做法一:狀態物件(GoF 的原版)

type State interface {
    Pay(*Order) error
    Ship(*Order) error
    Cancel(*Order) error
    Name() string
}
 
type pending struct{}
func (pending) Name() string { return "pending" }
func (pending) Pay(o *Order) error    { o.setState(paid{}); return nil }
func (pending) Ship(*Order) error     { return ErrNotPaid }
func (pending) Cancel(o *Order) error { o.setState(cancelled{}); return nil }
 
type paid struct{}
func (paid) Name() string { return "paid" }
func (paid) Pay(*Order) error        { return ErrAlreadyPaid }
func (paid) Ship(o *Order) error     { o.setState(shipped{}); return nil }
func (paid) Cancel(o *Order) error   { o.setState(refunding{}); return nil }
 
type Order struct{ state State }
func (o *Order) Pay() error  { return o.state.Pay(o) }   // ★ 全部委派出去
func (o *Order) Ship() error { return o.state.Ship(o) }

★ 好處:加一個狀態 = 加一個型別,編譯器會告訴你少實作了哪些方法。
★ 壞處:狀態轉換的邏輯散落在各個狀態型別裡,「整張狀態圖」看不到。

★ 做法二:轉換表(實務上通常更好)

type Status string
type Event  string
 
// ★ 整張狀態機一目了然 —— 這是它最大的優勢
var transitions = map[Status]map[Event]Status{
    "pending":   {"pay": "paid", "cancel": "cancelled"},
    "paid":      {"ship": "shipped", "cancel": "refunding"},
    "shipped":   {"deliver": "completed"},
    "cancelled": {},
    "completed": {},
}
 
func (o *Order) Apply(e Event) error {
    next, ok := transitions[o.status][e]
    if !ok {
        return fmt.Errorf("%w: cannot %q from %q", ErrInvalidTransition, e, o.status)
    }
    o.status = next
    return nil
}

★ 轉換表版本的三個實質優勢:

  1. 整張狀態圖在一個地方,可以被人讀、被審查、被畫成圖
  2. 可以被測試窮舉(跑遍所有 (狀態, 事件) 組合)
  3. 可以從設定檔載入,甚至產生文件或 Mermaid 圖

代價:每個轉換的「副作用」(發信、扣款)要另外掛 hook,不如狀態物件那麼自然。

判準:轉換多而副作用簡單 → 轉換表;每個狀態的行為複雜 → 狀態物件。

TypeScript 的長相

// ★ 用判別聯集讓「不合法的狀態」在型別層面就不存在
type Order =
  | { status: "pending";   items: Item[] }
  | { status: "paid";      items: Item[]; chargeId: string }
  | { status: "shipped";   items: Item[]; chargeId: string; tracking: string }
  | { status: "cancelled"; reason: string };
 
function ship(o: Order): Order {
  if (o.status !== "paid") throw new Error(`cannot ship from ${o.status}`);
  return { ...o, status: "shipped", tracking: createLabel(o) };
  // ★ o.chargeId 在這裡保證存在 —— 型別縮窄幫你保證了
}

★ 這是 TS 相對 Go 的一個實質優勢(見 工具-判別聯集):
「只有 paid 狀態才有 chargeId」可以被型別系統強制執行。
Go 只能把所有欄位放在同一個 struct,用註解說明「這個欄位只在某些狀態下有效」。

真實案例

案例狀態
TCP 連線CLOSED / LISTEN / ESTABLISHED / …
CI/CD jobqueued / running / success / failed
訂單、退款、審批工作流見上
斷路器★ closed / open / half-open(見 工具-服務容錯設計)
詞法/語法分析器掃描狀態

★ State vs Strategy(必考)

狀態策略模式
誰決定用哪個★ 物件自己(依內部狀態)★ 外部(呼叫端注入)
會不會自己換★ 會(狀態之間互相轉換)❌ 不會
實作之間知不知道彼此★ 知道(pending 要知道 paid 存在)❌ 互相獨立
語義「我現在是什麼」「我要用哪種做法」

★ 結構完全一樣,差別在「誰決定」與「會不會自己轉換」。

🧪 我實際套用的紀錄

  • (待填)

⚠️ 注意 / 什麼時候不適用

  • ★ 狀態少(2–3 個)且轉換簡單 → 一個欄位 + 幾個 if 就好。 別過度設計。
  • ★ 持久化是真正的難題。狀態要存進資料庫,而狀態物件不能直接序列化——
    要存字串/列舉再重建。轉換表版本天然適合持久化(狀態本來就是字串)。
  • ★ 並行下的狀態轉換必須原子。兩個請求同時 Pay() → 可能都成功。
    → 需要樂觀鎖(version 欄位)或資料庫的條件更新:
    UPDATE orders SET status='paid' WHERE id=? AND status='pending',檢查 rows affected。
    (見 樂觀離線鎖、工具-交易與隔離等級)
  • 副作用與狀態轉換的原子性:「改狀態」和「發信」不在同一個事務裡 →
    可能改了狀態但沒發信,或發了信但狀態沒改。→ 用 工具-Transactional-Outbox-Pattern。
  • ★ 狀態爆炸:狀態數量是各維度的乘積時(付款狀態 × 出貨狀態 × 退款狀態),
    不要硬塞成一個狀態機——拆成多個正交的狀態機。
  • 要記錄歷史(「什麼時候從哪變到哪」)就要另外做事件日誌——狀態機本身只知道「現在」。
  • 不合法轉換的錯誤訊息要具體:cannot "ship" from "pending" 遠勝於 invalid state。

🔗 相關工具