🎯 什麼情境該想到我
當你「遇到一個類別、函式或繼承層級,它其實沒帶來足夠的價值,卻讓結構多了一層要理解的東西」的時候——常是當初預期它會長大、結果沒有。
⚙️ 怎麼用(步驟 / 公式)
這味道是什麼徵兆:我們用類別、函式等程式元素來組織結構、支援重用與變化;但有些元素只是一個單純的委派、一個沒人再共用的小函式,存在的理由已經消失,只剩下多餘的間接與閱讀成本。
通常用哪些重構手法對治:
- 內聯函式(Inline Function):把沒價值的小函式併回呼叫端。
- 內聯類別(Inline Class):把過小的類別併進使用它的類別。
- 摺疊繼承體系(Collapse Hierarchy):若某層子類/父類已不再有存在意義,將它與相鄰層合併。
★ 定義:一個程式結構的「存在成本」高於它帶來的價值
// ★ 四種典型形態
// ① 只被呼叫一次、名字等於本體的函式
func isEmpty(s []Item) bool { return len(s) == 0 } // ★ len(s) == 0 已經最清楚
// ② 只有一個欄位、沒有任何方法的 struct
type Wrapper struct{ Value string } // ★ 直接用 string
// ③ ★ 只有一個實作、測試也不替換的介面
type UserRepository interface { FindByID(…) (*User, error) }
type postgresUserRepository struct{…} // ★ 唯一實作
// ④ ★ 只有一個檔案、只被一個 package 用的 package
internal/util/stringutil/stringutil.go // ★ 一個 20 行的函式★★ Go 裡最常見、也最貴的一種:介面污染
// ★★ 為每個 struct 配一個同名介面 —— 在 Go 裡沒有任何好處
type Service interface { Do(ctx context.Context) error }
type service struct{…}
func (s *service) Do(ctx context.Context) error { … }★ 它不會讓程式更解耦,因為只有一個實作;
只多了一層間接(無法內聯、多一次動態分派)、
一個要同步維護的宣告、以及**「跳到定義」會跳到介面而不是實作**。
Go 的原則:介面該定義在「消費方」,而且只在真的需要替換時才存在。
// ✅ 消費方宣告它需要的最小介面
package report
type userFinder interface { FindByID(ctx context.Context, id string) (*User, error) }
func Build(f userFinder) …
// ★ store package 完全不知道 report.userFinder 存在# golangci-lint(2026-09-24 查證的官方清單)
# iface —— ★ 偵測介面的錯誤使用,避免介面污染
# interfacebloat —— 介面方法太多
# ireturn —— ★ Accept Interfaces, Return Concrete Types
# unused —— 未使用的常數/變數/函式/型別
# unparam —— 未使用的函式參數★ 怎麼具體認出來
# ① 找出只有一個實作的介面(粗略)
grep -rn '^type [A-Za-z]* interface {$' --include='*.go' .
# ② 找出只有一個 .go 檔(不含 _test.go)的 package
find . -name '*.go' -not -name '*_test.go' | sed 's|/[^/]*$||' | sort | uniq -c | awk '$1==1'
# ③ ★ 死碼:staticcheck 的 unused,或 golang.org/x/tools/cmd/deadcode★ 對治
| 形態 | 對治 |
|---|---|
| 只呼叫一次、沒帶來理解的函式 | 行內函式 |
| 只包一個值、沒有行為的型別 | 內聯類別 |
| 只有一個實作的介面 | ★ 內聯類別(刪掉介面,直接用具體型別) |
| 只有一個函式的 package | 合併回呼叫它的 package |
| 完全沒人用 | 移除死程式碼 |
★★ 什麼時候這其實不是味道
| 情況 | 說明 |
|---|---|
| ★ 型別很小但有不變式 | Money、Email、UserID —— 小不等於冗員(見 以物件取代基本型別) |
| ★ 具名型別防型別搞混 | type UserID string 沒有方法,但它擋住了 LoadUser(orderID) |
| ★ 測試真的需要替換的介面 | 只要測試裡有第二個實作(fake/stub),介面就有存在價值 |
★ internal/ 用來建立編譯邊界 | 一個 package 只為了「不讓外面碰」而存在,是正當的 |
| 提煉的中間步驟 | 重構進行中的暫時結構 |
| ★ 名字帶來領域語彙 | func isEligible(u User) bool { return u.Age >= 18 } —— 18 變成 isEligible 是有價值的 |
★★ 判準:這個結構的名字有沒有帶來「程式碼本身沒說的資訊」?
isEmpty(s)的本體len(s) == 0已經說完了 → 冗員。
isEligible(u)的本體u.Age >= 18沒說「為什麼是 18」→ 名字帶來了領域知識,留著。
⚠️ 刪除的兩個風險
- ★ 匯出的型別/函式/介面是別人的 API。先掛
// Deprecated:一個版本。
型別可以用別名過渡:type Old = New。 - ★ 「只有一個實作」是現在的事實。如果你確知三個月內會有第二個實作
(已經在 roadmap 上,不是「說不定」),保留介面是合理的。
⚠️ 但**「說不定以後會有」就是 誇誇其談通用性**。
🧪 我實際套用的紀錄
- (待填)
⚠️ 注意 / 什麼時候不適用
-
「一個函式只有一行」不代表它沒價值——若它的名字清楚表達了意圖,就值得留著。
-
小而有意義的抽象(提供解釋力或未來擴充點)不是冗員;別把有用的命名層一併內聯掉。
-
★ 小不等於冗員。
Money、UserID、Email很小,但擋住了型別搞混或封裝了不變式。 -
★★ 判準是「名字有沒有帶來程式碼本身沒說的資訊」。
isEligible(u) → u.Age >= 18帶來了領域知識,留著。 -
★ 在 Go 裡最貴的一種是「為每個 struct 配一個介面」。只有一個實作時,介面沒有任何好處。
-
★ 測試裡有第二個實作,介面就有價值。判準是「現在有幾個實作」,包含測試替身。
-
★
internal/用來建立編譯邊界是正當的,即使只有一個檔案。 -
匯出的東西刪不得,要走 Deprecated + 型別別名的過渡。
-
「說不定以後會有第二個實作」不是理由,那是 誇誇其談通用性。
🔗 相關工具
- 誇誇其談通用性 Speculative Generality(為未來預留卻用不上的抽象,也常變成冗員)
- 中間人 Middle Man(只做轉呼叫的類別也是一種冗員傾向)
- 重構
- 行內函式、內聯類別 —— 主要對治手法
- 移除死程式碼 —— 完全沒人用時
- 誇誇其談通用性 —— 「為了以後」而留下的冗員
- 以物件取代基本型別 —— 為什麼小型別常常是對的
- 工具-針對介面編程、工具-Go介面小而隱式 —— Go 的介面污染與消費方定義