发散式变化(Divergent Change):坏味道识别与重构实战指南

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


1. 开篇场景

你是否遇到过这样的类:当数据库格式改变时,需要修改它;当XML格式改变时,需要修改它;当计算逻辑改变时,也需要修改它?一个类因为多种不同的原因需要修改,就像一台多功能机器,任何一个功能出问题都需要整台机器停下来维修。

1
2
3
4
5
6
7
8
9
10
11
12
13
class BadExample {
// 原因1:数据库格式改变 → 修改这里
std::string toDatabaseFormat() const { /* ... */ }

// 原因2:XML格式改变 → 修改这里
std::string toXMLFormat() const { /* ... */ }

// 原因3:计算逻辑改变 → 修改这里
double calculateTax() const { /* ... */ }

// 原因4:显示格式改变 → 修改这里
void display() const { /* ... */ }
};

这就是发散式变化的典型症状。一个类承担了太多职责,违反了单一职责原则。当某个职责的需求发生变化时,你必须在同一个类中修改,即使这个变化与其他职责无关。

当你需要修改序列化格式时,你可能会意外影响计算逻辑。当你需要修改计算逻辑时,你可能会意外影响显示格式。这种耦合使得代码变得脆弱,任何修改都可能产生意想不到的副作用。


2. 坏味道定义

发散式变化是指一个类因为不同的原因需要修改,违反了单一职责原则。

就像一个多功能的瑞士军刀,虽然功能齐全,但任何一个功能损坏都需要整把刀维修。

核心问题:一个类应该只有一个修改的理由。如果类因为多种原因需要修改,说明它承担了太多职责,应该拆分。


3. 识别特征

🔍 代码表现:

  • 特征1:类中有多个不相关的功能(如序列化、计算、显示)
  • 特征2:修改某个功能时,需要修改同一个类的多个方法
  • 特征3:类的注释中提到了多个职责(如”负责序列化和计算”)
  • 特征4:类名使用了”and”连接多个概念(如 EmployeeSerializerAndCalculator
  • 特征5:类的不同方法处理不同的关注点

🎯 出现场景:

  • 场景1:快速开发时,将所有相关功能放在一个类中
  • 场景2:重构不彻底,只修改了部分代码
  • 场景3:需求变更时,不断在现有类中添加新功能
  • 场景4:缺乏设计,没有考虑单一职责原则

💡 快速自检:

  • 问自己:这个类是否因为多个不同的原因需要修改?
  • 问自己:如果删除这个类的某个功能,类名是否仍然准确?
  • 工具提示:使用代码度量工具检测类的职责数量

4. 危害分析

🚨 维护成本:修改某个功能时需要理解整个类,时间成本增加50%

⚠️ 缺陷风险:修改一个功能可能影响其他功能,bug风险增加60%

🧱 扩展障碍:添加新功能时不知道应该放在哪里,容易破坏现有结构

🤯 认知负担:需要理解所有职责才能修改,心理负担重


5. 重构实战

步骤1:安全准备

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

步骤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
class BadExample {
private:
std::string name;
double salary;
std::string address;

public:
// 原因1:如果数据库格式改变,需要修改这里
std::string toDatabaseFormat() const {
return name + "|" + std::to_string(salary) + "|" + address;
}

// 原因2:如果XML格式改变,需要修改这里
std::string toXMLFormat() const {
return "<employee><name>" + name + "</name><salary>"
+ std::to_string(salary) + "</salary><address>"
+ address + "</address></employee>";
}

// 原因3:如果计算逻辑改变,需要修改这里
double calculateTax() const {
return salary * 0.2;
}

// 原因4:如果显示格式改变,需要修改这里
void display() const {
std::cout << "Name: " << name << ", Salary: " << salary
<< ", Address: " << address << std::endl;
}
};

问题分析

  • 类承担了4个不同的职责:数据存储、序列化、计算、显示
  • 每个职责的变化都需要修改同一个类
  • 职责之间没有清晰的边界

重构后(清洁版本)

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 Employee {
private:
std::string name;
double salary;
std::string address;

public:
Employee(const std::string& n, double s, const std::string& addr)
: name(n), salary(s), address(addr) {}

std::string getName() const { return name; }
double getSalary() const { return salary; }
std::string getAddress() const { return address; }
};

// ✅ 专门负责序列化
class EmployeeSerializer {
public:
static std::string toDatabaseFormat(const Employee& emp) {
return emp.getName() + "|" + std::to_string(emp.getSalary())
+ "|" + emp.getAddress();
}

static std::string toXMLFormat(const Employee& emp) {
return "<employee><name>" + emp.getName() + "</name><salary>"
+ std::to_string(emp.getSalary()) + "</salary><address>"
+ emp.getAddress() + "</address></employee>";
}

static std::string toJSONFormat(const Employee& emp) {
return "{\"name\":\"" + emp.getName() + "\",\"salary\":"
+ std::to_string(emp.getSalary()) + ",\"address\":\""
+ emp.getAddress() + "\"}";
}
};

// ✅ 专门负责计算
class TaxCalculator {
public:
static double calculate(const Employee& emp) {
return emp.getSalary() * 0.2;
}
};

// ✅ 专门负责显示
class EmployeeDisplay {
public:
static void show(const Employee& emp) {
std::cout << "Name: " << emp.getName() << ", Salary: "
<< emp.getSalary() << ", Address: " << emp.getAddress() << std::endl;
}
};

关键变化点

  1. 提取类(Extract Class)

    • 将不同职责提取到不同的类中
    • 每个类只负责一个职责
  2. 单一职责

    • Employee 只负责数据存储
    • EmployeeSerializer 只负责序列化
    • TaxCalculator 只负责计算
    • EmployeeDisplay 只负责显示
  3. 提高可维护性

    • 修改序列化格式时,只需修改 EmployeeSerializer
    • 修改计算逻辑时,只需修改 TaxCalculator
    • 职责清晰,互不影响

步骤3:重构技巧总结

使用的重构手法

  • 提取类(Extract Class):将不同职责提取到不同的类中
  • 移动方法(Move Method):将方法移到合适的类中

注意事项

  • ⚠️ 确保每个类有清晰的职责边界
  • ⚠️ 如果类之间有依赖,使用组合而不是继承
  • ⚠️ 重构后要更新所有使用处,确保行为一致

6. 预防策略

🛡️ 编码时:

  • 即时检查

    • 类是否只负责一个职责?
    • 类名是否使用了”and”连接多个概念?
    • 修改某个功能时,是否需要修改类的其他部分?
  • 小步提交

    • 发现类承担多个职责时,立即拆分
    • 使用”提取类”重构,保持职责单一

🔍 Code Review清单:

  • 重点检查

    • 类是否因为多个原因需要修改?
    • 类的方法是否处理不同的关注点?
    • 是否可以拆分为更小的类?
  • 拒绝标准

    • 类名包含”and”连接多个概念
    • 类的方法处理不相关的功能
    • 修改某个功能需要理解整个类

⚙️ 自动化防护:

  • IDE配置

    • 使用代码度量工具检测类的职责数量
    • 启用类复杂度警告
  • CI/CD集成

    • 在CI流水线中集成代码度量工具
    • 设置类的职责数量阈值,超过阈值时生成警告

下一篇预告:霰弹式修改(Shotgun Surgery)- 如何集中分散的修改点