18、处理继承(二):字段下移、以子类取代类型码、以委托取代超类、以委托取代子类

继承是个好东西,但用久了容易变味。

我见过太多项目,一开始用继承用得挺爽,后来发现子类越来越多,超类越来越臃肿。更麻烦的是,有些继承关系根本就是错的——子类并不真的是超类的一种,只是图方便才这么写。

这一章我们聊聊怎么收拾这些烂摊子。说白了,就是四个手法:字段下移以子类取代类型码以委托取代超类以委托取代子类

字段下移:把不该在父类的东西拿下去

先看个最简单的场景。

父类里有个字段,只有部分子类在用。其他子类根本用不上,甚至还得想办法绕开它。这时候就该做字段下移了。

核心思路:把只属于特定子类的字段,从父类搬到子类里。

举个例子。假设有个 Employee 父类,里面有个 commissionRate(佣金率)。但只有销售员才需要这个字段,普通员工根本用不着。

// 重构前
class Employee {
    protected double commissionRate; // 只有销售员用
    // ...
}

class Salesman extends Employee {
    // 用到了 commissionRate
}

class Engineer extends Employee {
    // 根本不用 commissionRate
}

重构后,把字段下移到 Salesman 里:

// 重构后
class Employee {
    // 移除了 commissionRate
}

class Salesman extends Employee {
    private double commissionRate;
    // 只有 Salesman 有
}

class Engineer extends Employee {
    // 干净了
}

我在项目中遇到过类似情况。一个订单系统,父类里有个 discountRate,结果只有 VIP 客户才用。普通客户每次创建订单都得传个 null 进去,看着就难受。字段下移之后,代码清爽多了。

小技巧:如果你发现父类里有一堆字段,每个子类只用其中一部分,那很可能父类的职责已经过重了。考虑拆分类。

以子类取代类型码:别再写 switch 了

这个手法我特别喜欢。很多老代码里,你会看到用一个数字或字符串来表示类型,然后到处写 switch 或 if-else。

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

这种写法的问题很明显:每次加一个新类型,就得改 switch。而且不同行为散落在各个方法里,维护起来很痛苦。

重构方案很简单——用子类代替类型码:

// 重构后
abstract class Employee {
    abstract double calculateBonus();
}

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

class Salesman extends Employee {
    @Override
    double calculateBonus() {
        return salary * 0.15 + commission;
    }
}

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

你想想看,这样改完以后,加一个新类型只需要新增一个子类,不用动任何现有代码。开闭原则就这么实现了。

注意:如果类型码在对象的生命周期内会变化,那用子类就不太合适了。因为对象的类型在 Java/C# 里是不能变的。这种情况可以考虑用状态模式。

以委托取代超类:别硬套继承

这个手法比较有意思。有时候我们让一个类继承另一个类,只是因为想复用一些代码,而不是因为它们是 is-a 的关系。

举个例子。有个 Scrollbar(滚动条)类,它继承了 UIComponent(UI组件)。但滚动条其实不是一个完整的 UI 组件,它只是借用了一些 UI 组件的方法。

// 重构前
class Scrollbar extends UIComponent {
    // 用了 UIComponent 的很多方法
    // 但 Scrollbar 并不是真正的 UIComponent
}

更好的做法是用委托:

// 重构后
class Scrollbar {
    private UIComponent component; // 委托给 UIComponent
    
    void draw() {
        component.draw(); // 委托调用
    }
    
    void resize() {
        component.resize();
    }
}

我曾经接手过一个项目,里面有个 Report 类继承了 DatabaseConnection。你想想看,报表怎么可能是数据库连接呢?只是因为报表需要查数据库,所以直接继承了。这种设计一旦要换数据库,整个报表类都得改。改成委托之后,报表只关心数据,不关心数据从哪来,清爽多了。

判断标准:问自己一句——子类真的是一种超类吗?如果不是,就用委托。

以委托取代子类:当继承太死板

这个手法和上一个正好相反。有时候我们用继承,是因为子类需要复用父类的行为。但继承有个问题:一个类只能有一个父类。

假设有个 Bird 类,下面有 FlyingBirdSwimmingBird。现在来了个鸭子,它既会飞又会游泳。用继承就尴尬了——你不能同时继承两个类。

// 重构前
class Duck extends FlyingBird {
    // 但 Duck 也会游泳啊!
}

用委托就灵活多了:

// 重构后
class Duck {
    private FlyingBehavior flyingBehavior;
    private SwimmingBehavior swimmingBehavior;
    
    void fly() {
        flyingBehavior.fly();
    }
    
    void swim() {
        swimmingBehavior.swim();
    }
}

嗯,这里要注意。委托虽然灵活,但也不是银弹。如果行为之间耦合很深,或者需要访问子类的内部状态,那委托反而会增加复杂度。我一般会先问自己:这个行为是「能做什么」还是「是什么」?如果是前者,用委托;如果是后者,用继承。

知识体系总览

下面这张图把四个手法的关系梳理了一下:

处理继承(二)知识体系 继承问题重构 字段下移 父类字段 → 子类 只属于特定子类的字段 以子类取代类型码 switch/if → 多态 消除类型码分支 以委托取代超类 继承 → 组合 不是 is-a 关系时 以委托取代子类 子类 → 行为委托 解决多继承问题 绿色箭头:继承关系增强 | 红色箭头:继承关系转委托

避坑指南

最后分享几个我踩过的坑:

  • 字段下移别做一半:我曾经把字段下移到子类,但忘了把父类里的 getter/setter 也移下去。结果子类调了父类的 setter,数据根本没存到子类里。要移就全移。
  • 类型码别急着改:如果类型码只在创建对象时用一次,其他地方都用多态,那改起来风险不大。但如果类型码散落在各个方法里,建议先做 Replace Conditional with Polymorphism,再动手。
  • 委托别滥用:委托虽然灵活,但会增加间接层。如果一个类委托了五六个对象,那调试起来会非常痛苦。我一般控制在三个以内。
  • 继承转委托后,接口要清晰:委托对象暴露的方法越少越好。我习惯用接口来约束委托对象的行为,这样替换起来也方便。

这四个手法,说白了就是帮你理清继承和组合的边界。继承不是不能用,但别硬用。该下移的字段就下移,该用多态的就用多态,该委托的就委托。代码会感谢你的。


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