🎯 什麼情境該想到我
當你「看到一堆為了『以後也許會需要』而預留的抽象、鉤子(hook)、參數或彈性,但現在根本沒人用到」的時候。
⚙️ 怎麼用(步驟 / 公式)
這味道是什麼徵兆:出於「將來可能要」而加的多餘機制,帶來的是此刻實實在在的複雜度——更難懂、更難改、更多要維護的東西。若那個「將來」沒到來,這些通用性就是純粹的負擔。作者引 Brian Foote:只在真正需要時才做,不要因為「你覺得將來會需要」而做。
通常用哪些重構手法對治:
- 摺疊繼承體系(Collapse Hierarchy):拿掉只有單一實作、沒帶來價值的抽象層。
- 內聯函式/內聯類別(Inline Function / Inline Class):把沒必要的間接層併回去。
- 改變函式宣告(Change Function Declaration):移除從沒被用到的參數。
- 移除死程式碼(Remove Dead Code):刪掉沒人呼叫、只為假想需求存在的碼。
★ 怎麼具體認出來:五個訊號
| 訊號 | 例子 |
|---|---|
| ★ 只有一個實作的介面/抽象層 | Storage 介面 + 唯一的 s3Storage |
| ★ 永遠沒被用到的參數/選項 | WithRetryPolicy() 從來沒人傳過(unparam linter 抓部分情況) |
| ★ 「為了以後」的擴充點 | type Plugin interface{…} + 零個外部 plugin |
| 只有一個型別參數實例的泛型 | func Process[T Item](…),而 T 永遠是 Order |
★ 名字裡有 Abstract/Base/Generic/Manager | 抽象名 = 沒有具體職責 |
# ★ 找出只有一個實作的介面(配合人工判斷)
grep -rn 'interface {' --include='*.go' . | wc -l
# golangci-lint(2026-09-24 查證)
# unparam —— 未使用的函式參數
# unused —— 未使用的型別/函式
# iface —— ★ 介面的錯誤使用(介面污染)
# interfacebloat —— 介面方法太多
# ireturn —— Accept Interfaces, Return Concrete Types★★ 為什麼會痛:代價不只是「多一層」
// ★ 一個「為了以後能換資料庫」的抽象層,實際的代價:
type Storage interface {
Get(ctx context.Context, k string) ([]byte, error)
Put(ctx context.Context, k string, v []byte) error
// ★ 只有 Postgres 一個實作
}- ★ 抽象會找最小公分母:介面只能暴露「所有可能實作都支援」的功能
→ 你用不到 Postgres 的SELECT … FOR UPDATE SKIP LOCKED、jsonb、部分索引。
為了假想的可移植性,放棄真實的能力。 - ★ 猜錯的機率很高。為「換資料庫」準備的抽象,真的要換時通常還是不夠
(交易語義、隔離等級、錯誤碼全都不同)。 - 每個讀者都要付閱讀成本:跳到定義會跳到介面,得再找一次實作。
- 死的擴充點會腐爛:沒有測試覆蓋的通用路徑,需要時才發現根本不能用。
★★ Fowler 的判準:「唯一能證明抽象是對的證據,是它已經被用於不止一種情況。」
★ Go 社群的立場特別鮮明
// ★ Rob Pike 的 Go 諺語(Go Proverbs)
// "The bigger the interface, the weaker the abstraction."
// "Don't design with interfaces, discover them."Go 的做法:先寫具體實作,等到「真的有第二個使用情境」才抽介面——
而且介面定義在消費方,只放它用到的方法(見 工具-Go介面小而隱式)。
★ 在 Go 抽介面是零風險重構(隱式滿足,不必改既有型別),
所以「以後再抽」的成本比其他語言低得多——沒有理由提前抽。
★★ 什麼時候這其實不是味道
| 情況 | 說明 |
|---|---|
| ★ 測試需要替換 | 有 fake/stub 就是「第二個實作」——介面此時是有根據的 |
| ★ 對外發布的函式庫 | 你不知道使用者要做什麼,適度的擴充點是產品需求。⚠️ 但仍然:先發布窄 API,再加寬——反過來做不到 |
| ★ 標準庫的既有抽象 | io.Reader/http.Handler——★ 它們是發現出來的(已有大量實作),不是設計出來的 |
| 已經在 roadmap 上的第二個實作 | ★ 判準:有明確的時程與需求,而不是「說不定」 |
| 合規/可審計性要求的抽象層 | 有外部理由 |
| ★ 真正的插件邊界 | LSP、MCP、driver 註冊表——★ 協定本身就是產品 |
★★ 判準:「第二個使用情境」是已經存在,還是想像出來的?
已經存在(含測試替身、已排期的需求)→ 抽象有根據。
想像的(「以後可能要支援 MySQL」「將來或許會有 plugin」)→ 這是味道。
★ 對治
只有一個實作的介面 ──▶ [[內聯類別]](刪掉介面)
沒人用的參數 ──▶ [[改變函式宣告]](移除)
沒人用的型別/函式 ──▶ [[移除死程式碼]]
只有一個子型別的階層 ──▶ [[內聯類別]] / 合併
只有一個型別參數實例的泛型 ──▶ 換回具體型別
⚠️ 刪之前確認:匯出的東西可能是別人的 API(走 // Deprecated: 過渡);
import _ 的副作用註冊;build tag 下的其他實作(GOOS=linux go build ./...)。
⚠️ 反向的失敗也是真的
★ 不要把這頁讀成「永遠不要抽象」。
真正需要第二個實作時才抽象,代價是一次改動(而 Go 的隱式介面讓這件事很便宜);
提前抽象錯的代價,是一個永久錯誤的形狀——它比沒有抽象貴得多,因為之後所有人都得繞著它寫。
★ 兩種失敗都存在,但「太早」比「太晚」貴,尤其在 Go 裡。
🧪 我實際套用的紀錄
- (待填)
⚠️ 注意 / 什麼時候不適用
-
真的有「近在眼前、已知會來」的需求時,適度預留是合理設計,不算此味道。
-
框架/函式庫(供外部擴充)本就需要一定的通用性,判斷標準不同於應用程式碼。
-
★ 測試替身就是「第二個實作」。有 fake/stub,介面就有根據。
-
★ 對外發布的函式庫需要適度擴充點,但仍然「先窄後寬」——加寬容易,收窄是破壞性變更。
-
★
io.Reader/http.Handler是被「發現」的,不是被設計的。它們在成為介面前已有大量實作。 -
★★ 判準:第二個使用情境是已經存在,還是想像的? 「以後可能要換資料庫」是想像的。
-
★ 抽象會找最小公分母,讓你放棄具體實作的真實能力(
SKIP LOCKED、jsonb…)。 -
★ 在 Go 抽介面是零風險重構(隱式滿足),所以提前抽的理由更薄弱。
-
不要反向過度:真的需要時就抽。但「太早」比「太晚」貴——錯的形狀會留很久。
-
刪除前確認:匯出 API、
import _的註冊副作用、build tag 下的其他實作。
🔗 相關工具
- 冗員元素 Lazy Element(沒被用到的抽象常同時是冗員)
- 暫時欄位 Temporary Field(只在假想情境才用到的欄位也是一種投機)
- 重構
- 冗員元素 —— 「為了以後」而留下的結構
- 移除死程式碼、內聯類別、改變函式宣告 —— 對治手法
- 工具-Go介面小而隱式 —— 介面污染與「發現而非設計」
- 工具-針對介面編程 —— Go 的介面該定義在消費方
- 工具-DRY原則 —— 抽象時機的另一面
- 工具-從相似定義提煉抽象 —— 何時該抽