🎯 什麼情境該想到我

當你「遇到一個類別、函式或繼承層級,它其實沒帶來足夠的價值,卻讓結構多了一層要理解的東西」的時候——常是當初預期它會長大、結果沒有。

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

這味道是什麼徵兆:我們用類別、函式等程式元素來組織結構、支援重用與變化;但有些元素只是一個單純的委派、一個沒人再共用的小函式,存在的理由已經消失,只剩下多餘的間接與閱讀成本。

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

  • 內聯函式(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」→ 名字帶來了領域知識,留著。

⚠️ 刪除的兩個風險

  1. ★ 匯出的型別/函式/介面是別人的 API。先掛 // Deprecated: 一個版本。
    型別可以用別名過渡:type Old = New。
  2. ★ 「只有一個實作」是現在的事實。如果你確知三個月內會有第二個實作
    (已經在 roadmap 上,不是「說不定」),保留介面是合理的。
    ⚠️ 但**「說不定以後會有」就是 誇誇其談通用性**。

🧪 我實際套用的紀錄

  • (待填)

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

  • 「一個函式只有一行」不代表它沒價值——若它的名字清楚表達了意圖,就值得留著。

  • 小而有意義的抽象(提供解釋力或未來擴充點)不是冗員;別把有用的命名層一併內聯掉。

  • ★ 小不等於冗員。Money、UserID、Email 很小,但擋住了型別搞混或封裝了不變式。

  • ★★ 判準是「名字有沒有帶來程式碼本身沒說的資訊」。isEligible(u) → u.Age >= 18 帶來了領域知識,留著。

  • ★ 在 Go 裡最貴的一種是「為每個 struct 配一個介面」。只有一個實作時,介面沒有任何好處。

  • ★ 測試裡有第二個實作,介面就有價值。判準是「現在有幾個實作」,包含測試替身。

  • ★ internal/ 用來建立編譯邊界是正當的,即使只有一個檔案。

  • 匯出的東西刪不得,要走 Deprecated + 型別別名的過渡。

  • 「說不定以後會有第二個實作」不是理由,那是 誇誇其談通用性。

🔗 相關工具