测试与代码质量

[代码异味 #5] 散弹式修改 vs 发散式变化

散弹式修改是指一项变更分散在多个文件中的异味,而发散式变化则是它的镜像:一个文件因多个原因而修改。本文总结如何通过提交记录实测并区分这两种处理方式完全相反的异味。

6 分钟阅读
[代码异味 #5] 散弹式修改 vs 发散式变化 封面图

“只是新增一种支付方式,为什么要花这么久?”

这是上一篇文章 代码异味 #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 · 依据:变更分散到多个模块中的散弹式修改案例

延伸阅读

代码异味系列