霰弹式修改(Shotgun Surgery):坏味道识别与重构实战指南

24种代码坏味道系列 · 第8篇


1. 开篇场景

你是否遇到过这样的场景:需要修改用户验证规则(如最小长度从3改为5),却发现需要在 UserServiceUserControllerUserRepository 等多个类中逐一修改相同的验证逻辑?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 需要在多个类中修改相同的验证逻辑
class UserService {
void createUser(const std::string& username) {
if (username.length() < 3) { // 需要修改这里
// ...
}
}
};

class UserController {
void registerUser(const std::string& username) {
if (username.length() < 3) { // 还需要修改这里
// ...
}
}
};

class UserRepository {
void saveUser(const std::string& username) {
if (username.length() < 3) { // 又要修改这里
// ...
}
}
};

这就是霰弹式修改的典型症状。一个变化需要在多个类中修改,就像用霰弹枪打靶,需要开很多枪才能命中目标。每次修改都需要在多个地方同步进行,容易遗漏某个地方,导致系统行为不一致。

当你需要修改业务规则时(如验证规则、计算逻辑),你必须在多个类中逐一修改。这不仅浪费时间,更重要的是容易出错——漏掉一个地方,就会导致系统行为不一致,产生难以追踪的bug。


2. 坏味道定义

霰弹式修改是指一个变化需要在多个类中修改,违反了开闭原则(对扩展开放,对修改关闭)。

就像同一首歌的多个翻唱版本,当需要修改歌词时,必须同步更新所有版本,否则就会出现不一致。

核心问题:相关的逻辑应该集中在一个地方。如果相同的逻辑分散在多个类中,修改时需要同步修改多个地方,增加了维护成本和出错概率。


3. 识别特征

🔍 代码表现:

  • 特征1:相同的逻辑在多个类中重复出现
  • 特征2:修改某个业务规则时,需要在多个类中修改
  • 特征3:多个类中有相似的代码模式
  • 特征4:类的职责不清晰,包含了不属于它的逻辑
  • 特征5:修改一个功能需要修改多个不相关的类

🎯 出现场景:

  • 场景1:快速开发时,在多个类中复制粘贴相同的逻辑
  • 场景2:重构不彻底,只修改了部分重复代码
  • 场景3:缺乏设计,没有考虑代码复用
  • 场景4:业务规则变化时,在多个地方添加相同的逻辑

💡 快速自检:

  • 问自己:如果这个业务规则需要修改,我需要在几个类中修改?
  • 问自己:这些类中是否有相同的逻辑可以提取?
  • 工具提示:使用 grep 或 IDE 的”查找相似代码”功能,可以快速发现重复模式

4. 危害分析

🚨 维护成本:修改一个业务规则需要在多个类中同步修改,时间成本增加2-3倍

⚠️ 缺陷风险:容易遗漏某些地方的修改,导致bug,风险增加70%

🧱 扩展障碍:添加新功能时不知道应该在哪里修改,容易产生新的重复

🤯 认知负担:需要记住哪些类中有相同的逻辑,增加了心理负担


5. 重构实战

步骤1:安全准备

  • ✅ 确保有完整的单元测试覆盖
  • ✅ 创建重构分支:git checkout -b refactor/consolidate-duplicate-logic
  • ✅ 使用版本控制,便于回滚

步骤2:逐步重构

重构前(问题代码)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// 坏味道:如果要改变用户验证逻辑,需要在多个地方修改
class UserService {
public:
void createUser(const std::string& username) {
// 这里也有验证逻辑
if (username.length() < 3) {
std::cout << "Username too short" << std::endl;
return;
}
std::cout << "User created: " << username << std::endl;
}
};

class UserController {
public:
void registerUser(const std::string& username) {
// 这里也有验证逻辑
if (username.length() < 3) {
std::cout << "Invalid username" << std::endl;
return;
}
std::cout << "User registered: " << username << std::endl;
}
};

class UserRepository {
public:
void saveUser(const std::string& username) {
// 这里也有验证逻辑
if (username.length() < 3) {
std::cout << "Cannot save invalid username" << std::endl;
return;
}
std::cout << "User saved: " << username << std::endl;
}
};

问题分析

  • 验证逻辑分散在3个不同的类中
  • 如果验证规则改变(如最小长度改为5),需要在3个地方修改
  • 验证逻辑可能不一致(错误消息不同)

重构后(清洁版本)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// ✅ 集中验证逻辑
class UserValidationRules {
public:
static bool isValidUsername(const std::string& username) {
if (username.empty()) {
return false;
}
if (username.length() < 3) {
return false;
}
if (username.length() > 20) {
return false;
}
// 可以添加更多验证规则
return true;
}
};

class GoodUserService {
public:
void createUser(const std::string& username) {
// ✅ 使用集中的验证逻辑
if (!UserValidationRules::isValidUsername(username)) {
std::cout << "Invalid username" << std::endl;
return;
}
std::cout << "User created: " << username << std::endl;
}
};

class GoodUserController {
public:
void registerUser(const std::string& username) {
// ✅ 使用集中的验证逻辑
if (!UserValidationRules::isValidUsername(username)) {
std::cout << "Invalid username" << std::endl;
return;
}
std::cout << "User registered: " << username << std::endl;
}
};

class GoodUserRepository {
public:
void saveUser(const std::string& username) {
// ✅ 使用集中的验证逻辑
if (!UserValidationRules::isValidUsername(username)) {
std::cout << "Cannot save invalid username" << std::endl;
return;
}
std::cout << "User saved: " << username << std::endl;
}
};

关键变化点

  1. 提取类(Extract Class)

    • 将验证逻辑提取到 UserValidationRules 类中
    • 所有类都使用同一个验证规则
  2. 集中管理

    • 验证规则集中在一个地方
    • 修改验证规则时,只需修改 UserValidationRules
  3. 统一行为

    • 所有类使用相同的验证逻辑
    • 确保系统行为一致

步骤3:重构技巧总结

使用的重构手法

  • 提取类(Extract Class):将分散的逻辑提取到专门的类中
  • 移动方法(Move Method):将方法移到合适的类中
  • 内联方法(Inline Method):将重复的方法调用替换为统一的调用

注意事项

  • ⚠️ 确保提取的类有清晰的职责
  • ⚠️ 如果逻辑有细微差异,先统一差异,再提取
  • ⚠️ 重构后要运行所有测试,确保行为一致

6. 预防策略

🛡️ 编码时:

  • 即时检查

    • 发现相同的逻辑在多个类中出现时,立即提取
    • 使用IDE的”查找相似代码”功能,及时发现重复
    • 编写新功能时,先检查是否有类似的现有代码可以复用
  • 小步提交

    • 每次提交前检查是否有重复逻辑
    • 如果发现重复,立即重构后再提交

🔍 Code Review清单:

  • 重点检查

    • 新代码是否与现有代码重复?
    • 是否有可以提取的公共逻辑?
    • 业务规则是否集中管理?
  • 拒绝标准

    • 相同的逻辑在多个类中重复
    • 修改业务规则需要在多个类中修改
    • 可以复用但没有复用的代码

⚙️ 自动化防护:

  • IDE配置

    • 启用代码重复检测插件
    • 配置相似代码阈值
  • CI/CD集成

    • 在CI流水线中集成代码重复检测工具
    • 设置重复代码阈值,超过阈值时阻止合并
    • 定期生成代码重复报告

下一篇预告:依恋情结(Feature Envy)- 如何让方法待在它应该待的地方