🎯 什麼情境該想到我

當你「一個函式對『另一個模組』的資料興致勃勃、頻繁存取它的欄位與方法,反而很少碰自己所在類別的資料」的時候。

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

這味道是什麼徵兆:程式的一個基本原則是「把一起變化的東西放在一起」——資料與操作它的行為應該相鄰。當某函式花大半力氣在操弄別的物件的資料,代表它其實比較想待在那個物件裡。

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

  • 搬移函式(Move Function):把這個函式整個搬到它所依戀的那個類別去。
  • 提煉函式(Extract Function):若只有函式中一段對別人資料上心,先把那段抽出來,再搬過去。

判斷歸屬的常見準則:看這段碼用到「誰的資料最多」,就把它放到誰那裡。

★ 怎麼具體認出來:數「點」

// ★ 訊號:函式裡對別人欄位的存取次數,多於對自己欄位的存取次數
func (r Report) Summary(o Order) string {
    return fmt.Sprintf("%s / %d / %.2f / %s",
        o.Customer.Name,        // ★ o. ×1
        o.Quantity,             // ★ o. ×2
        o.ItemPrice,            // ★ o. ×3
        o.Status)               // ★ o. ×4  —— 自己的欄位:0 次
}
// → 這個方法屬於 Order,不屬於 Report

可以直接跑的粗略檢查:

# 在一個方法裡數「同一個非接收者變數的欄位存取」次數
grep -nE '\b[a-z][a-zA-Z]*\.[A-Z][a-zA-Z]*' --include='*.go' -r . | head
# ★ 沒有現成 linter 能判斷這個味道 —— 它需要語義理解,只能靠 review 與上面的數點法

★★ 為什麼會痛:具體情境

// ★ 計價邏輯放在 Report 上,於是:
// ① Order 加一個 DiscountRate 欄位 → 得同時改 Report([[霰彈式修改]])
// ② Order 的欄位全部得公開,封裝變成零([[封裝記錄]] 做不了)
// ③ 三個不同的 Report 各自算一次 → 三份會漂移的邏輯([[重複程式碼]])
// ④ ★ 想測「折扣算得對不對」,得先建一個 Report

★ 根本原因:「資料」和「用資料做的事」被分開了。
這違反物件導向最基本的一條——把行為放在資料旁邊。

★ 對治的決策順序

整個方法都在用別人的資料? ── 是 ─▶ [[搬移函式]]
      │否(只有一部分)
先 [[提煉函式]] 把那一段抽出來,再 [[搬移函式]] 搬走
      │
搬過去會撞循環 import? ─▶ 介面定義在消費方,或把型別下移到共用 package
      │
資料分屬兩個型別、各用一半? ─▶ [[引入參數物件]] 或 [[提煉類別]](第三個概念沒被命名)

★★ 什麼時候這其實不是味道

情況說明
★ 有意的分離:資料與策略slices.SortFunc(s, cmp) 的 cmp 大量存取元素欄位——那正是策略的工作(策略模式)
★ 純資料的 DTOjson 對映用的 struct 就該只有欄位。把行為塞進 DTO 反而糟
★ 「純核心 + 外殼」的核心函式func computeSettlement(inv Invoice, now time.Time) Plan 大量讀 inv——這是刻意的,讓計算成為可測的純函式(見 拆分階段)
序列化/呈現層Render(o Order)、ToProto(o Order)——★ 呈現邏輯不該住在領域型別上,否則領域型別會依賴 HTTP/protobuf
跨 bounded context出貨服務讀訂單的欄位,但不該把出貨邏輯放進訂單

★★ 判準:如果把這個函式搬過去,被搬入的型別會不會因此依賴它不該依賴的東西?
Order.RenderHTML() → Order 開始依賴 html/template。那就不要搬。
「依戀情結」和「刻意的關注點分離」在結構上長得一模一樣——差別只在「這是哪一層」。

★ Go 的三個特殊考量

// ① ★ Go 不強迫邏輯掛在型別上 —— 自由函式常常是最好的答案
func TotalPrice(o Order) Money { … }        // ✅ 不必是方法,也不算依戀情結
//    只要它跟 Order 在同一個 package,就能存取小寫欄位,封裝沒有被破壞
 
// ② ★ 「同 package」就沒有封裝問題
//    Go 的可見性邊界是 package。同 package 的函式讀 struct 的私有欄位是正常的
//    → 這個味道在 Go 裡真正的痛點是「跨 package 的依戀」
 
// ③ 搬移會撞循環 import(硬性編譯錯誤)
//    → 解法:介面定義在消費方(見 [[工具-針對介面編程]])

★ 所以在 Go 裡判斷這個味道,要先問「跨 package 嗎」:
同 package 內的「依戀」通常只是「這個函式還沒決定要不要變成方法」,成本很低。
跨 package 的依戀才會逼你把欄位全部公開——那是真的問題。

🧪 我實際套用的紀錄

  • (待填)

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

  • 有些設計刻意把「行為」與「資料」分離(如某些策略/訪問者模式、把展示邏輯與領域資料分開),這種情況的「依戀」是有意為之,不算味道。

  • 若一段碼平均地用到好幾個模組的資料,就把它放到它「用最多資料」的那個模組。

  • ★★ 「刻意的關注點分離」和這個味道長得一樣。判準是「搬過去會不會讓被搬入的型別依賴不該依賴的東西」。Order.RenderHTML() 讓領域型別依賴 html/template —— 不要搬。

  • ★ 純資料 DTO、策略函式、純核心計算函式,都不是味道。

  • ★ 在 Go 裡先問「跨 package 嗎」。同 package 內讀私有欄位不破壞封裝,成本很低。

  • ★ 自由函式常常是最好的答案,Go 不強迫邏輯掛在型別上。

  • 搬移會撞循環 import(硬性編譯錯誤)。用「介面定義在消費方」解。

  • 不要把邏輯搬到 utils package。utils.CalcCharge(o, t) 只是把依戀情結搬進一個沒有名字的地方。

  • 資料分屬兩個型別、各用一半時,不要硬搬——那通常代表有第三個概念沒被命名(提煉類別)。

🔗 相關工具