🎯 什麼情境該想到我
當你發現「一個函式一直在引用另一個類別/模組的資料,反而很少用到自己家的東西」的時候,或想把彼此相關的函式聚攏、減少模組之間的耦合。
⚙️ 怎麼用(步驟 / 公式)
意圖:把函式搬到它「更該待的地方」——通常是它最常存取的資料所在的類別或模組。好的模組化是讓相關的程式碼聚在一起。
小步安全進行:
- 檢查這個函式在目前脈絡下用到的所有元素(變數、其他函式、欄位),想清楚它們是否也要一起搬。
- 確認函式沒有多型(覆寫/被覆寫)情況;有的話要先評估。
- 把函式複製一份到目標類別/模組,調整成適應新家(引數、命名)。
- 分析原函式:讓它變成一個委派——直接呼叫新位置的函式,或就地測試。
- 執行測試。
- 決定要不要把原函式徹底移除(改所有呼叫端指向新位置),再測一次。
★ 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(編譯失敗,不是警告)
三種解法:
- 介面定義在消費方 ——
billing自己宣告要什麼,不 importaccount(見 工具-針對介面編程)。 - 把共用型別下移到第三個 package(
domain),兩邊都 import 它。 - 函式往上搬,不往下搬 —— 讓上層的
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()(或反過來),
可能讓型別突然不再滿足某個介面——而錯誤訊息會出現在完全不相干的檔案裡。
機械化步驟
- 檢查函式在源型別裡用到的所有東西,決定它們要不要一起搬(欄位 → 搬移欄位)。
- 在目標型別上建立新函式(先複製,不刪)。編譯。
- 把源函式改成轉發:
return t.NewF(args)。→ ★ 編譯 + 測試。這時外部完全無感。 - 逐一把呼叫端改成直接呼叫新位置,每改一批測一次。
- 源函式零引用後刪掉(
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)只是把依戀情結搬進一個沒有名字的地方——那是 全域資料 的近親。
🔗 相關工具
- 搬移欄位 Move Field(函式與它常用的資料常常要一起搬)
- 提煉函式 Extract Function(有時先提煉出一段,再把它搬到更合適的地方)
- 重構
- 依戀情結 —— 觸發本手法的壞味道
- 搬移欄位 —— 常要一起做
- 提煉類別 —— 搬移的資料與邏輯夠多時
- 引入參數物件 —— 兩邊資料各半時的替代解
- 工具-針對介面編程 —— 用「介面定義在消費方」解循環 import
- 編寫內聚的程式碼 —— 「搬到哪」的判準