🎯 什麼情境該想到我

當你發現「一個函式一直在引用另一個類別/模組的資料,反而很少用到自己家的東西」的時候,或想把彼此相關的函式聚攏、減少模組之間的耦合。

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

意圖:把函式搬到它「更該待的地方」——通常是它最常存取的資料所在的類別或模組。好的模組化是讓相關的程式碼聚在一起。

小步安全進行:

  1. 檢查這個函式在目前脈絡下用到的所有元素(變數、其他函式、欄位),想清楚它們是否也要一起搬。
  2. 確認函式沒有多型(覆寫/被覆寫)情況;有的話要先評估。
  3. 把函式複製一份到目標類別/模組,調整成適應新家(引數、命名)。
  4. 分析原函式:讓它變成一個委派——直接呼叫新位置的函式,或就地測試。
  5. 執行測試。
  6. 決定要不要把原函式徹底移除(改所有呼叫端指向新位置),再測一次。

★ before / after(Go)

訊號:函式用別人的資料比用自己的多(依戀情結)。

// before:TopUpAgreement 只是為了算 Account 的事,卻住在 Account 上
func (a *Account) OverdraftCharge() float64 {
    if a.Type.IsPremium() {                       // ★ 五行裡四行在問 a.Type
        base := 10.0
        if a.DaysOverdrawn <= 7 { return base }
        return base + float64(a.DaysOverdrawn-7)*0.85
    }
    return float64(a.DaysOverdrawn) * 1.75
}
 
// after:搬到 AccountType 上,資料和邏輯在一起
func (t AccountType) OverdraftCharge(daysOverdrawn int) float64 {
    if t.IsPremium() {
        base := 10.0
        if daysOverdrawn <= 7 { return base }
        return base + float64(daysOverdrawn-7)*0.85
    }
    return float64(daysOverdrawn) * 1.75
}
 
// Account 保留一個轉發,呼叫端不用動
func (a *Account) OverdraftCharge() float64 { return a.Type.OverdraftCharge(a.DaysOverdrawn) }

★ 怎麼判斷「該搬到哪」:數欄位

OverdraftCharge 用到:a.Type ×2、a.DaysOverdrawn ×3
                      ↑ 屬於 AccountType   ↑ 屬於 Account

★ 搬到「被用得最多的那個資料所在的型別」。 數不出勝負(各半)→ 通常表示
這裡有第三個概念沒被命名(提煉類別),或該用 引入參數物件 把兩邊的資料包成一個。

★★ Go 特有:搬移函式的三個真實形態

Go 沒有 class,所以「搬移」在實務上是這三件事之一:

形態動作陷阱
換接收者func (a *Account) F() → func (t AccountType) F()值/指標接收者要一致,否則方法集不同(見下)
搬到另一個 package移檔案 + 改 import★ 循環 import(Go 直接編譯失敗)
從方法變成自由函式func Charge(t AccountType, days int) float64常是最乾淨的答案 —— Go 不強迫你掛在型別上

⚠️ 循環 import 是 Go 搬移函式最常撞的牆

package billing  → import account     ✅
package account  → import billing     ★ import cycle not allowed(編譯失敗,不是警告)

三種解法:

  1. 介面定義在消費方 —— billing 自己宣告要什麼,不 import account(見 工具-針對介面編程)。
  2. 把共用型別下移到第三個 package(domain),兩邊都 import 它。
  3. 函式往上搬,不往下搬 —— 讓上層的 service 同時 import 兩者並協調,底層互不相識。

⚠️ 值接收者 vs 指標接收者的方法集

type T struct{}
func (t T) A() {}      // T 和 *T 都有 A
func (t *T) B() {}     // ★ 只有 *T 有 B
 
var x any = T{}        // ★ T{} 不滿足含 B() 的介面

搬移時把 func (a *Account) F() 改成 func (a Account) F()(或反過來),
可能讓型別突然不再滿足某個介面——而錯誤訊息會出現在完全不相干的檔案裡。

機械化步驟

  1. 檢查函式在源型別裡用到的所有東西,決定它們要不要一起搬(欄位 → 搬移欄位)。
  2. 在目標型別上建立新函式(先複製,不刪)。編譯。
  3. 把源函式改成轉發:return t.NewF(args)。→ ★ 編譯 + 測試。這時外部完全無感。
  4. 逐一把呼叫端改成直接呼叫新位置,每改一批測一次。
  5. 源函式零引用後刪掉(go build ./... 確認)。若源函式是匯出的,先掛 // Deprecated: 一個版本。

工具支援

⚠️ gopls 沒有 Move Function。可以用 refactor.extract.toNewFile 把宣告搬到新檔案(同 package),
但跨 package 搬移要手動(移檔、改 package 宣告、修 import)。
Rename 可以搬套件,但不能搬單一函式。

🧪 我實際套用的紀錄

  • (待填)

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

  • 若函式同時大量用到新舊兩邊的資料,搬過去不見得更好,先想清楚「它到底屬於誰」。

  • 若牽涉繼承體系中的多型函式,貿然搬動會破壞覆寫關係。

  • 純為了「看起來整齊」而搬、卻增加參數傳遞的複雜度,就別搬。

  • ★ Go 的循環 import 是硬性編譯錯誤,不像 Java 只是難看。搬移前先想清楚依賴方向。

  • ★ 改變接收者的值/指標形式會改變方法集,可能讓型別不再滿足介面——錯誤出現在別的檔案。

  • 一定要留轉發期(步驟 3)。跳過它 = 一個改了 80 個檔案的 diff。

  • 搬移不改行為,所以測試應該完全不用改。如果你發現自己在改測試,那你同時在做別的事——停下來拆成兩次。

  • 匯出函式搬家是破壞性變更。留 // Deprecated: 改用 xxx 的轉發至少一個版本。

  • ★ 不要把邏輯搬到「工具 package」。utils.CalculateCharge(account, type) 只是把依戀情結搬進一個沒有名字的地方——那是 全域資料 的近親。

🔗 相關工具