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

好,咱们接着聊继承。上一章我们讲了字段上提、函数上提这些「往上走」的重构手法。这一章反过来,聊聊「往下走」和「拆掉继承」的场景。

说实话,我在项目里见过太多滥用继承的代码。有些团队恨不得把所有东西都塞进继承树里,结果搞得类层次又深又乱。你想想看,一个Bug定位要翻五六层父类,那滋味可不好受。

这一章我们讲四个手法:字段下移、以子类取代类型码、以策略类取代类型码、以组合取代继承。前两个是继承体系内的调整,后两个是彻底告别继承。

字段下移(Push Down Field)

字段下移,说白了就是把父类里只有部分子类才用的字段,挪到对应的子类里去。

为什么会这样?有时候我们设计父类时过于乐观,觉得「这个字段所有子类都会用到」。结果后来发现,只有一两个子类在用,其他子类根本不需要。这时候字段放在父类里就是浪费内存,还容易让人困惑。

核心原则: 父类只放所有子类共用的东西。只有部分子类用的,往下推。

举个例子:

// 重构前
class Employee {
    protected String name;
    protected int salary;
    protected String quota; // 只有销售员才有配额
}

class Salesman extends Employee {}
class Engineer extends Employee {}

你看,quota 这个字段只有 Salesman 需要,Engineer 根本用不上。放在父类里,每个工程师对象都白白占着一块内存。

// 重构后
class Employee {
    protected String name;
    protected int salary;
}

class Salesman extends Employee {
    protected String quota;
}

class Engineer extends Employee {}

嗯,这里要注意:字段下移之后,如果父类里还有访问这个字段的方法,也得一起下移。否则父类方法引用了不存在的字段,编译就过不去了。

我的习惯: 做字段下移前,先全局搜索一下这个字段的引用。如果引用点不多,直接改。如果引用点很多,我会考虑是不是这个字段其实应该留在父类里。

以子类取代类型码(Replace Type Code with Subclasses)

这个手法,我当年第一次看到时觉得挺反直觉的。你想想,类型码明明可以用枚举或者常量搞定,为什么要搞出子类来?

原因很简单:类型码往往伴随着行为差异。比如不同员工类型的薪资计算方式不同。用类型码加 switch 也能做,但每次加新类型都要改 switch,容易漏改。

// 重构前
class Employee {
    static final int ENGINEER = 0;
    static final int SALESMAN = 1;
    static final int MANAGER = 2;
    
    private int type;
    
    int payAmount() {
        switch (type) {
            case ENGINEER: return salary;
            case SALESMAN: return salary + bonus;
            case MANAGER: return salary + bonus + allowance;
            default: throw new RuntimeException();
        }
    }
}

我曾经在一个老项目里看到类似的代码,那个 switch 有七八个分支,每次加新类型都要小心翼翼,生怕漏掉某个分支。后来我把它改成了子类,世界清净了。

// 重构后
abstract class Employee {
    abstract int payAmount();
}

class Engineer extends Employee {
    int payAmount() { return salary; }
}

class Salesman extends Employee {
    int payAmount() { return salary + bonus; }
}

class Manager extends Employee {
    int payAmount() { return salary + bonus + allowance; }
}
注意: 如果类型码在对象的生命周期内会变化,就不适合用子类取代。因为对象的类不能运行时改变。这种情况可以考虑策略模式。

以策略类取代类型码(Replace Type Code with Strategy)

刚才说了,如果类型码会变,子类这条路就走不通了。这时候策略模式就派上用场了。

策略模式的核心思想:把行为抽出来,变成独立的策略类。原来的类持有一个策略对象的引用,把行为委托给它。

// 重构后
class Employee {
    private PayStrategy strategy;
    
    Employee(PayStrategy strategy) {
        this.strategy = strategy;
    }
    
    void setStrategy(PayStrategy strategy) {
        this.strategy = strategy;
    }
    
    int payAmount() {
        return strategy.calculate(this);
    }
}

interface PayStrategy {
    int calculate(Employee employee);
}

class EngineerPay implements PayStrategy {
    public int calculate(Employee e) { return e.getSalary(); }
}

class SalesmanPay implements PayStrategy {
    public int calculate(Employee e) { return e.getSalary() + e.getBonus(); }
}

你看,现在员工可以随时换策略。今天你是工程师,明天转岗做销售,换个策略对象就行,不用重新创建对象。

避坑指南: 我曾经在一个项目中,把策略对象和业务对象搞成了双向引用,结果序列化时出了循环引用的问题。建议策略对象只持有业务对象的必要数据,不要持有整个引用。

以组合取代继承(Replace Inheritance with Delegation)

这个手法,说白了就是「能用组合就别用继承」。继承是一种强耦合关系,子类和父类绑得死死的。组合就灵活多了,可以随时换掉被组合的对象。

我见过一个极端的例子:有个类继承了 ArrayList,就为了用它的 addsize 方法。结果这个类暴露了所有 ArrayList 的方法,用户能调用 removeclear 这些本不该暴露的操作。

// 重构前 - 滥用继承
class MyStack extends ArrayList<String> {
    public void push(String s) { add(s); }
    public String pop() { return remove(size() - 1); }
}

改成组合就清晰多了:

// 重构后 - 使用组合
class MyStack {
    private ArrayList<String> list = new ArrayList<>();
    
    public void push(String s) { list.add(s); }
    public String pop() { return list.remove(list.size() - 1); }
    public int size() { return list.size(); }
}

现在 MyStack 只暴露了三个方法,用户没法乱调 remove 或者 clear 了。

什么时候该用组合?
  • 你只想复用部分行为,不是全部
  • 被继承的类不稳定,经常变化
  • 你想隐藏被继承类的某些方法
  • 继承关系不符合「is-a」语义

知识体系总览

下面这张图把本章的四个手法串起来了,你可以看看它们各自的应用场景:

处理继承(下)知识体系 继承体系调整 字段下移 父类→子类,减少冗余 以子类取代类型码 类型码→多态,类型固定时用 以策略类取代类型码 类型码→策略,类型可变时用 以组合取代继承 继承→组合,降低耦合,灵活替换 核心思路 继承不是银弹。能用组合就别用继承,能用多态就别用switch。 类型固定用子类,类型可变用策略。

总结一下

这四个手法,其实反映了同一个趋势:从继承走向组合,从硬编码走向多态。

  • 字段下移:让父类更纯粹,只放公共字段
  • 以子类取代类型码:用多态代替条件判断,但要求类型固定
  • 以策略类取代类型码:类型可变时的替代方案,更灵活
  • 以组合取代继承:彻底解耦,只复用你需要的功能

我个人习惯是:新写代码时优先考虑组合。如果发现组合确实不合适,再退回到继承。你想想看,继承一旦建立,改起来成本很高。组合就轻松多了,大不了换个对象。

嗯,这一章就到这里。记住一句话:继承是手段,不是目的。别为了用继承而用继承。


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