🎯 什麼情境該想到我

當你發現「程式各處都在對同一個特殊值(最常見的是 null,或某個代表『查無此人/預設客戶』的值)重複做相同的檢查與處理」的時候。

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

意圖:用一個「特例物件」把該特殊值的預設行為封裝起來(null 的情況即 Null Object 模式)。這樣呼叫端就能像對待一般物件那樣使用它,不必再到處寫 if (x == null) ...——把散落的特例處理集中成一個物件的固有行為。

小步安全進行:

  1. 為容器類別加一個「是否為特例」的檢查方法(如 isUnknown()),初期回傳固定值。
  2. 建立特例物件(如 UnknownCustomer),讓它的 isUnknown() 回傳 true,並讓來源在「查無」時回傳這個特例物件。
  3. 找出所有「檢查該特例值」的重複程式碼,逐一改用新的檢查方法。
  4. 對每一段特例處理,把它的預設回應搬進特例物件成為其方法(如特例客戶的名字固定回傳 “occupant”)。
  5. 每搬一段就執行測試,直到呼叫端不必再自行判斷特例。

★ before / after(Go)

// before:「未知客戶」這件事在五個地方各自被檢查一次
if site.Customer == "unknown" { customerName = "occupant" } else { customerName = site.Customer.Name }
if site.Customer == "unknown" { plan = registry.BillingPlans["basic"] } else { plan = site.Customer.BillingPlan }
if site.Customer == "unknown" { weeksDelinquent = 0 } else { … }
// ★ 加一個「未知客戶要怎麼處理」的規則,就要找出全部五處
 
// after:做一個「未知客戶」型別,它自己知道該怎麼回答
type Customer interface {
    Name() string
    BillingPlan() Plan
    IsUnknown() bool
}
 
type unknownCustomer struct{}
func (unknownCustomer) Name() string      { return "occupant" }    // ★ 特例行為集中在這裡
func (unknownCustomer) BillingPlan() Plan { return basicPlan }
func (unknownCustomer) IsUnknown() bool   { return true }

呼叫端變成:

customerName := site.Customer().Name()      // ★ 不必問「是不是未知」

★ 這就是 Null Object 模式。 判準:同一個特例檢查出現三次以上,而且每次的回應都固定。

★★ Go 最漂亮的一招:nil 接收者上的方法

Go 允許在 nil 指標上呼叫方法(只要方法不解引用它)。實測 Go 1.26.4(2026-09-25):

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()
}
(*node)(nil).Sum()                 // → 0
(&node{1, &node{2, nil}}).Sum()    // → 3

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

★ 標準庫的三個實例

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

⚠️ 什麼時候不該用特例物件

情況為什麼
★ 「不存在」是真的錯誤找不到使用者該回 ErrNotFound,不是回一個假使用者。用特例物件會把 bug 變成靜默的錯誤資料
特例的回應不固定有時要 "occupant" 有時要空字串 → 特例物件只能選一種,剩下的還是要判斷
只有一、兩處檢查直接 if 更清楚
特例物件會被寫入unknownCustomer.SetName() 要怎麼辦?靜默忽略是最糟的選擇

★★ 最危險的誤用:用 Null Object 取代錯誤處理。
db.FindUser(id) 找不到卻回傳 unknownCustomer{} → 呼叫端以為成功,
錯誤被推到很遠的地方才浮現(可能是一筆 0 元的帳單)。
Go 的慣例是回 (nil, ErrNotFound),讓呼叫端明確決定。

TypeScript

TS 有更輕的工具,常常不需要特例物件:

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

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

🧪 我實際套用的紀錄

  • (待填)

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

  • 若各處對這個特殊值的處理其實各不相同(沒有共通的預設行為),封裝成單一特例物件反而不合適。

  • 只有一兩處檢查、不構成重複時,殺雞用牛刀。

  • ★★ 不要用特例物件取代錯誤處理。 找不到資料就回 ErrNotFound;回一個假物件會讓 bug 靜默地流到下游。

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

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

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

  • 值接收者不能是 nil:func (l Logger) Log() 在 var l *Logger; l.Log() 時直接 panic(解引用)。

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

  • 特例物件會讓「特例」變得不可見。日誌裡看到 occupant 時,沒人知道那是「真的叫 occupant」還是「查不到」。→ 保留 IsUnknown() 之類的探測方法。

🔗 相關工具