🎯 什麼情境該想到我
當你發現「兩個(或多個)類別有相似的欄位與行為」的時候——它們各自維護著幾乎一樣的資料與方法,彼此其實共享著某種共同概念。
⚙️ 怎麼用(步驟 / 公式)
意圖:抽出一個共同的超類別,把這些類別重複的欄位與行為上移到父類別集中管理,讓子類別只保留各自的差異。這是「消除重複」在類別層級的做法。
小步安全進行:
- 建立一個空的超類別,讓原本那幾個類別繼承它。
- 執行測試。
- 用欄位上移、函式上移、建構式本體上移,把共通的欄位、方法、建構邏輯逐一搬進超類別。
- 每搬一項就執行一次測試。
- 檢視子類別剩下的部分,若還有可協調的差異(如方法名不同但意圖相同),統一後再繼續上移。
- 檢查使用端,看能否改為面向新超類別的型別編程。
★★ Go 沒有超類別,所以這個手法在 Go 有三種不同的化身
原書:「兩個類別有相似之處 → 建一個超類,把共同部分搬上去。」
Go 沒有 extends,而且嵌入沒有虛擬分派。所以依「共同的是什麼」分成三條路:
// before:兩個型別有一部分很像
type Department struct { name string; staff []Employee }
func (d Department) TotalAnnualCost() float64 { … }
func (d Department) Name() string { return d.name }
func (d Department) HeadCount() int { return len(d.staff) }
type Employee struct { name string; id, annualCost float64 }
func (e Employee) AnnualCost() float64 { return e.annualCost }
func (e Employee) Name() string { return e.name }① 共同的是「行為契約」→ 抽介面(最常見,也最 Go)
type Party interface {
Name() string
AnnualCost() float64
}
// ★ 兩個型別一行都不用改 —— Go 的介面是隱式滿足的,抽介面是「零風險重構」★ 這是 Go 相對 Java 最大的優勢:抽介面不需要碰既有型別,
而且介面可以定義在使用它的那一側(見 工具-針對介面編程)。
⚠️ 但反過來說:介面不能放共同的「實作」,只能放方法簽章。
② 共同的是「資料 + 一段實作」→ 嵌入一個共用 struct
type named struct{ name string } // ★ 未匯出,純粹是實作細節
func (n named) Name() string { return n.name }
type Department struct { named; staff []Employee }
type Employee struct { named; id string; annualCost float64 }
d.Name() // ★ 直接可用,不必寫轉發⚠️ 兩個硬限制:
// ★ ① 沒有虛擬分派 —— 共用方法無法呼叫到「子型別」的覆寫
func (n named) Greet() string { return "hi " + n.Name() } // ★ n 永遠是 named
// ★ ② 嵌入欄位的名字就是型別名,會出現在 JSON 與反射裡(未匯出的嵌入則不會被 json 匯出)③ 共同的是「演算法骨架,細節不同」→ 函式欄位或介面參數
// ★ Go 沒有 Template Method,差異點要用「注入」表達
type CostCalculator struct {
base func() float64 // ★ 差異點
overhead float64
}
func (c CostCalculator) Total() float64 { return c.base() + c.overhead }★ 判斷用哪一條路
| 共同的是什麼 | Go 的做法 |
|---|---|
| 只有方法簽章 | ★ 抽介面(零風險,不碰既有型別) |
| 欄位 + 簡單的存取方法 | 嵌入未匯出的共用 struct |
| 一段演算法,中間有差異 | 函式欄位或介面參數注入差異點 |
| ★ 只是巧合相似 | 什麼都不要做(見下) |
⚠️ 最大的風險:把巧合相似變成強制耦合
// ★ 兩個型別都有 Name() 和 Cost(),但一個是 HR 的員工、一個是會計的科目
// 抽出共用超類之後 —— HR 的需求變動會逼著會計那邊一起改★★ 這是 DRY 最常見的誤用:「長得像」不等於「是同一件事」。
判準:未來的需求變動會同時影響兩者嗎? 不會 → 不要抽,重複比錯誤的抽象便宜。
→ 見 工具-DRY原則、工具-從相似定義提煉抽象。
機械化步驟(Go,介面版)
- 列出兩個型別簽章完全相同的方法(名字、參數、回傳值都要一致)。
⚠️ 簽章不同就不能直接抽——先用 改變函式宣告 對齊,一次一個,每次測試。 - 宣告介面,放在使用它的 package。
- 把用到這兩個型別的函式改成收介面。兩個型別不用改。
- 若有共同的「實作」也要共用,才進一步做嵌入(第②條路)。
🧪 我實際套用的紀錄
- (待填)
⚠️ 注意 / 什麼時候不適用
-
若這些類別的相似只是「表面巧合」、概念上並非同類,硬抽超類會製造出牽強的繼承關係;此時改用提煉類別+委派(組合)較恰當。
-
相較之下,「先有共同父類再分化子類」與本手法方向相反,視情境擇一。
-
★★ 巧合相似不要抽。「長得像」不等於「是同一件事」;抽了之後兩邊的需求變動會互相綁死。重複常常比錯的抽象便宜。
-
★★ Go 的嵌入沒有虛擬分派,共用 struct 的方法無法呼叫到外層型別的覆寫。原書「超類呼叫子類覆寫的方法」這個模式在 Go 不成立。
-
★ 介面只能共用簽章,不能共用實作。要共用實作必須嵌入,而嵌入會把被嵌入型別的匯出方法全部攤出來。
-
抽介面前要先對齊簽章。
Name() string和GetName() (string, error)不能直接抽——先做 改變函式宣告。 -
Go 的介面該定義在消費方。為兩個型別做一個「共用介面」然後放在它們的 package 裡,是 Java 直覺,在 Go 通常是反模式。
-
不要為了「以後可能有第三個實作」而抽(誇誇其談通用性)。等第三個真的出現再抽。
🔗 相關工具
- 函式上移 Pull Up Method(提煉超類過程中把共同方法搬上去)
- 以子類取代型別碼 Replace Type Code with Subclasses(反向:由一個類別分化出子類階層)
- 重構
- 函式上移 —— 抽出超類後把共同方法搬上去;Go 裡對應「搬進嵌入的 struct」
- 以子類取代型別碼 —— 反方向:從一個型別長出多個
- 提煉類別 —— 抽出的是「協作者」而非「共同父型別」時
- 異曲同工的類別 —— 觸發本手法的壞味道
- 工具-DRY原則、工具-從相似定義提煉抽象 —— 「該不該抽」的判準
- 工具-針對介面編程、工具-Go介面小而隱式 —— Go 的介面該放在哪一邊
- 工具-優先組合而非繼承 —— 為什麼 Go 選了這條路