“只是新增一种支付方式,为什么要花这么久?”
这是上一篇文章 代码异味 #4 的后续内容。
这是产品规划部门经常提出的问题,对开发者来说也很难回答。
因为实际上有十二个地方需要修改:给 enum 添加 case、修改五处 switch、图标映射表、字符串常量、分析事件名称、服务器参数转换和测试 fixture。
每处修改只有两三行,并不困难。困难的是不漏掉任何一处地找到这十二个地方。
Martin Fowler 在《重构》中为这种情况整理了一个名称:散弹式修改(Shotgun Surgery)(作者原文)。
为什么这个名字很准确
散弹枪射出一发子弹后,弹丸会向四处散开。这个名称表示一项变更会以散落在代码库各处的小修改形式出现。
每个伤口都不深,但数量很多,漏掉任何一个都会带来问题。
真正的问题出现在遗漏时。十二个地方只改了十一个就发布,剩下的一个可能只在特定条件下暴露。
支付页面运行正常,但收据页面显示“未知支付方式”。散弹式修改的成本不是修改时间,而是遗漏概率。
还有一个镜像异味
同一份列表中还有完全相反的异味:发散式变化(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必须处理所有 case。
给 enum 添加 case 后,所有没有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)