坏味道识别(下):发散式变化、霰弹式修改、依恋情结、数据泥团、基本类型偏执

好,我们接着聊。上一章我们讲了几个比较直观的坏味道,这一章要说的这几个,说实话,更隐蔽一些。它们往往藏在代码的「关系」里——类与类之间、函数与数据之间、基本类型与业务含义之间。我刚开始带项目时,经常被这些问题搞得焦头烂额,改一个功能要动七八个文件,改完还总出 bug。后来才明白,这些「坏味道」才是真正拖垮项目的元凶。

一、发散式变化(Divergent Change)

什么叫发散式变化?说白了就是:一个模块,因为不同的原因,需要被反复修改。

举个例子。你有一个 OrderProcessor 类,里面处理订单的创建、验证、折扣计算、库存扣减、发送通知……好家伙,全塞一起了。今天业务方说「折扣规则要改」,你得改这个类;明天说「通知模板要换」,你还得改这个类;后天说「库存校验逻辑要调整」,你依然得改这个类。

你想想看,一个类被不同的人、因为不同的需求、反复打开修改,这本身就是个危险信号。我见过一个项目,一个文件被改了 200 多次,最后没人敢动它,因为一动就崩。

判断标准: 如果你发现一个模块的修改原因超过两个,就该考虑拆分了。

怎么改?把不同变化原因的逻辑拆到不同的类里。比如上面那个例子,可以拆成:

  • OrderValidator —— 负责校验
  • DiscountCalculator —— 负责折扣
  • InventoryManager —— 负责库存
  • NotificationService —— 负责通知

这样,每个类只有一个「变化的原因」。改折扣时,你只需要动 DiscountCalculator,其他代码纹丝不动。这就是我们常说的「单一职责原则」在实践中的体现。

二、霰弹式修改(Shotgun Surgery)

这个和发散式变化正好相反。发散式变化是「一个类被多个原因修改」,霰弹式修改是「一个原因,要修改多个类」。

我举个例子。假设你的系统里,所有地方都直接使用 int 来表示「用户等级」:1 表示普通用户,2 表示 VIP,3 表示 SVIP。有一天,产品说「我们要加一个超级 VIP,等级 4」。好,你开始满世界找:

  • 用户注册模块里有个 if 判断:if (level == 1)
  • 权限校验模块里有个 switch:case 2: ...
  • 折扣计算模块里有个比较:if (level > 1)
  • 甚至某个工具函数里也硬编码了等级数字

你改完一个,还得检查有没有漏掉的。这种「改一个功能,要改七八个文件」的情况,就是典型的霰弹式修改。

我曾经踩过的坑: 有一次改支付渠道的枚举值,我自认为改全了,结果线上还是报错。查了半天,发现有个同事在配置文件中硬编码了旧的枚举字符串。从那以后,我对「散落各处的重复逻辑」格外敏感。

解决方案其实很直接:把散落的逻辑收拢到一个地方。比如上面的用户等级,可以定义一个 UserLevel 枚举,所有地方都引用这个枚举。以后加新等级,只改这一个地方就够了。

三、依恋情结(Feature Envy)

这个名字有点文艺,但意思很直白:一个函数,对另一个类的数据「过分依恋」。

怎么判断?你看一个函数里,调用自己的数据少,调用别人的 getter 方法多。比如:

// 坏味道:OrderService 对 Customer 的数据过于依恋
double calculateDiscount(Order order, Customer customer) {
    if (customer.getLevel() == VIP && customer.getTotalSpent() > 10000) {
        return order.getAmount() * 0.2;
    }
    return 0;
}

这个函数明明属于 OrderService,但它一直在问 Customer 要数据。说白了,它更想待在 Customer 类里。

我个人习惯的做法是:把这种函数挪到它「依恋」的那个类里。比如:

// 重构后:放到 Customer 类中
class Customer {
    double calculateDiscount(Order order) {
        if (this.level == VIP && this.totalSpent > 10000) {
            return order.getAmount() * 0.2;
        }
        return 0;
    }
}

嗯,这里要注意:不是所有「调用别的类的方法」都是依恋情结。关键看「数据归属」。如果函数主要操作的是自己的数据,只是偶尔调用别的类,那没问题。但如果它大部分时间都在「问别人要数据」,就该挪了。

四、数据泥团(Data Clumps)

这个坏味道,我估计每个人都见过,只是没意识到它是个问题。

数据泥团指的是:多个数据项总是同时出现,像黏在一起的泥团。比如:

  • 函数的参数列表里,总是同时出现 startDate 和 endDate
  • 多个类的字段里,都有 street、city、zipCode
  • 多个方法都返回 (x, y, width, height) 这样的组合

为什么这是个问题?因为一旦这些数据需要修改(比如地址要加一个 country 字段),你就要改所有出现的地方。而且,这些数据之间往往存在业务关联,把它们拆散反而丢失了语义。

我的经验: 如果你发现一组数据在三个以上的地方同时出现,就应该考虑把它们封装成一个类。比如把 startDate 和 endDate 封装成 DateRange,把 street、city、zipCode 封装成 Address。

封装之后的好处很明显:

  • 参数列表变短了,函数签名更清晰
  • 相关的逻辑(比如日期校验、地址格式化)可以集中到新类里
  • 以后加字段,只改一个地方

五、基本类型偏执(Primitive Obsession)

这个坏味道,说白了就是:滥用基本类型来表示业务概念。

比如:

  • 用 int 表示「用户等级」
  • 用 char* 表示「邮箱地址」
  • 用 double 表示「金额」
  • 用 int 表示「订单状态」

你想想看,一个 int 能做的事情太多了:你可以给它赋值 999,你可以对它做加减乘除,你甚至可以把它当布尔值用。但「用户等级」需要这些操作吗?不需要。它需要的是一组有限的、有意义的取值。

我见过最离谱的代码,是用 int 表示「性别」:0 表示未知,1 表示男,2 表示女。然后有个地方写了个 if (gender == 3)……你猜后来发生了什么?

解决方案: 为每个业务概念定义专属的类型。在 C 语言里,可以用枚举、结构体,或者简单的 typedef。比如:
// 坏味道
int level = 2;  // 2 表示 VIP

// 好味道
typedef enum {
    USER_NORMAL,
    USER_VIP,
    USER_SVIP
} UserLevel;

UserLevel level = USER_VIP;

这样做的好处:

  • 代码自文档化——看到 USER_VIP 就知道含义
  • 编译器帮你检查——你不可能给 UserLevel 赋一个 999
  • 修改时集中——加一个新等级,只改枚举定义

其实,基本类型偏执的本质是「懒」。懒得定义新类型,觉得用 int 省事。但省事的代价,就是后续无尽的维护成本。我个人现在写代码,只要看到一个基本类型被用来表示业务概念,就会条件反射地把它封装起来。

本章小结

这五种坏味道,其实都指向同一个核心问题:代码没有正确表达业务语义。

  • 发散式变化和霰弹式修改,是「职责分配」出了问题
  • 依恋情结,是「数据归属」出了问题
  • 数据泥团和基本类型偏执,是「抽象层次」出了问题

识别它们并不难,难的是每次看到时都愿意停下来想一想:「这段代码,是不是可以写得更好?」

嗯,下一章我们会聊一些更「高级」的坏味道,比如过度耦合、循环依赖这些。但今天这五个,已经足够你在日常项目中抓出一大堆问题了。试试看,从你手头的代码里找找这些味道——我保证,你会有惊喜(或者说惊吓)。


可可精品资料库