依恋情结(Feature Envy):坏味道识别与重构实战指南

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


1. 开篇场景

你是否遇到过这样的方法:它在一个类中,但大部分时间都在访问另一个类的数据,就像一个人住在A城市,但每天都要跑到B城市去工作?

1
2
3
4
5
6
7
8
9
class OrderCalculator {
public:
double calculateTotal(Order& order) {
// 这个方法频繁访问 Order 的字段,应该属于 Order 类
double discountedPrice = order.basePrice * (1.0 - order.discount);
double total = discountedPrice + (discountedPrice * order.tax);
return total;
}
};

这就是依恋情结的典型症状。一个方法过度使用另一个类的数据,应该移到那个类中。方法对另一个类的”依恋”超过了对自己所在类的依赖。

当你需要修改计算逻辑时,你必须在 OrderCalculator 中修改,但逻辑实际上应该属于 Order 类。这种设计使得代码的组织结构不合理,增加了维护的难度。


2. 坏味道定义

依恋情结是指一个方法过度使用另一个类的数据,应该移到那个类中。

就像一个员工总是跑到其他部门去工作,虽然完成了任务,但组织结构不合理。

核心问题:方法应该和它操作的数据放在一起。如果方法频繁访问另一个类的数据,说明它应该属于那个类。


3. 识别特征

🔍 代码表现:

  • 特征1:方法频繁访问另一个类的字段
  • 特征2:方法对另一个类的访问次数超过对自己类的访问
  • 特征3:方法名暗示它应该属于另一个类(如 calculateOrderTotalCalculator 类中)
  • 特征4:方法的主要逻辑都在操作另一个类的数据
  • 特征5:方法需要另一个类的多个字段才能工作

🎯 出现场景:

  • 场景1:快速开发时,将方法放在错误的类中
  • 场景2:重构不彻底,只移动了部分代码
  • 场景3:缺乏设计,没有考虑方法的归属
  • 场景4:从过程式编程迁移到面向对象时,没有重构方法位置

💡 快速自检:

  • 问自己:这个方法访问哪个类的数据最多?
  • 问自己:如果这个方法移到另一个类中,是否更合理?
  • 工具提示:使用代码度量工具检测方法对类的依赖关系

4. 危害分析

🚨 维护成本:修改逻辑时需要在错误的类中修改,时间成本增加30%

⚠️ 缺陷风险:方法位置不合理,容易产生理解错误,bug风险增加40%

🧱 扩展障碍:添加新功能时不知道应该放在哪个类中

🤯 认知负担:需要理解方法为什么在这个类中,增加了心理负担


5. 重构实战

步骤1:安全准备

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

步骤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
class Order {
public:
double basePrice;
double discount;
double tax;

Order(double price, double disc, double t)
: basePrice(price), discount(disc), tax(t) {}
};

// 坏味道:OrderCalculator 过度依赖 Order 的数据
class OrderCalculator {
public:
double calculateTotal(Order& order) {
// 这个方法频繁访问 Order 的字段,应该属于 Order 类
double discountedPrice = order.basePrice * (1.0 - order.discount);
double total = discountedPrice + (discountedPrice * order.tax);
return total;
}

void printOrder(Order& order) {
// 这个方法也过度使用 Order 的数据
std::cout << "Base Price: " << order.basePrice
<< ", Discount: " << order.discount
<< ", Tax: " << order.tax << std::endl;
}
};

问题分析

  • calculateTotal 方法在 OrderCalculator 类中,但主要操作 Order 的数据
  • printOrder 方法也在 OrderCalculator 类中,但主要操作 Order 的数据
  • 这些方法应该属于 Order

重构后(清洁版本)

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
// 好的做法:将方法移到数据所在的类
class GoodOrder {
private:
double basePrice;
double discount;
double tax;

public:
GoodOrder(double price, double disc, double t)
: basePrice(price), discount(disc), tax(t) {}

// ✅ 方法在数据所在的类中
double calculateTotal() const {
double discountedPrice = basePrice * (1.0 - discount);
double total = discountedPrice + (discountedPrice * tax);
return total;
}

// ✅ 方法在数据所在的类中
void print() const {
std::cout << "Base Price: " << basePrice
<< ", Discount: " << discount
<< ", Tax: " << tax << std::endl;
}

double getBasePrice() const { return basePrice; }
double getDiscount() const { return discount; }
double getTax() const { return tax; }
};

关键变化点

  1. 移动方法(Move Method)

    • calculateTotal 方法从 OrderCalculator 移到 Order
    • printOrder 方法从 OrderCalculator 移到 Order
  2. 封装数据

    • Order 的字段改为私有
    • 通过方法访问数据,而不是直接访问字段
  3. 提高内聚性

    • 方法和数据在同一个类中
    • 类的职责更清晰

步骤3:重构技巧总结

使用的重构手法

  • 移动方法(Move Method):将方法移到它应该属于的类中
  • 封装字段(Encapsulate Field):将字段封装为私有,通过方法访问

注意事项

  • ⚠️ 确保方法移到目标类后,不会破坏现有功能
  • ⚠️ 如果方法需要访问原类的其他成员,考虑使用组合
  • ⚠️ 重构后要更新所有调用处,确保行为一致

6. 预防策略

🛡️ 编码时:

  • 即时检查

    • 方法是否频繁访问另一个类的数据?
    • 方法是否应该属于另一个类?
    • 使用IDE的代码分析工具,检测方法的依赖关系
  • 小步提交

    • 发现方法位置不合理时,立即移动
    • 使用”移动方法”重构,保持方法在正确的类中

🔍 Code Review清单:

  • 重点检查

    • 方法是否过度依赖另一个类的数据?
    • 方法是否应该移到另一个类中?
    • 类的职责是否清晰?
  • 拒绝标准

    • 方法频繁访问另一个类的数据
    • 方法的主要逻辑都在操作另一个类的数据
    • 方法名暗示它应该属于另一个类

⚙️ 自动化防护:

  • IDE配置

    • 使用代码度量工具检测方法的依赖关系
    • 启用方法位置警告
  • CI/CD集成

    • 在CI流水线中集成代码度量工具
    • 检测方法的依赖关系,生成警告报告

下一篇预告:数据泥团(Data Clumps)- 如何将总是同时出现的数据组合成对象