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,就为了用它的 add 和 size 方法。结果这个类暴露了所有 ArrayList 的方法,用户能调用 remove、clear 这些本不该暴露的操作。
// 重构前 - 滥用继承
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」语义
知识体系总览
下面这张图把本章的四个手法串起来了,你可以看看它们各自的应用场景:
总结一下
这四个手法,其实反映了同一个趋势:从继承走向组合,从硬编码走向多态。
- 字段下移:让父类更纯粹,只放公共字段
- 以子类取代类型码:用多态代替条件判断,但要求类型固定
- 以策略类取代类型码:类型可变时的替代方案,更灵活
- 以组合取代继承:彻底解耦,只复用你需要的功能
我个人习惯是:新写代码时优先考虑组合。如果发现组合确实不合适,再退回到继承。你想想看,继承一旦建立,改起来成本很高。组合就轻松多了,大不了换个对象。
嗯,这一章就到这里。记住一句话:继承是手段,不是目的。别为了用继承而用继承。