🎯 什麼情境該想到我
當你「一個物件的某個(或某幾個)欄位,只在特定情況下才被賦值,其他時候都是空的」的時候——讀碼的人得不斷猜「它現在到底有沒有值」。
⚙️ 怎麼用(步驟 / 公式)
這味道是什麼徵兆:物件裡總是空著、只在某個演算法執行時才短暫有值的欄位,非常令人困惑——因為你直覺以為欄位隨時該有意義。這些欄位通常是某段複雜計算的中間狀態,卻被硬塞進物件的長期狀態裡。
通常用哪些重構手法對治:
- 提煉類別(Extract Class):把這些成群的暫時欄位與操作它們的邏輯,抽成一個專責的類別,讓它們有自己的家。
- 引入特例(Introduce Special Case / Null Object):針對「欄位沒值」的情況提供一個特例物件,取代到處的空值判斷。
- 搭配搬移函式把只在該情境用到的行為一起搬過去。
★ 定義與怎麼具體認出來
// ★ 訊號:欄位只在某些情況下有值,其餘時候是零值/nil,而且沒人知道何時該有值
type Order struct {
ID string
Items []Item
// ★ 只有在 calculateShipping() 執行期間才有意義
tempWeight float64
tempZone string
tempRate float64
}其他訊號:
- 欄位名帶
temp/cached/current/last(cached有時是合理的,見下)。 - ★ 一組欄位總是「一起有值或一起是零值」 → 它們是另一個概念(提煉類別)。
- ★ 某些方法一開始就在檢查
if o.tempWeight == 0 { return errNotReady }→
那個檢查就是暫時欄位的自白。 String()/MarshalJSON印出來的東西有一半是空的。
★★ 為什麼會痛:三個具體情境
// ① ★ 讀者無法判斷欄位何時有效
// 看到 o.tempRate 時,你必須讀完整個型別才知道「要先呼叫 calculateShipping()」
// → 這叫「時間耦合」(temporal coupling):方法有隱含的呼叫順序,而型別沒有表達它
// ② ★★ 併發下直接爆炸
// 兩個 goroutine 同時對同一個 Order 呼叫 calculateShipping()
// → tempWeight 互相覆蓋。go test -race 會抓到
// ③ ★ 序列化會帶上垃圾
// json.Marshal 印出 "temp_rate": 0 —— 下游要猜這是「零」還是「沒算」★ 對治的三條路
欄位只在一個方法的執行期間有意義 ──▶ ★ 改成區域變數 / 回傳值(最常見、最簡單)
一組欄位總是一起有值 ──▶ [[提煉類別]](它們是一個「計算中的情境」物件)
欄位有時有值有時沒有 ──▶ [[引入特例]](Null Object),或明確用 (值, bool)
// ★ 第一條路:把暫時狀態變成函式的參數與回傳值
func (o Order) Shipping() (Money, error) {
weight := o.totalWeight() // ★ 區域變數,生命週期清楚
zone, err := zoneFor(o.Address)
if err != nil { return Money{}, err }
return rateFor(weight, zone), nil // ★ 型別上完全沒有暫時欄位
}★ 第一條路成功率最高,而且順便讓方法變成純函式(好測、併發安全)。
如果暫時狀態太多、參數會爆 → 那就是第二條路(提煉類別)的訊號。
★★ 什麼時候這其實不是味道
| 情況 | 說明 |
|---|---|
| ★ 有明確生命週期的狀態機欄位 | conn.state、scanner.err —— 狀態機的欄位本來就只在某些狀態有意義,但那是設計,而且有明確的狀態轉換 |
| ★ memoization 快取 | cachedTotal *Money + 「第一次算完就存起來」—— ✅ 只要它是「算出來的」而非「別人塞的」,且失效規則明確 |
| ★ 錯誤累積欄位 | bufio.Scanner.err、bytes.Buffer 的內部狀態 —— Go 標準庫的既定模式(Scan() 回 false 後才問 Err()) |
| 建造中的物件 | Builder 的中間欄位;★ 但 builder 本身應該是獨立型別,不是在最終型別上開暫時欄位 |
| 零值即有效 | var b bytes.Buffer 直接可用——欄位是零值不代表「無效」 |
★★ 判準:型別有沒有「誠實地表達」這個欄位何時有效?
有(Scanner的契約寫明「Scan()回 false 後呼叫Err()」)→ 不是味道,那是協定。
沒有(讀者只能靠讀實作猜「要先呼叫哪個方法」)→ 這是味道。
★ 而「誠實表達」最便宜的方式就是註解——// 只有在 Prepare() 之後才有效。
那不能解決結構問題,但至少讓下一個人不會踩雷(見 註釋 的第④類)。
⚠️ Go 特有的兩個細節
// ① ★ 零值分不出「沒算」和「算出來是 0」
tempRate float64 // ★ 0 是「還沒算」還是「免運費」?
tempRate *float64 // ✅ nil = 沒算。⚠️ 代價:到處要判 nil
computed bool // ✅ 或明確用一個 flag(★ 但那是兩個欄位要同步的新問題)
// ② ★ json tag 的 omitempty 會遮蔽問題
tempRate float64 `json:"temp_rate,omitempty"` // ★ 0 就不輸出 —— 下游更難判斷🧪 我實際套用的紀錄
- (待填)
⚠️ 注意 / 什麼時候不適用
-
有些欄位天生就是選填(optional)且語意清楚,不算此味道;重點在「令人困惑的、演算法中間狀態」。
-
★ 狀態機欄位、memoization 快取、
bufio.Scanner.err式的錯誤累積,都不是味道——只要型別誠實表達了「何時有效」。 -
★★ 判準是「型別有沒有表達這個欄位何時有效」。讀者只能靠讀實作猜 → 是味道。
-
★ 併發下暫時欄位直接爆炸(兩個 goroutine 互相覆蓋)。
go test -race會抓到。 -
★ 零值分不出「沒算」和「算出來是 0」。用指標的代價是到處判 nil;用 flag 的代價是兩個欄位要同步。
-
★ 先試「改成區域變數/回傳值」,成功率最高,而且順便讓方法變純。
-
Builder 的中間狀態應該在獨立的 builder 型別上,不是在最終型別上開暫時欄位。
-
改不動的時候,至少寫上註解:「只有在 X() 之後才有效」。那不解決結構問題,但防止下一個人踩雷。
🔗 相關工具
- 資料泥團 Data Clumps(成群的暫時欄位常一起提煉成類別)
- 誇誇其談通用性 Speculative Generality(只為特殊情境預留的欄位)
- 重構
- 提煉類別 —— 一組暫時欄位是一個「計算情境」物件
- 引入特例 —— 欄位可能不存在時
- 以查詢取代暫時變數 —— 把存下來的改成算出來的
- 可變資料 —— 同一根因
- 純資料類別 —— 提煉出的情境物件要有行為
- 註釋 —— 改不動時至少誠實標註契約