坏味道识别(下):发散式变化、霰弹式修改、依恋情结、数据泥团、基本类型偏执
好,我们接着聊。上一章我们讲了几个比较直观的坏味道,这一章要说的这几个,说实话,更隐蔽一些。它们往往藏在代码的「关系」里——类与类之间、函数与数据之间、基本类型与业务含义之间。我刚开始带项目时,经常被这些问题搞得焦头烂额,改一个功能要动七八个文件,改完还总出 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)……你猜后来发生了什么?
// 坏味道
int level = 2; // 2 表示 VIP
// 好味道
typedef enum {
USER_NORMAL,
USER_VIP,
USER_SVIP
} UserLevel;
UserLevel level = USER_VIP;
这样做的好处:
- 代码自文档化——看到
USER_VIP就知道含义 - 编译器帮你检查——你不可能给
UserLevel赋一个 999 - 修改时集中——加一个新等级,只改枚举定义
其实,基本类型偏执的本质是「懒」。懒得定义新类型,觉得用 int 省事。但省事的代价,就是后续无尽的维护成本。我个人现在写代码,只要看到一个基本类型被用来表示业务概念,就会条件反射地把它封装起来。
本章小结
这五种坏味道,其实都指向同一个核心问题:代码没有正确表达业务语义。
- 发散式变化和霰弹式修改,是「职责分配」出了问题
- 依恋情结,是「数据归属」出了问题
- 数据泥团和基本类型偏执,是「抽象层次」出了问题
识别它们并不难,难的是每次看到时都愿意停下来想一想:「这段代码,是不是可以写得更好?」
嗯,下一章我们会聊一些更「高级」的坏味道,比如过度耦合、循环依赖这些。但今天这五个,已经足够你在日常项目中抓出一大堆问题了。试试看,从你手头的代码里找找这些味道——我保证,你会有惊喜(或者说惊吓)。