第44章:代码坏味道:霰弹式修改 - 实战搬移函数与提炼类

说实话,霰弹式修改这名字挺形象的。你想想看,改一个需求,得跑到七八个类里去改代码,就像霰弹枪打出去,弹片散得到处都是。我在项目里见过最夸张的一次,改个支付接口的字段名,愣是改了16个文件。嗯,那天加班到凌晨两点。

什么是霰弹式修改?

简单说,就是一个变化引发多处修改。它的反面是发散式变化——一个类因为不同原因被频繁修改。霰弹式修改正好相反:一个原因,却要改一堆类。

典型症状:

  • 改一个业务逻辑,要改3个以上的类
  • 新增一个字段,要同步修改5个文件
  • 修一个bug,改了6个地方还有遗漏

为什么会这样?说白了就是职责没分清楚。本该属于同一个概念的东西,散落在不同的类里。就像你把钥匙、钱包、手机分别放在三个房间,出门时就得来回跑。

实战案例:订单状态机

我之前接手过一个电商项目,订单状态变更的逻辑散得到处都是。来看个简化版:

// 订单类
public class Order {
    private String status;
    
    public void pay() {
        this.status = "PAID";
        // 散弹1:通知物流
        LogisticsService.notify(this);
        // 散弹2:更新库存
        InventoryService.deduct(this);
        // 散弹3:发送短信
        SmsService.send(this);
    }
    
    public void cancel() {
        this.status = "CANCELLED";
        // 散弹4:释放库存
        InventoryService.release(this);
        // 散弹5:退款
        RefundService.process(this);
        // 散弹6:通知用户
        EmailService.send(this);
    }
}

你看,每个状态变更方法里,都直接调用了各种外部服务。改个状态,得改Order类、LogisticsService、InventoryService、SmsService……这还只是冰山一角。

重构第一步:搬移函数

我个人习惯,先做搬移函数。把那些散落在各处的逻辑,先集中到一个地方。比如,把状态变更相关的逻辑搬到Order类里:

public class Order {
    private String status;
    private OrderStateMachine stateMachine;
    
    public void pay() {
        stateMachine.transitionTo(OrderState.PAID);
    }
    
    public void cancel() {
        stateMachine.transitionTo(OrderState.CANCELLED);
    }
}

// 新建的状态机类
public class OrderStateMachine {
    private Order order;
    
    public void transitionTo(OrderState newState) {
        validateTransition(newState);
        executeSideEffects(newState);
        order.setStatus(newState.name());
    }
    
    private void executeSideEffects(OrderState state) {
        switch(state) {
            case PAID:
                LogisticsService.notify(order);
                InventoryService.deduct(order);
                SmsService.send(order);
                break;
            case CANCELLED:
                InventoryService.release(order);
                RefundService.process(order);
                EmailService.send(order);
                break;
        }
    }
}

嗯,这里要注意:搬移函数不是简单的复制粘贴。你得考虑依赖关系、生命周期、测试覆盖。我曾经直接把一个方法搬过去,结果发现它依赖了5个私有字段……那叫一个惨。

重构第二步:提炼类

搬移完函数,你会发现OrderStateMachine这个类开始膨胀了。这时候就该提炼类了。把不同维度的逻辑拆开:

// 状态变更的副作用处理
public class OrderSideEffectHandler {
    private Order order;
    
    public void handle(OrderState state) {
        switch(state) {
            case PAID: handlePaid(); break;
            case CANCELLED: handleCancelled(); break;
        }
    }
    
    private void handlePaid() {
        // 只处理支付后的副作用
        LogisticsService.notify(order);
        InventoryService.deduct(order);
        SmsService.send(order);
    }
    
    private void handleCancelled() {
        // 只处理取消后的副作用
        InventoryService.release(order);
        RefundService.process(order);
        EmailService.send(order);
    }
}

// 状态转换规则
public class OrderStateRule {
    private static final Map<OrderState, List<OrderState>> TRANSITIONS = 
        Map.of(
            OrderState.PENDING, List.of(OrderState.PAID, OrderState.CANCELLED),
            OrderState.PAID, List.of(OrderState.SHIPPED, OrderState.REFUNDING)
        );
    
    public boolean canTransition(OrderState from, OrderState to) {
        return TRANSITIONS.getOrDefault(from, List.of()).contains(to);
    }
}

我的经验:提炼类时,先画个依赖图。把每个类依赖的外部服务列出来,看看哪些是强相关的。强相关的放一起,弱相关的拆出去。这样提炼出来的类,内聚性高,耦合度低。

重构第三步:统一入口

最后一步,给状态变更提供一个统一的入口。这样外部调用者只需要关心「我要做什么」,不需要关心「怎么做」:

public class OrderService {
    private OrderRepository orderRepo;
    private OrderStateMachine stateMachine;
    
    public void payOrder(String orderId) {
        Order order = orderRepo.findById(orderId);
        stateMachine.transition(order, OrderState.PAID);
        orderRepo.save(order);
    }
    
    public void cancelOrder(String orderId) {
        Order order = orderRepo.findById(orderId);
        stateMachine.transition(order, OrderState.CANCELLED);
        orderRepo.save(order);
    }
}

你看,现在改一个状态变更逻辑,只需要改OrderStateMachine和它内部的几个类。不会再出现「改一个需求,改16个文件」的惨剧了。

避坑指南

我曾经踩过的坑:

  • 过度搬移:把不该搬的逻辑也搬过去了。比如日志记录,它跟业务逻辑无关,留在原地反而更好。
  • 忽略测试:搬移函数后,原来的测试用例全废了。记得先写测试,再重构。
  • 一次改太多:我试过一次重构5个类,结果改到一半发现依赖关系搞错了。建议一次只重构一个概念。

知识体系总览

下面这张图,概括了霰弹式修改的识别、重构步骤和最终效果:

霰弹式修改重构知识体系 识别霰弹式修改 症状 改一个需求改多个类 新增字段同步改多处 修bug遗漏修改点 搬移函数 操作 将散落逻辑集中 注意依赖关系 保持测试覆盖 提炼类 操作 按职责拆分 高内聚低耦合 统一入口 最终效果:一个变化只改一个地方 核心原则 单一职责:每个类只负责一个变化维度 开闭原则:对扩展开放,对修改关闭

总结

霰弹式修改说白了就是职责没分清楚。重构的核心思路就三步:搬移函数、提炼类、统一入口。每一步都要谨慎,尤其是搬移函数时,别忘了检查依赖关系。

我个人经验,重构这种代码时,最好先画个类图,把每个类的职责标清楚。然后一步步来,每次只改一个概念。改完一个,跑一遍测试,确认没问题再改下一个。这样虽然慢,但稳。

一句话记住:如果你改一个需求要改3个以上的类,那就是霰弹式修改在向你招手了。赶紧重构,别等到改16个文件的时候再后悔。


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