18、处理继承(上):函数上移、字段上移、构造函数本体上移、函数下移

继承,是面向对象设计里最基础也最容易被滥用的机制。我见过太多项目,一开始把继承树画得漂漂亮亮,半年后却成了谁也动不了的泥潭。说白了,继承本身不是问题,问题在于我们怎么用它——以及,当它开始腐烂时,我们敢不敢动手重构。

这一章我们聊的是继承体系里的「搬家」操作。把东西往上挪,或者往下挪,听起来简单,但做起来有不少讲究。我按自己的经验,把最常见的四种场景拆开来讲。

函数上移(Pull Up Method)

先看一个最常见的场景:两个子类里有几乎一模一样的函数。

// 重构前
class Employee {
    // ...
}

class Salesman extends Employee {
    get name() { return this._name; }
}

class Engineer extends Employee {
    get name() { return this._name; }
}

你想想看,这种重复代码放在两个子类里,每次改逻辑都要改两处。万一漏改一个,bug就藏进去了。

我的做法很简单:把相同的函数直接搬到父类里。

// 重构后
class Employee {
    get name() { return this._name; }
}

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

核心判断标准:如果两个子类的函数体完全一样,或者只有细微差异(比如调用了不同的子类方法),那就应该上移。

我在项目中遇到过一种情况:两个子类的函数看起来一样,但内部调用了各自子类独有的方法。这时候不能硬搬,得先把那些子类方法也上移,或者改成父类的抽象方法。

小技巧:上移之前,先检查函数里引用了哪些字段和方法。如果引用的东西都在父类里,那就可以放心搬。如果引用了子类独有的东西,得先处理那些依赖。

字段上移(Pull Up Field)

字段重复的问题,比函数重复更隐蔽。很多时候我们写子类时,顺手就加了一个同名字段,想着「反正每个子类都要用」。

// 重构前
class Salesman extends Employee {
    private String name;
}

class Engineer extends Employee {
    private String name;
}

嗯,这里要注意:Java里子类和父类可以有同名字段,但这不是好习惯。更麻烦的是,如果字段类型不一样,或者初始化逻辑不同,后续维护就是噩梦。

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

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

警告:字段上移时,注意访问权限。如果子类原本是 private,上移后至少改成 protected。别直接改成 public,那等于把内部实现暴露给所有人。

我曾经接手过一个项目,父类里有个 public 的 List 字段,所有子类都在往里面塞东西。结果呢?谁也不知道这个 List 里到底有什么,出了 bug 根本查不到源头。所以字段上移时,权限控制一定要想清楚。

构造函数本体上移(Pull Up Constructor Body)

构造函数的重构,比普通函数麻烦一点。因为子类构造函数里,通常第一行就是 super() 调用,然后才是自己的初始化逻辑。

// 重构前
class Employee {
    protected String name;
    protected String id;
}

class Salesman extends Employee {
    public Salesman(String name, String id, String region) {
        this.name = name;
        this.id = id;
        this.region = region;
    }
}

你看,name 和 id 的赋值逻辑,每个子类都要写一遍。这时候就该把公共的初始化逻辑搬到父类的构造函数里。

// 重构后
class Employee {
    public Employee(String name, String id) {
        this.name = name;
        this.id = id;
    }
}

class Salesman extends Employee {
    public Salesman(String name, String id, String region) {
        super(name, id);
        this.region = region;
    }
}

关键点:上移构造函数本体时,要确保子类构造函数里那些「公共赋值语句」的顺序和逻辑完全一致。如果某个子类多了一步校验,或者赋值顺序不同,那就不能简单上移。

我个人习惯的做法是:先把子类构造函数里公共的部分提取成一个独立方法,然后再把这个方法上移到父类。这样风险更小,也方便测试。

函数下移(Push Down Method)

有上就有下。函数下移的场景,通常是父类里的某个方法,只有少数子类才用得到。

// 重构前
class Employee {
    void calculateQuota() { ... }
}

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

如果 Engineer 根本不需要计算配额,那这个方法放在父类里就是误导。别的开发者看到父类有 calculateQuota,以为所有员工都能用,结果 Engineer 调用时抛异常或者返回空值,这就很尴尬了。

// 重构后
class Employee { }

class Salesman extends Employee {
    void calculateQuota() { ... }
}

class Engineer extends Employee { }

什么时候该下移? 当父类里某个方法只被一个子类使用,或者被少数子类使用,而且其他子类根本不需要它的时候,就果断下移。

我曾经犯过一个错误:觉得「方法放在父类里,子类都能继承,多方便」。结果父类越来越臃肿,子类继承了一堆用不上的方法。后来重构时,光是下移就花了两天时间。所以我现在有个原则:父类只放所有子类都需要的公共行为,其他的,谁用谁拿。

知识体系总览

下面这张图,是我自己整理的处理继承重构的核心逻辑。你可以把它当成一个决策地图:

继承体系重构 函数上移 字段上移 构造函数上移 函数下移 判断条件: 子类是否有重复代码? 父类方法是否只被少数子类使用? 注意事项: 检查依赖关系 控制访问权限

总结一下

处理继承体系的重构,说白了就是两件事:消除重复,明确职责。

  • 函数上移:子类有相同方法,搬到父类。
  • 字段上移:子类有相同字段,搬到父类。
  • 构造函数本体上移:子类构造函数有公共初始化逻辑,搬到父类构造函数里。
  • 函数下移:父类方法只被少数子类使用,搬到对应的子类里。

这四个操作,看起来都是简单的「搬代码」。但真正做的时候,一定要想清楚:搬完之后,继承体系是不是更清晰了?子类的职责是不是更明确了?如果只是机械地搬,那还不如不搬。

我的经验:每次做继承重构之前,先画一张类图。把父类、子类、方法、字段都列出来。然后问自己三个问题:哪些是所有子类都需要的?哪些是部分子类需要的?哪些是某个子类独有的?答案清楚了,代码自然就知道往哪搬了。

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