🎯 什麼情境該想到我
當你「兩個類別其實在做同一類事情,介面(方法名稱、參數)卻長得不一樣,導致無法互相替換」的時候。
⚙️ 怎麼用(步驟 / 公式)
這味道是什麼徵兆:把功能相近的類別做成一致的介面,才能享受多型帶來的可替換性;介面各說各話,就白白失去互換與統一處理的好處,還逼使用者記兩套用法。
通常用哪些重構手法對治:
- 改變函式宣告(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 之間切換、或測試要替換)→ 值得對齊。
不需要(只是碰巧長得像)→ 對齊只是製造耦合。
⚠️ 三個實務風險
- ★ 改簽章是破壞性變更。匯出方法要走遷移式(留轉發 +
// Deprecated:)。 - ★ 參數換順序最危險:同型別換順序 → 編譯通過,語義整個反了。
要嘛同時改名逼呼叫端面對,要嘛不要做。 - ★ 抽出的介面要小。硬把兩個型別的所有方法都放進介面 → 十個方法的介面,
替換性是零(見 工具-Go介面小而隱式)。只放呼叫端真正用到的。
🧪 我實際套用的紀錄
- (待填)
⚠️ 注意 / 什麼時候不適用
-
若兩個類別只是「表面像、本質不同、不會互換」,強行統一介面反而製造假抽象。
-
對齊到底要不要抽父類,取決於它們共通的程度與未來是否真的以多型使用。
-
★★ 判準是「呼叫端真的需要互換嗎」。只是碰巧長得像,統一簽章只是製造耦合。
-
★ 巧合相似不要統一。兩個型別會各自演化;抽了之後 A 的需求會綁死 B。
-
★ 底層能力真的不同時,統一介面等於放棄真實能力(S3 的 presigned URL、Postgres 的
SKIP LOCKED)。 -
★ 第三方套件改不動 → 用 配接器模式,不要 fork。
-
★ 參數換順序是最危險的對齊動作:同型別換順序編譯通過,語義反了。
-
★ 抽出的介面要小。把兩邊所有方法都塞進去,替換性是零。
-
改匯出方法的簽章是破壞性變更,走遷移式。
-
Go 的優勢:對齊簽章後抽介面是零風險(隱式滿足,不必改既有型別)。
🔗 相關工具
- 重複程式碼 Duplicated Code(對齊介面後常能進一步消除重複)
- 重複的 switch Repeated Switches(統一介面是走向多型的前置)
- 重構
- 改變函式宣告 —— 對齊簽章的主要手法
- 提煉超類 —— 對齊後抽出共同抽象(Go 裡是抽介面)
- 搬移函式 —— 把共同行為移到該去的地方
- 配接器模式 —— ★ 改不動實作時的解法
- 誇誇其談通用性 —— 為了統一而放棄真實能力
- 工具-Go介面小而隱式 —— 抽出的介面該多小
- 工具-針對介面編程 —— 介面定義在消費方