🎯 什麼情境該想到我

當你「一個子類只用到、甚至根本不想要父類傳下來的部分方法與資料」的時候——它繼承了一堆用不上、甚至不該有的東西。

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

這味道是什麼徵兆:子類拒絕接受父類的遺贈,通常代表繼承關係用錯了——它們其實不是「是一種(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 上,你沒得選。要控制就用具名欄位。

🔗 相關工具