🎯 什麼情境該想到我
當你「一個函式對『另一個模組』的資料興致勃勃、頻繁存取它的欄位與方法,反而很少碰自己所在類別的資料」的時候。
⚙️ 怎麼用(步驟 / 公式)
這味道是什麼徵兆:程式的一個基本原則是「把一起變化的東西放在一起」——資料與操作它的行為應該相鄰。當某函式花大半力氣在操弄別的物件的資料,代表它其實比較想待在那個物件裡。
通常用哪些重構手法對治:
- 搬移函式(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 大量存取元素欄位——那正是策略的工作(策略模式) |
| ★ 純資料的 DTO | json 對映用的 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(硬性編譯錯誤)。用「介面定義在消費方」解。
-
不要把邏輯搬到
utilspackage。utils.CalcCharge(o, t)只是把依戀情結搬進一個沒有名字的地方。 -
資料分屬兩個型別、各用一半時,不要硬搬——那通常代表有第三個概念沒被命名(提煉類別)。
🔗 相關工具
- 純資料類別 Data Class(只有資料沒行為,行為都跑到別處,最易引發依戀)
- 內幕交易 Insider Trading(模組間過度存取彼此內部)
- 過長函式 Long Function(提煉是搬移前常見的第一步)
- 重構
- 搬移函式 —— 主要對治手法
- 提煉函式 —— 只有一部分在依戀時的前置
- 提煉類別、引入參數物件 —— 資料分屬兩邊時
- 純資料類別 —— 對偶的味道:只有資料沒有行為
- 策略模式 —— 「刻意的依戀」的正當形態
- 拆分階段 —— 純核心函式為什麼不算味道
- 工具-針對介面編程 —— 循環 import 的解法