🎯 什麼情境該想到我

當你「兩個類別其實在做同一類事情,介面(方法名稱、參數)卻長得不一樣,導致無法互相替換」的時候。

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

這味道是什麼徵兆:把功能相近的類別做成一致的介面,才能享受多型帶來的可替換性;介面各說各話,就白白失去互換與統一處理的好處,還逼使用者記兩套用法。

通常用哪些重構手法對治:

  • 改變函式宣告(Change Function Declaration):對齊方法名稱與參數,讓兩者介面一致。
  • 搬移函式(Move Function):把行為搬移,使兩個類別具備對等的能力。
  • 提煉超類(Extract Superclass):當兩者對齊到足夠相似時,抽出共同父類收攏一致介面。

★ 定義與怎麼具體認出來

// ★ 兩個型別在做同一件事,但介面長得不一樣 —— 呼叫端無法互換
type S3Store struct{}
func (S3Store) Upload(ctx context.Context, key string, data []byte) error
func (S3Store) Download(ctx context.Context, key string) ([]byte, error)
 
type GCSStore struct{}
func (GCSStore) Write(ctx context.Context, path string, r io.Reader) error   // ★ 名字不同
func (GCSStore) Read(ctx context.Context, path string) (io.ReadCloser, error) // ★ 型別也不同

訊號:

  • ★ 兩個型別的方法「語義相同、簽章不同」(名字、參數順序、回傳型別)。
  • ★ 呼叫端有一個 if useS3 { … } else { … } 各寫一遍。
  • ★ switch 依「用哪個實作」分派,而不是依資料型別分派。
  • 兩個型別的測試檔看起來幾乎一樣,只是名字不同。

★★ Go 的關鍵優勢:對齊簽章之後,抽介面是免費的

// 第 1 步:★ 用 [[改變函式宣告]] 把簽章對齊(一次改一個方法,每次跑測試)
func (GCSStore) Upload(ctx context.Context, key string, data []byte) error     // Write → Upload
func (GCSStore) Download(ctx context.Context, key string) ([]byte, error)      // Read → Download
 
// 第 2 步:★ 宣告介面 —— 兩個型別「一行都不用改」
package uploader
type ObjectStore interface {
    Upload(ctx context.Context, key string, data []byte) error
    Download(ctx context.Context, key string) ([]byte, error)
}

★★ 這是 Go 相對 Java 最大的優勢:隱式滿足讓「抽出共同介面」變成零風險重構——
不必回頭在 S3Store 上寫 implements ObjectStore,也不必動它的 package。
而且介面可以定義在消費方,被抽象的 package 完全不知道它存在(見 工具-針對介面編程)。

★ 對齊簽章的四種情況與手法

差異手法
名字不同改變函式宣告(gopls 的 Rename 最嚴謹)
參數順序不同★ 改變函式宣告。最危險——同型別換順序會編譯通過(見該頁)
參數多寡不同移除旗標參數/引入參數物件,或加一個轉發方法補上預設值
★ 回傳型別不同★ 最難:[]byte vs io.ReadCloser 不能靠改名解決
// ★ 回傳型別不同時:用 [[配接器模式]],不要硬改實作
type gcsAdapter struct{ inner *GCSStore }
 
func (a gcsAdapter) Download(ctx context.Context, key string) ([]byte, error) {
    rc, err := a.inner.Read(ctx, key)
    if err != nil { return nil, err }
    defer rc.Close()                           // ★ 配接器負責處理資源
    return io.ReadAll(rc)
}

★ 配接器是這個味道最實用的解:不動兩邊的實作(可能是第三方套件),
只在邊界上做一層轉換——而在 Go 裡配接器幾乎免費(隱式滿足 + http.HandlerFunc 式的函式型別)。

★★ 什麼時候這其實不是味道

情況說明
★ 只是「巧合相似」兩個型別現在長得像,但背後是不同的概念,會各自演化。抽了之後 A 的需求會綁死 B
★ 底層能力真的不同S3 有 presigned URL、GCS 沒有;強行統一成最小公分母,等於放棄真實能力(見 誇誇其談通用性)
★ 只有一個呼叫端,且不需要互換統一簽章的成本高於收益
★ 第三方套件你改不動它們的簽章 → ★ 只能用配接器,不要 fork
跨 bounded context兩邊的 Order 剛好都有 Total(),但那是兩個領域的兩個概念

★★ 判準:呼叫端「真的需要互換」它們嗎?
需要(要在 S3/GCS 之間切換、或測試要替換)→ 值得對齊。
不需要(只是碰巧長得像)→ 對齊只是製造耦合。

⚠️ 三個實務風險

  1. ★ 改簽章是破壞性變更。匯出方法要走遷移式(留轉發 + // Deprecated:)。
  2. ★ 參數換順序最危險:同型別換順序 → 編譯通過,語義整個反了。
    要嘛同時改名逼呼叫端面對,要嘛不要做。
  3. ★ 抽出的介面要小。硬把兩個型別的所有方法都放進介面 → 十個方法的介面,
    替換性是零(見 工具-Go介面小而隱式)。只放呼叫端真正用到的。

🧪 我實際套用的紀錄

  • (待填)

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

  • 若兩個類別只是「表面像、本質不同、不會互換」,強行統一介面反而製造假抽象。

  • 對齊到底要不要抽父類,取決於它們共通的程度與未來是否真的以多型使用。

  • ★★ 判準是「呼叫端真的需要互換嗎」。只是碰巧長得像,統一簽章只是製造耦合。

  • ★ 巧合相似不要統一。兩個型別會各自演化;抽了之後 A 的需求會綁死 B。

  • ★ 底層能力真的不同時,統一介面等於放棄真實能力(S3 的 presigned URL、Postgres 的 SKIP LOCKED)。

  • ★ 第三方套件改不動 → 用 配接器模式,不要 fork。

  • ★ 參數換順序是最危險的對齊動作:同型別換順序編譯通過,語義反了。

  • ★ 抽出的介面要小。把兩邊所有方法都塞進去,替換性是零。

  • 改匯出方法的簽章是破壞性變更,走遷移式。

  • Go 的優勢:對齊簽章後抽介面是零風險(隱式滿足,不必改既有型別)。

🔗 相關工具