🎯 什麼情境該想到我

當你「一個物件的某個(或某幾個)欄位,只在特定情況下才被賦值,其他時候都是空的」的時候——讀碼的人得不斷猜「它現在到底有沒有值」。

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

這味道是什麼徵兆:物件裡總是空著、只在某個演算法執行時才短暫有值的欄位,非常令人困惑——因為你直覺以為欄位隨時該有意義。這些欄位通常是某段複雜計算的中間狀態,卻被硬塞進物件的長期狀態裡。

通常用哪些重構手法對治:

  • 提煉類別(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() 之後才有效」。那不解決結構問題,但防止下一個人踩雷。

🔗 相關工具