🎯 什麼情境該想到我
當你「厭倦到處寫 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 |
io | io.Discard(寫入黑洞)、io.NopCloser(Close 什麼都不做) |
context | context.Background() —— ★ 永不取消、無 deadline 的「空 context」 |
net/http | http.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 的
??解不掉「一組相關的預設行為」,那時特例物件仍然贏。
🔗 相關工具
- 層超型 Layer Supertype(特例常繼承自該層的共同父類別)
- 值物件 Value Object(特例值可設計為值物件)
- 企業應用架構模式
- 引入特例 —— 重構手法
- 值物件 —— 特例物件通常是不可變值
- 服務樁 —— 測試替身,不是生產特例
- 純資料類別 —— 特例物件要有行為
- 層超型 —— 共用基礎型別
- 外鍵對應 —— 跨服務參照不存在時的處理