🎯 什麼情境該想到我
當你發現「程式各處都在對同一個特殊值(最常見的是 null,或某個代表『查無此人/預設客戶』的值)重複做相同的檢查與處理」的時候。
⚙️ 怎麼用(步驟 / 公式)
意圖:用一個「特例物件」把該特殊值的預設行為封裝起來(null 的情況即 Null Object 模式)。這樣呼叫端就能像對待一般物件那樣使用它,不必再到處寫 if (x == null) ...——把散落的特例處理集中成一個物件的固有行為。
小步安全進行:
- 為容器類別加一個「是否為特例」的檢查方法(如
isUnknown()),初期回傳固定值。 - 建立特例物件(如
UnknownCustomer),讓它的isUnknown()回傳 true,並讓來源在「查無」時回傳這個特例物件。 - 找出所有「檢查該特例值」的重複程式碼,逐一改用新的檢查方法。
- 對每一段特例處理,把它的預設回應搬進特例物件成為其方法(如特例客戶的名字固定回傳 “occupant”)。
- 每搬一段就執行測試,直到呼叫端不必再自行判斷特例。
★ 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+) |
io | io.Discard(寫入黑洞)、io.NopCloser(Close 什麼都不做) |
context | context.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()之類的探測方法。
🔗 相關工具
- 以多型取代條件式 Replace Conditional with Polymorphism(特例物件常是多型體系裡的一員)
- 搬移函式 Move Function(把特例的預設行為搬進特例物件)
- 重構
- 以多型取代條件式 —— 特例物件是它的一個特化用法
- 分解條件式 —— 只有一兩處檢查時的輕量解
- 純資料類別 —— 特例物件要有行為,不能只有預設值
- 中間人 —— 特例物件轉發過多時