🎯 什麼情境該想到我
當你遇到「一個函式既回傳一個值、又同時偷偷改變了系統狀態」的時候——你想再叫它一次拿值,卻怕它又造成一次副作用,於是不敢隨便呼叫。
⚙️ 怎麼用(步驟 / 公式)
意圖:遵循「命令與查詢分離」原則——任何有回傳值的函式都應該是純查詢(沒有可觀察的副作用),可以放心任意次數呼叫、任意調換順序。把「回傳值」與「改狀態」拆成兩個函式。
小步安全進行:
- 複製原函式,取一個純查詢用的名字(描述它回傳什麼)。
- 從新的查詢函式中,移除所有會造成副作用的動作,只留下計算與回傳。
- 執行測試。
- 找出每個呼叫原函式的地方,改成:先呼叫查詢函式取值,再呼叫原函式執行副作用(原函式改為不回傳值的修改器)。
- 每改一處就測一次,最後把原函式的回傳值移除。
★ 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)的函式仍可以是純查詢。
🔗 相關工具
- 移除旗標參數 Remove Flag Argument(同屬讓函式職責更明確的手法)
- 提煉函式 Extract Function(拆分過程常需把查詢部分提煉出來)
- 重構
- 以查詢取代暫時變數 —— 它的前提就是「純查詢」
- 提煉函式 —— 切點的判準:要嘛查詢要嘛命令
- 移除旗標參數 —— 另一種「一個函式做兩件事」
- 可變資料 —— 副作用的源頭