19、处理继承(下):字段下移、以子类取代类型码、以策略类取代类型码

继承这东西,用好了是神器,用砸了就是灾难。上一章我们聊了字段上移、函数上移这些往上走的操作。这一章反过来,聊聊怎么把东西往下放,以及什么时候该彻底放弃继承,改用组合。

我个人习惯是:先看代码里有没有“假继承”。什么叫假继承?就是子类之间除了一个类型码不同,其他逻辑几乎一样。这种代码,说白了就是披着继承外衣的 switch-case。遇到这种情况,我建议你果断动手重构。

字段下移:把不该共享的字段赶回子类

先看一个我踩过的坑。几年前我接手一个支付系统,基类 Payment 里有个字段 bankAccount,所有子类都继承它。但后来发现,只有 BankTransferPayment 才需要这个字段,CreditCardPaymentWeChatPayment 根本用不上。

这就是典型的“字段放错位置”。基类里躺着只有部分子类才用的字段,不仅浪费内存,还让代码理解成本飙升。你想想看,新同事看到基类里有个 bankAccount,第一反应肯定是“所有支付方式都有银行账户?”——这显然不对。

怎么改?把字段下移到真正需要它的子类里。

// 重构前:基类里躺着不该有的字段
class Payment {
    protected String bankAccount;  // 只有银行转账才需要
    protected BigDecimal amount;
}

class BankTransferPayment extends Payment {
    // 用到了 bankAccount
}

class CreditCardPayment extends Payment {
    // bankAccount 根本没用!
}

// 重构后:字段下移
class Payment {
    protected BigDecimal amount;
}

class BankTransferPayment extends Payment {
    private String bankAccount;  // 下移到这里
}

class CreditCardPayment extends Payment {
    // 干净了
}
判断标准:如果基类中某个字段在超过一半的子类中都没有被使用,就该考虑下移了。别犹豫,直接动手。

以子类取代类型码:告别 switch-case 的第一步

类型码这东西,我见过太多项目被它拖垮。一个 int type 或者 String type 字段,然后满屏的 if (type == 1) 或者 switch(type)。每次新增一种类型,就要改好几个地方,漏改一个就出 bug。

我曾经在一个电商项目中看到这样的代码:订单状态用整数表示,0 代表待支付,1 代表已支付,2 代表已发货……然后业务逻辑里到处都是 if (order.status == 1)。后来需求变了,要新增一个“部分退款”状态,结果改了十几个文件,还漏了两个,线上出了 P0 事故。

重构手法很简单:把类型码变成子类。每个子类负责自己的行为。

// 重构前:类型码 + switch
class Employee {
    private int type;  // 0: 工程师, 1: 经理, 2: 销售
    
    double calculateBonus() {
        switch (type) {
            case 0: return salary * 0.1;
            case 1: return salary * 0.2;
            case 2: return salary * 0.15;
            default: throw new IllegalArgumentException();
        }
    }
}

// 重构后:以子类取代类型码
abstract class Employee {
    abstract double calculateBonus();
}

class Engineer extends Employee {
    double calculateBonus() {
        return salary * 0.1;
    }
}

class Manager extends Employee {
    double calculateBonus() {
        return salary * 0.2;
    }
}

class Salesperson extends Employee {
    double calculateBonus() {
        return salary * 0.15;
    }
}
核心收益:新增一种员工类型时,只需要新增一个子类,不需要改任何现有代码。开闭原则,就这么简单。

以策略类取代类型码:当继承不合适的时候

等等,是不是所有类型码都要用子类取代?当然不是。我遇到过一种情况:某个对象的类型会频繁变化。比如订单状态,从“待支付”变成“已支付”,再变成“已发货”。如果用子类,难道每次状态变化都要把对象替换成另一个子类的实例?太麻烦了。

这时候,策略模式就派上用场了。把变化的行为抽到独立的策略类里,通过组合来解决问题。

// 策略接口
interface ShippingStrategy {
    double calculateCost(double weight);
}

// 具体策略
class StandardShipping implements ShippingStrategy {
    public double calculateCost(double weight) {
        return weight * 5;
    }
}

class ExpressShipping implements ShippingStrategy {
    public double calculateCost(double weight) {
        return weight * 10 + 20;
    }
}

// 上下文类
class Order {
    private ShippingStrategy strategy;  // 组合策略
    
    void setShippingStrategy(ShippingStrategy strategy) {
        this.strategy = strategy;
    }
    
    double calculateShippingCost(double weight) {
        return strategy.calculateCost(weight);
    }
}
什么时候用子类,什么时候用策略? 如果类型在对象生命周期内不会改变,用子类;如果类型会动态变化,用策略。我个人的经验是:超过 80% 的场景用子类就够了,别为了用设计模式而用设计模式。

知识体系总览

下面这张图把三种重构手法的适用场景和关系梳理清楚了。我建议你把它存下来,每次重构前看一眼。

继承重构决策树 发现类型码 类型会变化? 以子类取代类型码 继承 + 多态 以策略类取代类型码 组合 + 策略 适用场景 • 类型在生命周期内不变 • 不同类型行为差异大 • 需要新增类型时不想改旧代码 • 配合字段下移清理基类 适用场景 • 类型在运行时频繁变化 • 行为可以独立于对象存在 • 需要动态切换算法 • 已有大量 if-else 难以消除

避坑指南

最后分享几个我踩过的坑,希望能帮你少走弯路。

  • 字段下移别过头:我曾经把基类里一个很常用的字段下移了,结果导致所有子类都得重复声明。下移前先看看这个字段在多少子类里被用到,超过 70% 就别动了。
  • 子类数量别太多:如果类型码有几十种,全变成子类反而更糟。这时候可以考虑用策略模式,或者用枚举 + 函数式接口来替代。
  • 别忘了工厂方法:用子类取代类型码后,创建对象的地方需要改。我习惯在基类里加一个静态工厂方法,根据参数返回对应的子类实例。
我的经验:重构继承关系时,最好从叶子节点开始改。先改最具体的子类,再逐步往上。这样每次改动的影响范围最小,测试也更容易覆盖。

嗯,这一章的内容就到这里。记住:继承不是银弹,组合也不是。关键是看你的业务场景,选最合适的方案。代码整洁之道,说白了就是不断做减法——去掉不必要的继承,去掉冗余的字段,去掉恶心的 switch-case。