3、代码坏味道(下):发散式变化、霰弹式修改、依恋情结、数据泥团

好,咱们接着聊代码坏味道。上一章讲了那些比较「显眼」的问题,这一章要说的几个,其实更隐蔽,也更致命。它们往往不是一眼能看出来的语法错误,而是设计层面的「慢性病」。

我个人的经验是,这些坏味道一旦出现,项目后期维护成本会指数级上升。你想想看,改一个需求,要动十几个文件,或者一个文件里塞了完全不相关的逻辑——这种代码,谁碰谁头疼。

发散式变化(Divergent Change)

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

举个例子,你有一个 UserService 类,里面既处理用户注册逻辑,又处理发送邮件,还负责生成报表。那么问题来了:

  • 邮件模板改了 → 你要改 UserService
  • 注册规则变了 → 你也要改 UserService
  • 报表格式调整 → 还是改 UserService

一个类,承担了太多「责任」。每次改动,你都得小心翼翼地在这个大泥球里找对应的代码段。改邮件的时候,不小心碰了注册逻辑,线上就炸了。

核心判断标准: 如果一个类,因为多个不同的「外部原因」而需要修改,那就是发散式变化。

我在项目中遇到过这样的案例:一个 OrderService 类,足足有 3000 行。里面混着订单状态机、支付回调、库存扣减、物流查询。每次上线,光是回归测试就要跑半天。后来我们把它拆成了 OrderStateMachinePaymentHandlerInventoryServiceLogisticsService 四个独立的模块。改动范围一下子缩小了 70%。

我的建议: 遵循单一职责原则。一个模块,只因为一个原因而改变。如果发现一个文件里塞了超过两个「业务主题」,就该考虑拆分了。

霰弹式修改(Shotgun Surgery)

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

你想想看,改一个需求,要跑遍十几个文件。改完这个改那个,漏掉一个就出 bug。这就是典型的霰弹式修改。

为什么会这样?因为相关的逻辑被分散到了各个角落。比如,你有一个「用户等级」的概念,结果:

  • UserController 里判断了一次等级
  • OrderService 里又判断了一次
  • DiscountCalculator 里又写了一遍
  • PrivilegeChecker 里还有一份

现在产品说:「用户等级规则要改。」你怎么办?满项目搜索 userLevel,一个一个改。漏一个,线上就出问题。

我曾经踩过的坑: 有一次改支付渠道的费率计算逻辑,我自认为搜得很全了,结果漏掉了一个报表模块里的硬编码。上线后,财务对账对不上,整整排查了两天。从那以后,我见到这种散落各处的重复逻辑,就条件反射地要重构。

解决方案其实很简单:把散落的逻辑收拢到一个地方。比如,把用户等级判断封装成一个 UserLevelService,所有地方都调用它。这样改规则时,只改这一个文件就够了。

坏味道 特征 解决方案
发散式变化 一个类被多个原因修改 拆分职责,单一化
霰弹式修改 一个原因要改多个类 合并逻辑,集中管理

依恋情结(Feature Envy)

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

说白了,就是方法里频繁地调用另一个类的 getter/setter,或者大量操作另一个类的数据。它自己所在类的数据反而用得很少。

看个例子:

class Order {
  calculateDiscount(customer) {
    let discount = 0;
    if (customer.level === 'VIP') {
      discount = customer.totalSpent * 0.2;
    } else if (customer.level === 'Normal') {
      discount = customer.totalSpent * 0.1;
    }
    // 这里大量使用了 customer 的数据
    // Order 自己的数据反而用得很少
    return discount;
  }
}

你看,calculateDiscount 方法里,几乎全是在操作 customer 对象的属性。它「依恋」的是 Customer 类,而不是自己所在的 Order 类。

正确的做法是:把这个方法挪到 Customer 类里去。

class Customer {
  calculateDiscount() {
    let discount = 0;
    if (this.level === 'VIP') {
      discount = this.totalSpent * 0.2;
    } else if (this.level === 'Normal') {
      discount = this.totalSpent * 0.1;
    }
    return discount;
  }
}

嗯,这里要注意:依恋情结的本质是「数据和行为应该在一起」。数据在哪里,操作数据的方法就应该在哪里。

一个小技巧: 写方法的时候,留意一下你调用了多少次 this.,又调用了多少次 其他对象.。如果后者远多于前者,那大概率就是依恋情结。

数据泥团(Data Clump)

这个坏味道,我估计每个程序员都见过。就是几个数据项,总是成群结队地出现

比如:

  • 三个参数:startDateendDateformat
  • 四个参数:citydistrictstreetzipCode
  • 两个参数:widthheight

这些数据项,总是在多个方法签名里同时出现。你复制粘贴了无数次,但从来没想过把它们封装成一个对象。

数据泥团的问题在于:

  • 参数列表越来越长,可读性差
  • 修改时,要改所有出现的地方
  • 容易漏掉某个地方的更新

我个人的习惯是:只要一组数据出现超过两次,就考虑封装

比如上面的地址信息:

// 坏味道:散落的参数
function createUser(city, district, street, zipCode) { ... }
function updateAddress(city, district, street, zipCode) { ... }
function queryByAddress(city, district, street, zipCode) { ... }

// 重构后:封装成 Address 对象
class Address {
  constructor(city, district, street, zipCode) {
    this.city = city;
    this.district = district;
    this.street = street;
    this.zipCode = zipCode;
  }
}

function createUser(address) { ... }
function updateAddress(address) { ... }
function queryByAddress(address) { ... }

封装之后,好处很明显:

  • 参数列表变短了
  • 如果地址结构变了,只改 Address 类就行
  • 代码语义更清晰了
注意: 不要过度封装。如果只是两个简单的数字参数,而且只在同一个方法里出现一次,没必要硬封装成一个对象。数据泥团的关键是「成群结队地出现」,而不是「偶尔结伴」。

好了,这一章的内容就这些。四个坏味道,其实都指向同一个核心问题:代码的组织方式,没有跟上业务逻辑的自然结构

你想想看,业务上明明是一个整体,代码里却散落各处;业务上明明是不同的概念,代码里却混在一起。这就是坏味道的根源。

下一章,咱们聊聊更高级的坏味道——那些涉及继承和多态的「结构性」问题。到时候见。