🎯 什麼情境該想到我

當你遇到「一個函式既回傳一個值、又同時偷偷改變了系統狀態」的時候——你想再叫它一次拿值,卻怕它又造成一次副作用,於是不敢隨便呼叫。

⚙️ 怎麼用(步驟 / 公式)

意圖:遵循「命令與查詢分離」原則——任何有回傳值的函式都應該是純查詢(沒有可觀察的副作用),可以放心任意次數呼叫、任意調換順序。把「回傳值」與「改狀態」拆成兩個函式。

小步安全進行:

  1. 複製原函式,取一個純查詢用的名字(描述它回傳什麼)。
  2. 從新的查詢函式中,移除所有會造成副作用的動作,只留下計算與回傳。
  3. 執行測試。
  4. 找出每個呼叫原函式的地方,改成:先呼叫查詢函式取值,再呼叫原函式執行副作用(原函式改為不回傳值的修改器)。
  5. 每改一處就測一次,最後把原函式的回傳值移除。

★ before / after(Go)

// before:回傳值 + 偷偷寄了一封信(副作用藏在名字後面)
func (a *Account) TotalOutstandingAndSendBill() float64 {
    result := a.outstanding()
    sendBill(a)                       // ★ 呼叫兩次 → 寄兩封帳單
    return result
}
 
// after
func (a *Account) TotalOutstanding() float64 { return a.outstanding() }   // ★ 純查詢
func (a *Account) SendBill()                 { sendBill(a) }              // ★ 純命令

★★ 為什麼這條規則值得守:查詢可以被自由重排

純查詢可以…有副作用就不行
呼叫任意次數★ 每次都寄一封帳單
快取結果快取會跳過副作用
在測試裡隨意呼叫來檢查狀態★ 測試本身改變了狀態
平行呼叫需要鎖,而且可能有競爭
換成 以查詢取代暫時變數那個重構的前提就是「純」

★ 這條規則的實際價值是:讓你「不必讀實作就能安全使用一個函式」。
名字叫 Total() 卻會寄信,是最惡質的一種驚喜。

★ Go 的命名慣例把這件事寫在簽章上

// 查詢:有回傳值,沒有 error(或只有解析類的 error)
func (a *Account) Outstanding() float64
func (c Config) IsEnabled(f string) bool
 
// 命令:通常只回 error,或什麼都不回
func (a *Account) SendBill() error
func (c *Cache) Invalidate(k string)
 
// ★ 兩者都要時,Go 的做法是「明確地兩個回傳值」,而不是藏起來
func (q *Queue) Pop() (Item, bool)          // ★ Pop 本身就是修改 —— 名字有誠實說出來

★ Pop()、Next()、ReadString() 都同時查詢與修改,而且完全合理——
因為名字本身就宣告了「我會改變狀態」。規則不是「禁止」,是「不要意外」。

★★ Go 裡三種「合理的違規」

// ① 迭代器:Next() 必然同時前進與回報
for rows.Next() { … }           // database/sql
 
// ② ★ 快取:查詢帶著「填充快取」的副作用(memoization)
func (r *Repo) User(id string) (*User, error) {
    if u, ok := r.cache[id]; ok { return u, nil }
    u, err := r.load(id)
    if err == nil { r.cache[id] = u }       // ★ 副作用,但對呼叫端不可觀測
    return u, err
}
//    判準:★ 副作用「對呼叫端不可觀測」→ 這叫參照透明,不算違規
//    ⚠️ 但它不是併發安全的 —— map 需要鎖,否則 -race 會抓到
 
// ③ sync/atomic 的 CompareAndSwap
ok := counter.CompareAndSwap(old, new)   // ★ 必須是單一原子操作,硬拆開就壞了

★ 判準:副作用是「對呼叫端可觀測的狀態變化」嗎?
不可觀測(快取、延遲初始化、統計計數)→ 可以接受,但要註明併發安全性。
可觀測(寄信、寫 DB、發訊息)→ 一定要分開。

⚠️ 併發下這條規則會反咬你

// ★ 把查詢與修改分離之後,中間多了一個競爭窗口
if b.Balance() >= amount {          // ← 查詢
    b.Withdraw(amount)               // ← 修改:★ 兩者之間別人可能也提款了
}
 
// ✅ 併發下必須提供「單一原子操作」
func (b *Account) Withdraw(amount int) error {
    b.mu.Lock(); defer b.mu.Unlock()
    if b.balance < amount { return ErrInsufficient }
    b.balance -= amount
    return nil
}

★★ 這是本手法最重要的例外:在併發或分散式環境下,
「先查詢再修改」是 TOCTOU(check-then-act)競爭;必須合併成一個帶條件的命令。
用 go test -race 驗。

和 CQS / CQRS 的關係

CQS(Command-Query Separation)就是本手法:函式層級的規則
CQRS系統層級:讀模型與寫模型分開儲存、分開擴展。是完全不同量級的決策,不要因為讀了這頁就去做

🧪 我實際套用的紀錄

  • (待填)

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

  • 有些既定慣用法(如某些並行結構下的「取出並移除」)刻意把查詢與修改綁在一起,強拆反而破壞原子性——這類情況不適用。

  • 若拆開後兩個函式必須各自重跑昂貴的計算,需權衡效能。

  • ★★ 併發下不要盲目分離。「先 Balance() 再 Withdraw()」是 TOCTOU 競爭;要提供單一原子操作(見上)。

  • ★ 名字已經誠實宣告會修改狀態的,不算違規:Pop()、Next()、ReadLine()、CompareAndSwap()。

  • 對呼叫端不可觀測的副作用可以接受(快取、延遲初始化)——但要在文件裡註明是否併發安全。

  • 分離會讓呼叫端多寫一行。只有一個呼叫點、且不會被重複呼叫時,收益很小。

  • ★ 不要把 CQS 誤讀成 CQRS。前者是免費的函式設計習慣,後者是要付出雙寫、最終一致性、兩套模型代價的架構決策。

  • error 不算「修改」。回傳 (T, error) 的函式仍可以是純查詢。

🔗 相關工具