5、代码坏味道详解(中):发散式变化、霰弹式修改、依恋情结、数据泥团、基本类型偏执、switch语句

好,我们继续聊代码坏味道。上一章我们讲了几个常见的“臭味”,这一章我挑了几个在实际项目中特别容易踩坑的。说实话,这些坏味道往往不是单独出现的,它们经常抱团,一个引出一个。你想想看,一个设计不合理的模块,往往同时散发着好几种臭味。

我个人习惯是,闻到一种味道,就顺藤摸瓜,把相关的坏味道一起揪出来。这样重构起来才彻底。好,我们一个一个来看。

5.1 发散式变化 (Divergent Change)

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

举个例子,你有一个 OrderService 类,里面既处理订单计算,又处理数据库保存,还负责发送邮件通知。这时候,如果价格规则变了,你要改这个类;如果数据库表结构变了,你也要改这个类;如果邮件模板变了,你还是要改这个类。这就是典型的发散式变化。

核心判断标准: 问自己一个问题——“这个类,有多少种不同的理由会被修改?” 如果答案超过一个,就该考虑拆分了。

我在项目中遇到过这样一个案例:一个报表生成类,里面混杂着数据查询、格式转换、文件导出、权限校验。每次需求变更,哪怕只是改个表头颜色,都要在这个上千行的类里找半天。后来我们把它拆成了 DataFetcherReportFormatterFileExporterAccessValidator 四个类,每个类只有一个修改理由。清爽多了。

重构手法: 提取类 (Extract Class)。把每个变化原因对应的代码,提取到独立的类中。

5.2 霰弹式修改 (Shotgun Surgery)

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

比如,你要修改某个业务逻辑,结果发现需要改动 OrderControllerOrderServiceOrderRepositoryOrderValidator 四个文件。每改一个地方,都要小心翼翼,生怕漏掉。这就是霰弹式修改。

为什么会这样?通常是因为某个逻辑被分散到了多个地方,没有集中管理。比如,订单状态的判断逻辑,散落在各个类里,而不是封装在一个 OrderStatus 枚举或策略类中。

我曾经踩过的坑: 有一次改一个支付系统的汇率计算逻辑,我改了 7 个文件,结果还是漏了一个地方,导致线上汇率计算错误。后来我把所有汇率相关的逻辑收拢到一个 ExchangeRateService 里,再也没出过这种问题。

重构手法: 移动方法 (Move Method) 和 内联类 (Inline Class)。把分散的逻辑收拢到一起。

5.3 依恋情结 (Feature Envy)

这个坏味道的名字有点文艺,但意思很直白:一个方法,对另一个类的数据或方法,比对自己所在类的更感兴趣

看个例子:

public class Order {
    private double amount;
    private Customer customer;

    public double calculateDiscount() {
        // 这个方法大量使用了 customer 的数据
        if (customer.isVip() && customer.getTotalOrders() > 10) {
            return amount * 0.2;
        }
        return amount * 0.1;
    }
}

你看,calculateDiscount() 方法里,频繁调用 customerisVip()getTotalOrders()。这个方法明显更“依恋” Customer 类。正确的做法是,把这个方法移到 Customer 类中。

我个人习惯是,看到一个方法里,有超过一半的调用都是针对另一个对象的,我就会考虑把它搬过去。这能让类的内聚性更高。

判断技巧: 把方法里的 this 调用和 otherObject 调用数一数。如果 otherObject 的调用次数明显更多,那就是依恋情结。

5.4 数据泥团 (Data Clumps)

数据泥团,就是一堆数据项经常一起出现,出现在多个类的字段里,或者多个方法的参数列表里。

比如,你经常看到 nameaddressphone 三个字段同时出现。或者方法参数里总是 (int x, int y, int width, int height) 一起传。这就是数据泥团。

为什么不把它们封装成一个对象呢?比如 Address 类或 Rectangle 类。

我记得有一次重构一个物流系统,发现 originCityoriginProvincedestCitydestProvince 这四个字段在十几个类里重复出现。我们把它封装成了 LocationRoute 两个类,代码量直接减少了 30%,而且逻辑清晰了很多。

核心原则: 如果你看到三个或以上的数据项总是同时出现,就考虑把它们封装成一个对象。

5.5 基本类型偏执 (Primitive Obsession)

这个坏味道,说白了就是滥用基本类型,比如用 String 表示电话号码、用 int 表示金额、用 String 表示颜色代码。

基本类型本身没问题,但业务概念不应该用基本类型来表达。比如:

// 坏味道
public void setPrice(double price) { ... }
public void setPhoneNumber(String phone) { ... }

// 好味道
public void setPrice(Money price) { ... }
public void setPhoneNumber(PhoneNumber phone) { ... }

用基本类型,你无法做类型校验,也无法把相关行为(比如格式化、验证)封装在一起。你想想看,如果 PhoneNumber 类里自带 isValid()format() 方法,是不是比到处写 phone.matches("^1[3-9]\\d{9}$") 要优雅得多?

注意: 不要过度封装。如果一个基本类型只是简单传递,没有任何业务逻辑,那暂时用基本类型也没问题。等它开始“长胖”了,再提取也不迟。

5.6 switch 语句 (Switch Statements)

这里的 switch 语句,不单指 Java 里的 switch,也包括大量的 if-else if 链。它们的问题是一样的:根据某个类型或枚举值,做不同的分支处理

比如:

public double calculateShipping(String type) {
    switch (type) {
        case "standard": return 5.0;
        case "express": return 15.0;
        case "overnight": return 25.0;
        default: throw new IllegalArgumentException();
    }
}

这种代码的问题在于,每次新增一个类型,你都要修改这个 switch 语句。很容易漏掉某个分支,而且 switch 语句会随着时间越来越长。

我建议用多态来替代 switch。把每个分支的逻辑封装到独立的类中:

public interface ShippingStrategy {
    double calculate();
}

public class StandardShipping implements ShippingStrategy {
    public double calculate() { return 5.0; }
}

public class ExpressShipping implements ShippingStrategy {
    public double calculate() { return 15.0; }
}

这样,新增一种运输方式,只需要新增一个类,不需要修改现有代码。符合开闭原则。

例外情况: 如果 switch 语句只在一个地方出现,并且分支很少(比如 2-3 个),而且不太可能变化,那保留 switch 也可以。不要为了重构而重构。

知识体系总览

下面这张图,把这六种坏味道的核心逻辑串了起来。你可以看到,它们本质上都是职责分配不合理的问题。

代码坏味道详解(中):核心逻辑关系 核心根源:职责分配不合理 从“修改”维度看 发散式变化 一个类,多种修改原因 霰弹式修改 一个修改原因,改多个类 从“数据与行为”维度看 依恋情结 方法更“爱”别的类 数据泥团 数据项总是一起出现 基本类型偏执 用基本类型代替业务概念 switch 语句 用多态替代分支

嗯,这一章的内容就到这里。这六种坏味道,你在日常开发中肯定见过不少。下次闻到类似的味道,别急着动手改,先想清楚它属于哪一种,然后对症下药。重构不是炫技,而是为了让代码更容易理解和修改。


公众号:蓝海资料掘金营,微信deep3321