🎯 什麼情境該想到我

當你發現「好幾個子類別各自有一份內容相同(或幾乎相同)的方法」的時候——改動一處就得記得同步其他每一份,重複散落在繼承體系裡。

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

意圖:把各子類別重複的方法上移到共同父類別,消除重複。以後只需維護一份,邏輯也集中在一處。相關的還有「欄位上移」與「建構式本體上移」,一併把共通的資料與初始化搬上去。

小步安全進行:

  1. 檢查各子類別中這些方法是否真的完全等價;若只是相似,先重構到彼此一致(統一參數、統一其引用的欄位名)。
  2. 若方法引用了子類別才有的欄位或方法,先把那些欄位/方法一併上移,或在父類別宣告為抽象。
  3. 把其中一份方法搬進父類別。
  4. 執行測試。
  5. 逐一刪除其他子類別中重複的那份方法,每刪一個就測一次。

★★ Go 沒有超類別,所以這個手法對應「搬進共用的地方」

原書:「兩個子類有一模一樣的方法 → 搬到超類去。」
Go 沒有 extends,所以「上移」在 Go 裡是這三件事之一:

// before:兩個型別有字面上完全相同的方法
type Employee struct{ name string }
func (e Employee) AnnualCost() float64 { return e.monthlyCost * 12 }
 
type Contractor struct{ name string }
func (c Contractor) AnnualCost() float64 { return c.monthlyCost * 12 }   // ★ 一模一樣

① 搬進嵌入的共用 struct(最接近原意)

type monthlyCosted struct{ monthlyCost float64 }
func (m monthlyCosted) AnnualCost() float64 { return m.monthlyCost * 12 }   // ★ 只有一份
 
type Employee   struct{ monthlyCosted; name string }
type Contractor struct{ monthlyCosted; name string }
 
e.AnnualCost()   // ★ 自動轉發到嵌入的 monthlyCosted

② ★ 變成一個自由函式(Go 最常見、也常是最好的答案)

func AnnualCost(monthlyCost float64) float64 { return monthlyCost * 12 }

★ Go 不強迫邏輯掛在型別上。 如果那個方法只是在算一個純函式,
一個 package-level 函式比任何繼承結構都清楚——而且可以直接測試。

③ 變成介面的預設行為?→ ★ Go 做不到

type Costed interface {
    AnnualCost() float64      // ★ 介面只能放簽章,不能放實作
}

Go 的介面沒有 default method(Java 8 有、C# 有)。
每個實作都得自己寫——這正是嵌入存在的理由(方案①)。

★★ 硬前提:方法必須「字面上完全等價」

原書要求兩個方法一模一樣,Go 更嚴格,因為還要檢查這些:

// ① 接收者的值/指標形式必須一致
func (e Employee) AnnualCost() float64    // ★ 值接收者
func (c *Contractor) AnnualCost() float64 // ★ 指標接收者 —— 方法集不同,不能直接上移
 
// ② 用到的欄位必須也一起搬上去
func (e Employee) AnnualCost() float64 { return e.monthlyCost * 12 }
//                                              ↑ ★ monthlyCost 必須先做 [[搬移欄位]]
 
// ③ 方法內若呼叫了「各自不同」的方法 —— ★★ 這是最大的地雷
func (m monthlyCosted) Total() float64 { return m.AnnualCost() + m.Tax() }
//                                                              ↑ ★ 永遠是 monthlyCosted.Tax()
//                                                                  不會分派到 Employee.Tax()

★★ 第 ③ 點是從 Java 轉 Go 最容易踩的坑:
原書的「函式上移」常常搭配「留一個抽象方法給子類實作」(即 範本方法模式),
那個模式在 Go 完全不成立。要表達「骨架共用、細節不同」,
必須把差異點變成函式欄位或介面參數注入進去。

★ 兩個方法「幾乎相同」時怎麼辦

func (e Employee)   AnnualCost() float64 { return e.monthlyCost * 12 }
func (c Contractor) AnnualCost() float64 { return c.monthlyCost * 12 * 1.1 }   // ★ 差一個係數

先讓它們變成字面相同,再上移:

  1. 把差異參數化:func annualCost(monthly, multiplier float64) float64。
  2. 兩邊都改成呼叫它(各自傳不同的 multiplier)。
  3. 這時兩個方法只剩 return annualCost(x.monthlyCost, 1.0) —— 差異在資料而非程式碼了。
  4. 差異變成欄位後,方法就真的可以共用。

★ 這個「先參數化再上移」的順序是關鍵——直接上移一個「幾乎相同」的方法,
你會忍不住在裡面加 if,那就製造了 以多型取代條件式 要消滅的東西。

⚠️ Go 的嵌入代價(決定要不要用方案①)

  • 嵌入型別的匯出方法會全部出現在外層——monthlyCosted 之後新增方法,Employee 的 API 自動變大。
    → 讓共用 struct 未匯出(小寫),把它當純實作細節。
  • 嵌入欄位的名字就是型別名:e.monthlyCosted 是可存取的(未匯出時僅限同 package)。
  • 兩個嵌入型別有同名方法 → 呼叫時編譯錯誤(歧義),必須明確指定。
  • JSON:未匯出的嵌入不會被序列化;匯出的嵌入會讓欄位變扁平(見 提煉類別 的實測)。

🧪 我實際套用的紀錄

  • (待填)

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

  • 若各子類別的方法只是「看起來像」但語意其實不同,上移會強行綁死本該獨立演化的邏輯——不要為了消重複而合併不同概念。

  • 方法依賴大量子類別專屬狀態時,上移前得先處理那些依賴,成本可能不划算。

  • ★★ Go 的嵌入沒有虛擬分派。共用方法內呼叫的方法永遠是共用 struct 自己的,不會分派到外層型別。原書「上移骨架、留抽象方法給子類」的做法在 Go 不成立。

  • ★ 介面不能有預設實作。Go 沒有 default method,共用實作只能靠嵌入或自由函式。

  • ★ 「幾乎相同」要先參數化再上移,不要上移後在裡面加 if。

  • 接收者的值/指標形式不一致就不能直接上移(方法集不同)。

  • 用到的欄位要先 搬移欄位,否則共用方法拿不到資料。

  • ★ 巧合相同不要上移。兩個 AnnualCost() 剛好都是 × 12,但一個是薪資、一個是租金——它們會各自演化。見 工具-DRY原則。

  • 共用 struct 要未匯出,否則它的方法會擴散到所有嵌入它的型別的公開 API。

🔗 相關工具