「只是新增一種付款方式,為什麼要花這麼久?」
這是上一篇文章 程式碼異味 #4 的延續。
這是產品規劃端常問的問題,對開發者來說也很難回答。
因為實際上有 12 個地方要修改:在 enum 新增案例、修改 5 個 switch、圖示對應表、字串常數、分析事件名稱、伺服器參數轉換,以及測試 fixture。
每項修改只有兩三行,也沒有什麼困難。困難的是不漏掉任何一處地找出這 12 個地方。
Martin Fowler 在《重構》中為這種情況整理了名稱:散彈式修改(Shotgun Surgery)(作者原文)。
名稱精準的原因
散彈槍射出一發子彈後,彈丸會向四處散開。意思是單一變更會以散落在程式碼庫各處的小幅修改呈現。
每個傷口都不深,但數量很多,漏掉任何一個都會造成問題。
真正的問題發生在漏改時。12 個地方只改了 11 個就部署,剩下的那一處可能只在特定條件下才暴露。
付款畫面運作正常,卻只有收據畫面顯示「未知的付款方式」。散彈式修改的成本不是修改時間,而是遺漏機率。
還有一個鏡像異味
同一份清單中有一種完全相反的異味:發散式變更(Divergent Change,部分譯本稱為「糾結式變更」)。
- 發散式變更:同一模組因多個彼此不同的原因而頻繁變更。即使更換資料庫,也要修改這個檔案;即使付款規則改變,也要修改這個檔案。
- 散彈式修改:單一原因的變更會影響多個模組(Shotgun Surgery 原文)。
把兩者的關係畫出來,就會發現軸線顛倒了。
| 發散式變更 | 散彈式修改 | |
|---|---|---|
| 關注點與程式碼 | 一處有多個關注點 | 一個關注點分布在多處 |
| 症狀 | 這個檔案持續變更 | 這項變更持續擴散 |
| 處方 | 拆分 | 集中 |
處方方向完全相反,這一點很重要。談到程式碼異味時,通常會導向「拆開」,但散彈式修改的解法是集中。
如果誤判問題所在,處方就會朝完全相反的方向前進。
區分兩者的軸線只有一個:變更原因。
這就是為什麼單一職責原則不是定義為「只能做一件事」,而是「只能有一個變更原因」。工作的數量因人而異,但變更原因可以從實際提交紀錄確認。
透過提交紀錄實測
可以使用資料取代感覺。散彈式修改會呈現為總是一起變更的檔案。
這稱為變更耦合(change coupling)或邏輯耦合。
import重點在於,這是關係上看不見的耦合。如果兩個檔案彼此沒有參照,卻總是出現在同一筆提交中,就表示有未寫入程式碼的規則將它們綁在一起。
統計最近提交中一起變更的檔案配對,就能找出候選對象。
git log --format='%H' --since=6.months.ago | while read c; do
git show --format= --name-only "$c" | grep '\.swift$' | sort | \
awk 'NR==FNR{a[NR]=$0;n=NR} END{for(i=1;i<n;i++)for(j=i+1;j<=n;j++)print a[i]" + "a[j]}'
done | sort | uniq -c | sort -rn | head -20
接著確認排名前面的配對是否真的應該屬於同一段程式碼。
當然,也有自然會一起變更的配對,例如實作與其測試檔案。
篩選後留下的,就是值得檢查的對象。
集中方法
**將分散的條件分支集中到單一型別中。**這是最常見的形式。
// 之前:認識付款方式的邏輯 switch散落在專案各處
func iconName(for method: PaymentMethod) -> String {
switch method {
case .card: return "creditcard"
case .transfer: return "building.columns"
}
}
func displayName(for method: PaymentMethod) -> String { ... }
func serverCode(for method: PaymentMethod) -> String { ... }
如果這三個函式位於不同檔案,每新增一種付款方式,就得找遍三個地方。集中到單一型別後,就只剩一個地方。
// 之後:只要查看一個地方
extension PaymentMethod {
var iconName: String { ... }
var displayName: String { ... }
var serverCode: String { ... }
}
**把遺漏偵測交給編譯器。**在 Swift 中,switch必須處理所有案例。
在 enum 新增案例後,所有缺少default的switch都會產生編譯錯誤。這無法消除散彈式修改,卻能把遺漏從執行階段移到編譯階段。
既然前面提到真正的成本是遺漏機率,光是這點就會改變問題的性質。
因此,不要習慣性地在處理 enum 的switch中加入default: break。那等於親手切斷這層安全網。
**將字串鍵改成型別。**如果分析事件名稱、使用者預設值鍵、通知名稱都以字串常值散落,編譯器就無法提供協助。
將它們集中成一處的常數,或用型別包裝起來,就能讓新增與修改只剩一個位置。
**將設定變成資料。**如果每種付款方式都需要圖示、名稱與代碼,也可以用一個結構陣列定義,而不是散落在程式碼中。
新增方式只需在陣列中加入一個項目。
**難以集中時,至少留下標記。**有些情況無法實際集中。
例如伺服器與用戶端必須共用相同規則。
這時可以在各處加上互相指向的註解,或加入值不一致就失敗的測試。既然不得不依賴人的記憶,至少把需要記住的位置寫下來。
拆分與集中之間
這裡會接上前幾篇文章。程式碼異味的處方大致只有拆分與集中兩個方向,但把任一方向推得太過頭,就會變成另一種異味。
- 為了修正發散式變更而拆分 → Ravioli Code
- 為了修正散彈式修改而集中 → God Object
- 拆成階層 → Lasagna Code
因此,不應把片段的大小或數量當成目標。目標只有一個。
**一起變更的放在一起,分開變更的分開放。**內聚力與耦合度最終表達的也是這句話。
判斷模糊時,可以問自己一個問題:「最近一次為什麼修改這段程式碼?」試著回想大約三次。
如果每次原因都不同,就該拆分;如果每次都因相同原因修改多個地方,就該集中。
總結
- 散彈式修改是指單一變更分散成多個檔案中的小幅修改。
- 真正的成本不是修改時間,而是遺漏機率。
- 完全相反的發散式變更,是同一檔案因多個原因而變更,處方也完全相反。一種要集中,另一種要拆分。
- 從提交紀錄找出總是一起變更的檔案配對,就能實測候選對象。
- 在 Swift 中,enum 與
switch的完整檢查能將遺漏轉為編譯錯誤。不習慣性加入default,就是守住這層安全網的方法。
下一篇將介紹讓所有這些異味有了同一個名稱的比喻:技術債。
Ward Cunningham 所說的原始含義並不是「髒亂的程式碼」。
來源與確認標準
- Refactoring — Martin Fowler · 作者原文 · 確認 2026-08-17 · 依據:Shotgun Surgery、Divergent Change 程式碼異味與重構原則
- The Shotgun Surgery Problem — Martin Fowler · 作者原文 · 確認 2026-08-17 · 依據:變更分散在多個模組中的散彈式修改案例

![[程式碼異味 #5] 散彈式修改 vs 發散式變更 封面圖](/assets/images/posts/d4a472ee-9fec-4ab5-9b61-0e9875b3dae5/shotgun-surgery-scattered-changes.jpg)