🎯 什麼情境該想到我

當你「看到一堆為了『以後也許會需要』而預留的抽象、鉤子(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 一個實作
}
  1. ★ 抽象會找最小公分母:介面只能暴露「所有可能實作都支援」的功能
    → 你用不到 Postgres 的 SELECT … FOR UPDATE SKIP LOCKED、jsonb、部分索引。
    為了假想的可移植性,放棄真實的能力。
  2. ★ 猜錯的機率很高。為「換資料庫」準備的抽象,真的要換時通常還是不夠
    (交易語義、隔離等級、錯誤碼全都不同)。
  3. 每個讀者都要付閱讀成本:跳到定義會跳到介面,得再找一次實作。
  4. 死的擴充點會腐爛:沒有測試覆蓋的通用路徑,需要時才發現根本不能用。

★★ 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 下的其他實作。

🔗 相關工具