🎯 什麼情境該想到我
當你用「一個型別碼欄位」(如 type = "engineer" / "manager",或一個列舉、數字)來區分物件是哪一種,而且不同種類其實還帶有各自不同的行為或欄位的時候。
⚙️ 怎麼用(步驟 / 公式)
意圖:把型別碼換成一個子類別階層——每一種類型變成一個子類別。這麼做打開了兩扇門:一是能用多型讓各類型自己管自己的行為(後續施以以多型取代條件式),二是能讓「只屬於某種類型」的欄位落在對應子類別,不再污染共用結構。
小步安全進行:
- 先封裝型別碼欄位(用存取器包起來),執行測試。
- 為某個型別值建立子類別;若無法直接用建構式建立子類,改用工廠函式回傳適當子類。
- 讓該子類別的型別碼取值方法回傳對應的固定值,並把建立這種類型的地方改為產生此子類別。
- 逐一為其他型別值重複,每加一個子類別就執行測試。
- 全部搬完後,把只屬於某類型的欄位與行為下移到對應子類別。
★★ Go 沒有繼承,所以這個手法要改名叫「以介面取代型別碼」
原書的做法是「為每個 type code 建一個子類別」。Go 沒有子類別,
而且嵌入沒有虛擬分派,所以照抄會失敗。Go 的對應做法是這樣:
// before:型別碼 + 到處的 switch
type Employee struct {
Name string
Type string // ★ "engineer" | "manager" | "salesman"
}
func (e Employee) Capitalisation() float64 {
switch e.Type {
case "engineer": return e.MonthlySalary * 12
case "manager": return e.MonthlySalary*12 + e.Bonus
…
}
}
// after:介面 + 各自的具體型別
type Employee interface {
Name() string
Capitalisation() float64
}
type Engineer struct{ name string; monthlySalary float64 }
func (e Engineer) Capitalisation() float64 { return e.monthlySalary * 12 }
type Manager struct{ name string; monthlySalary, bonus float64 }
func (m Manager) Capitalisation() float64 { return m.monthlySalary*12 + m.bonus }★ 真正的收益:Bonus 欄位只存在於 Manager 上。
原本 Employee 得帶著所有型別的欄位,而 engineer 的 Bonus 永遠是 0
(那正是 暫時欄位 這個壞味道)。
★ 機械化步驟(Go 版,每步可編譯)
- 先封裝型別碼:
Type改小寫 + 加Type() string方法(封裝變數)。 - 抽出介面:列出所有
switch e.Type的函式,它們的集合就是介面的方法。
⚠️ 介面要小——只放呼叫端真正用到的(見 工具-Go介面小而隱式)。 - 加工廠函式,把「型別碼 → 具體型別」的對應收攏到一個地方:
★ 這個 switch 是允許留下來的唯一一個——它是邊界,負責把外部資料轉成內部型別。func NewEmployee(kind string, name string, salary float64) (Employee, error) { switch kind { case "engineer": return Engineer{name, salary}, nil case "manager": return Manager{name, salary, 0}, nil } return nil, fmt.Errorf("unknown employee type %q", kind) } - 逐一把 switch 函式搬進各型別(以多型取代條件式),每搬一個測一次。
- 全部搬完後刪掉型別碼欄位。
★ 兩種變體:要不要保留「共同資料」
| 做法 | 什麼時候 | |
|---|---|---|
| 直接子型別 | Engineer/Manager 各自完整 | 共同欄位少 |
| ★ 嵌入共同部分 | type Engineer struct { employeeBase; … } | 共同欄位多。⚠️ 但不能靠嵌入做覆寫 |
| ★ 型別碼變成一個獨立型別 | Employee 持有一個 EmployeeType 介面欄位 | ★ 員工可以換職位時必須用這種——因為 Go 無法改變一個值的型別 |
// ★★ 第三種是實務上最重要的:物件的「型別」會變時
type Employee struct {
Name string
role Role // ★ Role 是介面,換職位 = 換這個欄位
}
type Role interface{ Capitalisation(monthlySalary float64) float64 }
func (e *Employee) Promote(r Role) { e.role = r } // ★ 如果用子型別,這件事做不到→ 這正是 狀態模式/策略模式 與「以子類取代型別碼」的分界線。
⚠️ Go 的三個代價
- ★ 沒有窮盡檢查。介面是開放的,「漏實作一種」是執行期才發現。
→ 用未匯出方法封印介面,或寫一個「所有 kind 都能被工廠建立」的測試。 - ★ 序列化變麻煩。
json.Unmarshal不知道要建哪個具體型別: 這是把型別碼換成型別最大的隱性成本——DB 與 API 邊界永遠需要一個 kind 欄位。type envelope struct { Kind string `json:"kind"` Data json.RawMessage `json:"data"` // ★ 先讀 kind,再決定 Unmarshal 成哪個型別 } - 總覽消失。原本一個 switch 看完所有行為,現在要開 N 個型別。
TypeScript
判別聯集直接勝出,而且序列化免費:
type Employee =
| { kind: 'engineer'; monthlySalary: number }
| { kind: 'manager'; monthlySalary: number; bonus: number };★ kind 欄位就是型別碼,而 TS 不要求你移除它——它同時是判別依據和序列化格式。
在 TS 裡這個重構通常不必做,見 工具-判別聯集。
🧪 我實際套用的紀錄
- (待填)
⚠️ 注意 / 什麼時候不適用
-
若型別碼在物件生命週期中會改變(今天是 A 明天變 B),子類別化就不適用(物件無法變更自己的類別)——此時改用以委派取代子類。
-
各類型之間若沒有行為差異、純粹只是標籤,引入子類階層是過度設計。
-
★★ Go 沒有子類別也沒有虛擬分派,原書步驟不適用。要用「介面 + 具體型別」。
-
★★ 物件的型別會變(員工升職、訂單狀態流轉)→ 不能用子型別。Go 無法改變一個值的型別;要把型別碼換成一個介面欄位(見上第三種變體)。
-
★ 序列化與 DB 邊界必須保留一個 kind 欄位。這是換成型別最大的隱性成本。
-
★ 沒有窮盡檢查。要靠封印介面或覆蓋率測試補。
-
只有一、兩處 switch 且型別很少變 → 保留型別碼。這是重手法。
-
★ 在 TypeScript 裡常常不該做——判別聯集已經解掉原始痛點。
-
共同邏輯很多時,嵌入不能替你做覆寫。要用函式欄位或明確轉發。
🔗 相關工具
- 以多型取代條件式 Replace Conditional with Polymorphism(有了子類別後,把依型別的 switch 改成多型)
- 提煉超類 Extract Superclass(反向思考:先有多個類別再抽共同父類)
- 重構
- 以多型取代條件式 —— 本手法的後續步驟
- 重複的 switch、暫時欄位 —— 觸發本手法的壞味道
- 狀態模式、策略模式 —— 型別會變動時的正確方向
- 工具-判別聯集 —— ★ TS 的答案
- 工具-Go嵌入機制 —— 為什麼不能靠嵌入做覆寫
- 工具-Go介面小而隱式 —— 抽出的介面該多小