🎯 什麼情境該想到我
當你「一個子類只用到、甚至根本不想要父類傳下來的部分方法與資料」的時候——它繼承了一堆用不上、甚至不該有的東西。
⚙️ 怎麼用(步驟 / 公式)
這味道是什麼徵兆:子類拒絕接受父類的遺贈,通常代表繼承關係用錯了——它們其實不是「是一種(is-a)」的關係,只是想重用一點碼而硬套繼承。若子類覆寫父類方法讓它報錯或什麼都不做,那是更強烈的警訊。
通常用哪些重構手法對治:
- 以委託取代子類(Replace Subclass with Delegate)/以委託取代父類(Replace Superclass with Delegate):當繼承關係不恰當時,改用委託來重用,關係更誠實。
- 下移函式(Push Down Method)/下移欄位(Push Down Field):若只是父類把「只有部分子類要用」的成員放太高,把它們下移到真正需要的子類。
★★ Go 沒有繼承,所以這個味道換了一張臉
原書:「子類別繼承了父類的資料與方法,但只想要其中一部分。」
Go 沒有 extends,所以這個味道在 Go 裡是下面四種形態:
// ① ★★ 最常見:嵌入了一個型別,卻只想要它的一部分方法
type Service struct {
*sql.DB // ★ 嵌入 —— Service 現在有 Query、Exec、Begin、Close、Stats…
}
// ★ 問題:呼叫端可以 svc.Close() 關掉整個連線池,svc.Begin() 開一個沒人管的交易
// 而你只是想要 Query。這就是「被拒絕的遺贈」——你繼承了不想要的 API
// ② ★ 實作了介面,但有些方法只能 panic
type ReadOnlyStore struct{}
func (ReadOnlyStore) Get(k string) ([]byte, error) { … }
func (ReadOnlyStore) Put(k string, v []byte) error { panic("read-only") } // ★★ 違反里氏替換
// ③ ★ 嵌入介面卻沒實作完 —— 編譯通過,執行期 nil panic
type partialStore struct {
Store // ★ 嵌入介面,未設值 → 呼叫未覆寫的方法時 nil panic
}
func (p partialStore) Get(k string) ([]byte, error) { … } // 只覆寫這個
// ④ 巨大的介面,多數實作只用得到一部分
type Repository interface { Get; Put; List; Delete; Watch; Stats; Migrate }★★ 形態 ③ 的兩面性:它既是陷阱,也是慣用技巧
// ★ 這是 Go 的標準技巧:嵌入介面做「部分覆寫」,其餘自動轉發
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)
}
// ✅ 這是 [[裝飾者模式]],完全正當
// ★ 但同樣的結構,不設值就是地雷
type fakeStore struct{ Store } // ★ 測試裡常見:「只實作我要測的方法」
func (f fakeStore) Get(k string) ([]byte, error) { return []byte("x"), nil }
// ★ 實測 Go 1.26.4(2026-09-25):fakeStore{} 滿足 Store,編譯完全通過
// s.Get("a") → "x"
// s.Put(…) → panic: runtime error: invalid memory address or nil pointer dereference
// ★ 而堆疊看起來像是 Store 的問題,不像是「你忘了實作 Put」★ 差別只在「嵌入的介面有沒有被設值」——而編譯器完全不檢查這件事。
⚠️ 這個技巧在測試替身裡最危險:它讓「我忘了實作這個方法」變成執行期 panic 而非編譯錯誤。
★ 權衡:如果介面有 15 個方法而你只在意 1 個,嵌入省下 14 個樣板方法;
但要接受「漏實作會在執行期才炸」。介面小的時候,就全部明確實作。
★ 對治
| 形態 | 對治 |
|---|---|
| 嵌入太多 | ★ 改成具名的私有欄位 + 明確轉發(只轉發你要的) |
| 只想要一部分方法 | ★ 介面提取/分離介面——宣告你真正需要的小介面 |
| panic 的方法 | 拆成兩個介面(Reader / ReadWriter),讓型別只實作它能做的 |
| 巨大介面 | ★ 拆成小介面,在消費方宣告(見 工具-Go介面小而隱式) |
// ✅ 形態 ① 的對治
type Service struct {
db *sql.DB // ★ 私有具名欄位 —— 外部完全看不到 sql.DB 的 API
}
func (s *Service) FindUser(ctx context.Context, id string) (*User, error) { … }
// ★ 現在沒人能 svc.Close()
// ✅ 形態 ②④ 的對治:介面按能力切
type Getter interface { Get(context.Context, string) ([]byte, error) }
type Putter interface { Put(context.Context, string, []byte) error }
// ★ ReadOnlyStore 只實作 Getter,不必 panic★★ 什麼時候這其實不是味道
| 情況 | 說明 |
|---|---|
★ 嵌入 sync.Mutex 等實作細節 | ⚠️ 但應該用小寫具名欄位(mu sync.Mutex),否則 Lock() 會變成公開 API,外部能鎖住你的物件 |
| ★ 嵌入介面做裝飾者 | ✅ 有設值時完全正當,是 Go 的慣用技巧 |
| 嵌入是為了「就是要那整套 API」 | type MyHandler struct{ http.Handler } —— 你真的想要 ServeHTTP |
| ★ 標準庫刻意的「部分實作」 | io.ReadWriteCloser 的實作不必都支援 seek —— 用 io.Seeker 型別斷言探測是既定模式 |
| 測試替身刻意只實作用到的方法 | ⚠️ 可接受,但要知道漏實作會在執行期炸,而不是編譯期 |
★★ 判準:這個型別「對外承諾」的 API,它全都能誠實履行嗎?
不能(某些方法 panic、某些方法回ErrUnsupported)→ 介面切錯了,拆小它。
能 → 不是味道。
⚠️ Go 的一個額外陷阱:嵌入沒有虛擬分派
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"★ 所以「覆寫父類方法」在 Go 根本不成立——這也意味著原書的部分討論
(子類覆寫父類方法以拒絕遺贈)在 Go 裡沒有對應物。見 工具-Go嵌入機制。
🧪 我實際套用的紀錄
- (待填)
⚠️ 注意 / 什麼時候不適用
-
子類「用不到父類全部」其實相當常見,作者認為這種輕微情況通常不必大動干戈。
-
只有當「拒絕遺贈」造成困惑或錯誤(尤其是拒絕父類的介面/行為)時才值得重構。
-
★★ Go 沒有繼承,這個味道在 Go 裡是「嵌入了不想要的 API」「介面太大導致方法只能 panic」。
-
★★ 嵌入介面而不設值 → 呼叫未覆寫的方法時 nil panic,編譯器不檢查。測試替身裡最危險。
-
★ 同樣的結構,有設值就是裝飾者(正當),沒設值就是地雷。 差別只在這裡。
-
★ 嵌入
sync.Mutex應該用小寫具名欄位,否則Lock()變成公開 API。 -
★ 方法只能 panic 或回
ErrUnsupported→ 介面切錯了,拆小它,別讓型別說謊。 -
★ Go 的嵌入沒有虛擬分派,「覆寫」在 Go 不成立。
-
嵌入會讓被嵌入型別新增的匯出方法自動出現在你的 API 上,你沒得選。要控制就用具名欄位。
🔗 相關工具
- 內幕交易 Insider Trading(繼承造成的過度耦合)
- 異曲同工的類別 Alternative Classes with Different Interfaces(改用委託後可能需要對齊介面)
- 重構
- 介面提取、分離介面 —— 宣告你真正需要的小介面
- 內聯類別 —— 只有一個實作的介面
- 中間人 —— 明確轉發會產生的另一個味道
- 裝飾者模式 —— 嵌入介面的正當用法
- 工具-Go嵌入機制 —— 嵌入的完整規則與陷阱
- 工具-Go介面小而隱式 —— 介面要小、要定義在消費方
- 工具-優先組合而非繼承 —— 為什麼 Go 選了組合