🎯 什麼情境該想到我
當你在猶豫「用繼承還是組合來重用行為」,或繼承層級越來越深、改父類就牽連一堆子類的時候。
★ 如果你寫 Go:這不是選擇題——Go 根本沒有繼承。 但理解「為什麼」仍然重要。
⚙️ 怎麼用
原則
重用行為時,先想「有一個」(has-a,組合)而非「是一個」(is-a,繼承)。
| 繼承 | 組合 | |
|---|---|---|
| 綁定時機 | 編譯期,寫死 | 執行期,可換 |
| 可見性 | ★ 白箱(子類看得到父類內部) | 黑箱(只透過介面互動) |
| 耦合 | 強(父類改動直接影響子類) | 弱 |
| 多重來源 | 多數語言不支援多重繼承 | ★ 可以組合任意多個 |
★ 為什麼繼承這麼脆弱:三個具體問題
① 類別爆炸
咖啡 × {加牛奶, 加糖, 加奶泡} 用繼承 → ★ 2³ = 8 個子類
組合 → 3 個裝飾者
→ 這正是 裝飾者模式 存在的理由。
② 脆弱基底類別(Fragile Base Class)
class Base {
void addAll(List<T> items) {
for (T i : items) add(i); // ★ 內部呼叫了自己的 add
}
void add(T i) { count++; }
}
class Counting extends Base {
@Override void add(T i) { super.add(i); logged++; }
@Override void addAll(List<T> l) { super.addAll(l); logged += l.size(); }
}
// ★ addAll 會讓 logged 被加兩次 —— 因為 Base.addAll 內部又呼叫了被覆寫的 add★ 子類必須知道父類的「內部實作細節」才能正確覆寫——這就是白箱重用的代價。
而父類的實作細節隨時可能在下一版改變。
③ 語義被污染
「Stack extends Vector」是 Java 標準庫的著名錯誤——Stack 因此繼承了 insertAt(),
可以從中間插入,完全違反堆疊的語義。
★ Go 的情況:嵌入不是繼承
Go 用 struct 嵌入(embedding) 提供語法便利,但它是組合,不是繼承:
type Logger struct{ prefix string }
func (l Logger) Log(msg string) { fmt.Println(l.prefix, msg) }
type Server struct {
Logger // ★ 嵌入 —— Server 直接擁有 Log 方法
addr string
}
s := Server{Logger{"[srv]"}, ":8080"}
s.Log("started") // ★ 語法上像繼承但關鍵差異:沒有虛擬分派(virtual dispatch)
type Base struct{}
func (b Base) Name() string { return "base" }
func (b Base) Greet() string { return "hi, " + b.Name() } // ★ b 永遠是 Base
type Child struct{ Base }
func (c Child) Name() string { return "child" }
Child{}.Greet() // ★ 回傳 "hi, base" —— 不是 "hi, child"★ 這是從 Java/C++ 轉 Go 最容易踩的坑。
嵌入型別的方法永遠呼叫自己的方法,不會分派到外層型別。
→ 所以 Go 裡不能用嵌入模擬 範本方法模式;要用函式欄位或介面參數(見該頁)。好處是:脆弱基底類別的問題在 Go 裡根本不存在。
Go 的嵌入該用在哪:
| 用途 | 例子 |
|---|---|
| ★ 介面嵌入(組合小介面) | io.ReadWriter = interface{ Reader; Writer } |
| 轉發方法,省掉樣板 | 嵌入 sync.Mutex 讓型別直接有 Lock()/Unlock() |
| 裝飾/包裝 | 嵌入一個介面,只覆寫其中幾個方法 |
// ★ 嵌入介面做部分覆寫 —— 很好用的技巧
type loggingStore struct {
Store // 嵌入介面:其餘方法自動轉發
log *slog.Logger
}
func (s loggingStore) Put(ctx context.Context, k string, v []byte) error {
s.log.Info("put", "key", k)
return s.Store.Put(ctx, k, v) // ★ 只覆寫這一個
}TypeScript 的情況
TS 有 class extends,可以照 Java 那套做——但生態的主流是組合:
// ❌ 繼承
class AdminUser extends User { ... }
// ✅ 組合 + 型別交集
type Admin = User & { permissions: Permission[] };
// ✅ 行為用高階函式組合(就是 [[裝飾者模式]])
const withRetry = <T>(f: () => Promise<T>, n: number) => async () => { ... };🧪 我實際套用的紀錄
- 2026-07-14:(待填)
⚠️ 注意 / 什麼時候不適用
- ★ 不是「禁用繼承」。真正的 is-a、父類穩定、需要多型時,繼承仍然恰當且更簡潔。
判準:子類能不能在任何需要父類的地方替換它而不出錯(里氏替換原則)?不能 → 不該繼承。 - 組合的代價是樣板程式碼:要手動轉發方法。Go 的嵌入解掉了大部分,其他語言可能要寫一堆 delegate。
- ★ 組合也會被濫用:十層包裝的物件,除錯時堆疊裡全是 wrapper,和深繼承一樣難讀。
- ★ Go 的嵌入有名稱衝突規則:兩個嵌入型別有同名方法 → 呼叫時編譯錯誤(除非明確指定)。
外層自己定義的方法會遮蔽嵌入的。 - 嵌入會暴露被嵌入型別的所有匯出方法——包含你不想暴露的。要控制就別嵌入,改用具名欄位。
sync.Mutex嵌入要注意:它會讓Lock()/Unlock()變成公開 API,外部可以鎖住你的物件。通常該用具名的私有欄位。