🎯 什麼情境該想到我

當你發現「兩個(或多個)類別有相似的欄位與行為」的時候——它們各自維護著幾乎一樣的資料與方法,彼此其實共享著某種共同概念。

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

意圖:抽出一個共同的超類別,把這些類別重複的欄位與行為上移到父類別集中管理,讓子類別只保留各自的差異。這是「消除重複」在類別層級的做法。

小步安全進行:

  1. 建立一個空的超類別,讓原本那幾個類別繼承它。
  2. 執行測試。
  3. 用欄位上移、函式上移、建構式本體上移,把共通的欄位、方法、建構邏輯逐一搬進超類別。
  4. 每搬一項就執行一次測試。
  5. 檢視子類別剩下的部分,若還有可協調的差異(如方法名不同但意圖相同),統一後再繼續上移。
  6. 檢查使用端,看能否改為面向新超類別的型別編程。

★★ 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,介面版)

  1. 列出兩個型別簽章完全相同的方法(名字、參數、回傳值都要一致)。
    ⚠️ 簽章不同就不能直接抽——先用 改變函式宣告 對齊,一次一個,每次測試。
  2. 宣告介面,放在使用它的 package。
  3. 把用到這兩個型別的函式改成收介面。兩個型別不用改。
  4. 若有共同的「實作」也要共用,才進一步做嵌入(第②條路)。

🧪 我實際套用的紀錄

  • (待填)

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

  • 若這些類別的相似只是「表面巧合」、概念上並非同類,硬抽超類會製造出牽強的繼承關係;此時改用提煉類別+委派(組合)較恰當。

  • 相較之下,「先有共同父類再分化子類」與本手法方向相反,視情境擇一。

  • ★★ 巧合相似不要抽。「長得像」不等於「是同一件事」;抽了之後兩邊的需求變動會互相綁死。重複常常比錯的抽象便宜。

  • ★★ Go 的嵌入沒有虛擬分派,共用 struct 的方法無法呼叫到外層型別的覆寫。原書「超類呼叫子類覆寫的方法」這個模式在 Go 不成立。

  • ★ 介面只能共用簽章,不能共用實作。要共用實作必須嵌入,而嵌入會把被嵌入型別的匯出方法全部攤出來。

  • 抽介面前要先對齊簽章。Name() string 和 GetName() (string, error) 不能直接抽——先做 改變函式宣告。

  • Go 的介面該定義在消費方。為兩個型別做一個「共用介面」然後放在它們的 package 裡,是 Java 直覺,在 Go 通常是反模式。

  • 不要為了「以後可能有第三個實作」而抽(誇誇其談通用性)。等第三個真的出現再抽。

🔗 相關工具