🎯 什麼情境該想到我
當你「同一個方法的行為,取決於物件目前處在哪個狀態」的時候——
訂單(待付款→已付款→已出貨→已完成/已取消)、連線(斷線→連線中→已連線)、工作流審批、播放器。
⚙️ 怎麼用
意圖
允許物件在內部狀態改變時改變它的行為。物件看起來好像修改了它的類別。
它要消滅的是這個:
// ❌ 每個方法都重複一整套狀態判斷
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
}★ 轉換表版本的三個實質優勢:
- 整張狀態圖在一個地方,可以被人讀、被審查、被畫成圖
- 可以被測試窮舉(跑遍所有 (狀態, 事件) 組合)
- 可以從設定檔載入,甚至產生文件或 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 job | queued / 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。