🎯 什麼情境該想到我
當你「要做一個小改動,卻得同時到很多不同的類別或檔案裡各改一點」的時候——而且很容易漏掉其中一處。
⚙️ 怎麼用(步驟 / 公式)
這味道是什麼徵兆:某一個決策或概念被打散、灑遍程式各處(像霰彈打出去的碎片),導致每次改它都要四處奔波,還很難確定改全了。這與「發散式變化」相反:霰彈是「一種變化、多處要改」。
通常用哪些重構手法對治:
- 搬移函式(Move Function)/搬移欄位(Move Field):把散落各處、屬於同一件事的碼與資料集中到同一個模組。
- 函式組合成類別(Combine Functions into Class)/組合成轉換(Combine Functions into Transform):把處理同一資料的相關函式聚在一起。
- 內聯函式/內聯類別(Inline Function / Inline Class):若碎片本就過度切割,先合回一處再重新切得更合理。
目標:讓每一種變化都對應到「一個要改的地方」。
★ 定義與它的對偶
★ 霰彈式修改:改 1 個需求 → 動 N 個檔案(一個變化軸散落在多處)
★ 發散式變化:改 1 個需求 → 動 1 個檔案的好幾處(一個檔案服務多個變化軸)
★★ 怎麼具體認出來:用 git 量「一起改的檔案」
# ★ 找出「總是一起被修改」的檔案群(時間耦合 / temporal coupling)
git log --format='%H' --name-only | awk '
/^[0-9a-f]{40}$/ {c++; next}
NF {files[c] = files[c] " " $0}
END { for (i in files) print files[i] }
' | tr ' ' '
' | sort | uniq -c | sort -rn | head -20更實用的版本——問一個具體問題:
# ★ 「上次加一種支付方式,動了哪些檔案?」
git show --stat <那個 commit>
# 如果動了 8 個檔案,而其中 6 個都是「加一個 case」→ 就是這個味道★ 這個味道最可靠的證據是「上一次做同類需求時的 diff」。
問團隊「加一種新的 X,要改哪些地方?」——如果答案是一份 checklist,那份 checklist 就是味道本身。
★ Go 裡最常見的四種霰彈式修改
// ① ★ 同一組 switch 散落在多處([[重複的 switch]])
// 加一種 EventKind → 要改 handler.go / validator.go / metrics.go / audit.go
// → 對治:[[以多型取代條件式]],或分派表 + 覆蓋率測試
// ② ★ 加一個欄位要改五個地方
// domain.User → dto.UserResponse → db.userRow → 三個 mapper
// → 對治:減少層數,或讓 mapper 自動產生;★ 先問「這三層真的都需要嗎」
// ③ ★ 按技術分層的目錄結構
// models/ services/ handlers/ repositories/ → 加一個功能要動四個資料夾
// → 對治:改成按領域切(internal/billing/ internal/shipping/),
// ★ 每個領域內部才分層。這是 Go 社群的主流做法
// ④ ★ 常數/設定散落
// 超時值寫在三個地方、重試次數寫在五個地方
// → 對治:[[封裝變數]],收攏到一個 config 型別★ 對治的決策順序
是「同一組 switch 出現在多處」? ── 是 ─▶ [[以多型取代條件式]](或分派表)
│否
是「一堆小函式散在各處,其實屬於同一個概念」? ── 是 ─▶ [[搬移函式]] → [[內聯類別]] → 重新 [[提煉類別]]
│否
是「一個資料的變動要同步好幾份副本」? ── 是 ─▶ 建立單一真相來源([[搬移欄位]])
│否
是「目錄按技術分層」? ── 是 ─▶ ★ 改成按領域切 package
│否
是「重複的邏輯被複製到多處」? ── 是 ─▶ [[提煉函式]]
★ 「先合回去、再重新切」(內聯類別 → 提煉類別)是這個味道最有效的解法——
因為散落的碎片通常是「一開始就切錯了」,不是「切得不夠」。
★★ 什麼時候這其實不是味道
| 情況 | 說明 |
|---|---|
| ★ 跨服務/跨 repo 的變動 | 微服務架構下「加一個欄位要動三個服務」是架構的既定代價,不是程式碼味道。想解要改架構(或接受它) |
| 編譯器/型別系統會抓到全部 | 改介面 → 所有實作編譯失敗 → ★ 編譯器就是你的 checklist,不會漏。這種「散落」風險很低 |
| ★ 資料庫 migration + 程式碼 | 本質上就是兩個地方要同步,不可能只改一處 |
| 只有兩處,而且很少改 | 抽象的成本高於收益 |
★ 判準:漏改會不會被自動抓到?
編譯器抓得到(介面、型別)→ 散落的風險低,可接受。
抓不到(switch 漏 case、設定檔漏一處、mapper 漏一個欄位)→ 這才是真正要處理的。
★ 實務上最高槓桿的一招常常不是重構,而是「讓漏改會失敗」:
一個「所有 kind 都要有 handler」的測試,比重構成多型便宜得多。
🧪 我實際套用的紀錄
- (待填)
⚠️ 注意 / 什麼時候不適用
-
集中之後要小心別讓那個模組吸收太多職責,反而變成「發散式變化」或「過大類別」——兩者要取得平衡。
-
★★ 跨服務的散落是架構代價,不是程式碼味道。 想解要改架構邊界,不是重構一個檔案。
-
★ 編譯器抓得到的散落風險很低(改介面 → 所有實作編譯失敗)。要處理的是「漏改不會被抓到」的那種。
-
★ 有時最好的解不是重構,而是「讓漏改會失敗」:一個覆蓋性測試比拆成多型便宜太多。
-
★ 修這個味道常製造 發散式變化。把散落的東西全收進一個型別 → 那個型別開始服務多個變化軸。
-
只有兩處且很少改 → 別動。抽象的成本是永久的。
-
按技術分層(models/services/handlers)是這個味道的結構性來源。但改成按領域切是大工程,要分階段。
-
用 git 找證據,不要靠感覺。「上次做同類需求動了哪些檔案」是最硬的證據。
🔗 相關工具
- 發散式變化 Divergent Change(一體兩面的對照味道)
- 內幕交易 Insider Trading(碎片散落常伴隨模組間過度耦合)
- 重構
- 發散式變化 —— ★ 對偶的味道;修這個常製造那個
- 以多型取代條件式 —— 散落的 switch
- 重複的 switch —— 最常見的具體形態
- 搬移函式、搬移欄位 —— 把散落的碎片聚攏
- 內聯類別 → 提煉類別 —— ★ 先合回去再重切
- 封裝變數 —— 散落的設定值