🎯 什麼情境該想到我

當你「厭倦到處寫 null 檢查、想讓『特殊情況』自己有預設行為」的時候。

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

意圖:用一個代表「特殊情況」的子類別取代 null,把該情況的行為封裝進去。

做法要點:

  • 為特殊情況(如查無資料、未知客戶)建立子類別,實作與正常物件相同的介面。
  • 在方法裡回傳合理的預設行為,讓呼叫端可一視同仁地呼叫,不必先判空。
  • Null Object(空物件)是其常見特例,代表「什麼都不做/中性值」。

★★ 它就是 Null Object,而 Go 有一個特別漂亮的做法

// ★★ Go 允許在 nil 指標上呼叫方法(只要方法不解引用它)
type Logger struct{ prefix string }
func (l *Logger) Log(msg string) {
    if l == nil { return }              // ★ nil 就什麼都不做
    fmt.Println(l.prefix, msg)
}
var l *Logger
l.Log("安全")                            // ★★ 不 panic,什麼都不印
// ★ 更漂亮的:遞迴資料結構的「空」
type node struct{ v int; next *node }
func (n *node) Sum() int {
    if n == nil { return 0 }            // ★★ nil 就是空清單,不需要 EmptyList 型別
    return n.v + n.next.Sum()
}

★★ 這讓 Go 能用 nil 本身當特例物件,不必新增型別。
⚠️ 但必須在文件裡寫明「nil 接收者是合法的」——
★ 否則下一個人會加 nil 檢查,或改成值接收者(★★ 值接收者對 nil 指標直接 panic)。

★ 標準庫的四個實例

特例物件
log/slog★ slog.DiscardHandler —— 丟掉所有輸出,Enabled 對所有 Level 回 false
ioio.Discard(寫入黑洞)、io.NopCloser(Close 什麼都不做)
contextcontext.Background() —— ★ 永不取消、無 deadline 的「空 context」
net/httphttp.NoBody
// ★★ 最實用的應用:讓「不記 log」不需要在每個呼叫點判斷
func New(logger *slog.Logger) *Service {
    if logger == nil { logger = slog.New(slog.DiscardHandler) }   // ★ 一次搞定
    return &Service{log: logger}
}

★★ 最危險的誤用:用它取代錯誤處理

// ❌★★ 找不到使用者卻回一個「空使用者」
func (r *Repo) Get(ctx context.Context, id ID) *User {
    u, err := r.load(ctx, id)
    if err != nil { return &unknownUser{} }     // ★★ 呼叫端以為成功了
}
// → ★ 後果:① 一筆 0 元的帳單 ② 權限檢查通過(因為 unknownUser 的權限是「空」)
//          ③ ★★ 錯誤被推到很遠的地方才浮現,而且看不出來源

★★ Go 的慣例是回 (nil, ErrNotFound),讓呼叫端明確決定。
★ 判準:「不存在」是「一種正常狀態」還是「一個錯誤」?

★ 正常狀態 → 特例物件合理
  「這個網站的訪客沒有登入」→ 一個 anonymousUser 是合理的
★★ 錯誤 → 回 error
  「查詢 ID=42 的使用者卻找不到」→ 那是資料不一致或呼叫錯誤

★★ 四個使用前的檢查

① ★★ 特例的回應必須「固定」
   ★ 有時要回 "occupant" 有時要回空字串 → 特例物件只能選一種,剩下的還是要判斷
② ★★ 特例物件必須是唯讀的
   ★ unknownCustomer.SetName() 要怎麼辦?靜默忽略是最糟的選擇
   → ★ 若特例物件會被寫入,這個模式不適用
③ ★★ 特例必須「可被探測」
   ★ 日誌裡看到 "occupant" 時,沒人知道那是真的叫 occupant 還是查不到
   → ✅ 保留 IsUnknown() 之類的方法,並在關鍵路徑上記錄
④ ★ 只有一兩處檢查就別引入型別
   → ★ 判準:三次以上,且每次回應相同

★★ ③ 最常被忽略:特例物件讓「特例」變得不可見,
於是「為什麼這筆帳單是 0 元」查不出來。

★★ TypeScript 有更輕的工具,但解不掉全部

const name = site.customer?.name ?? 'occupant';     // ★ 一行解決「取值」

★★ 但 ?? 解不掉「一組相關的預設行為」:
customer.plan、customer.discount、customer.creditLimit 都要預設值時,
?? 要在五個地方各寫一次——★ 那時特例物件仍然贏。

★ 兄弟辨析

差別
值物件★ 特例物件通常是不可變的值
層超型共用基礎型別
服務樁★ 測試用的替身(★ 不是生產用的特例)
純資料類別★ 特例物件要有行為,不能只有預設值
引入特例★ 重構手法:怎麼引入它

🧪 我實際套用的紀錄

  • (待填)

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

  • 若各處對「特殊情況」的期望行為不一致,硬塞單一預設反而會藏 bug。

  • 只有零星一兩處判空時,直接判斷可能更清楚,不需引入子類別。

  • 要小心別讓特例默默吞掉真正該報錯的情況。

  • ★★ 不要用特例物件取代錯誤處理。 找不到資料回一個假物件 → 呼叫端以為成功,錯誤被推到很遠才浮現(一筆 0 元帳單、權限檢查通過)。

  • ★★ 判準:「不存在」是正常狀態還是錯誤? 正常(未登入訪客)→ 特例物件;錯誤(查 ID 找不到)→ 回 error。

  • ★★ 特例必須「可被探測」(保留 IsUnknown()),否則「為什麼這筆是 0 元」查不出來。

  • ★★ Go 的 nil 接收者技巧必須寫進文件。 「nil 是合法接收者」不是常識,而且改成值接收者就 panic。

  • ★ 特例的回應必須固定。 同一個特例在不同呼叫點要不同回應 → 不適用。

  • ★ 可寫入的特例物件沒有好答案(SetX() 該 panic、靜默忽略、還是回 error?)。

  • ★ 一兩處檢查就別引入型別。 判準是三次以上且回應相同。

  • TS 的 ?? 解不掉「一組相關的預設行為」,那時特例物件仍然贏。

🔗 相關工具