13、继承体系重构(上):函数上移、字段上移、构造函数本体上移、函数下移、字段下移、提炼子类
继承体系,说白了就是代码世界里最经典的“父子关系”。
我刚入行那会儿,特别喜欢用继承。看到两个类有相似逻辑,二话不说就拉出一个父类。结果呢?半年后回来一看,继承树长得跟榕树似的,根深叶茂,但想动一根枝桠都怕整棵树倒了。
嗯,这就是技术债务的典型表现。今天我们就来聊聊,怎么用一套系统的手法,把这棵“榕树”修剪得清爽、健康。
核心思想:继承体系重构的核心,就是让每个类只做它该做的事。不该它管的,往上提;该它管的,往下放。
13.1 函数上移(Pull Up Method)
先问个问题:两个子类里有完全相同的函数,你会怎么做?
我见过不少项目,直接复制粘贴。结果呢?改一个地方,另一个忘了改,bug就来了。
正确的做法是:把这个函数往上提到父类里。
// 重构前
class Employee {
// ...
}
class Salesman extends Employee {
get name() {
return this._name;
}
}
class Engineer extends Employee {
get name() {
return this._name;
}
}
// 重构后
class Employee {
get name() {
return this._name;
}
}
class Salesman extends Employee {
// name 方法已上移
}
class Engineer extends Employee {
// name 方法已上移
}
你看,代码瞬间清爽了。但要注意一点:上移之前,先确认两个函数的行为是否真的完全一致。我曾经踩过一个坑,两个函数看起来一模一样,但一个用了this._name,另一个用了this.getName()。上移之后,子类的行为就变了。
我的习惯:上移之前,先跑一遍所有测试用例。别偷懒,这一步能救你命。
13.2 字段上移(Pull Up Field)
字段上移和函数上移的逻辑类似。如果多个子类都有同一个字段,那就把它提到父类里。
// 重构前
class Salesman extends Employee {
private String name;
}
class Engineer extends Employee {
private String name;
}
// 重构后
class Employee {
protected String name;
}
class Salesman extends Employee {
// name 字段已上移
}
class Engineer extends Employee {
// name 字段已上移
}
这里有个小细节:字段的访问权限。上移之后,子类还能不能访问这个字段?我建议用protected,除非你确定子类不需要直接访问它。
注意:字段上移后,如果子类里有对这个字段的初始化逻辑,别忘了检查构造函数。否则,字段可能没被正确赋值。
13.3 构造函数本体上移(Pull Up Constructor Body)
构造函数的重构,比普通函数要复杂一些。因为子类的构造函数里,往往有对父类字段的初始化逻辑。
// 重构前
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;
}
}
// 重构后
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;
}
}
你看,我们把name和id的初始化逻辑上移到了父类的构造函数里。子类只需要调用super()就行了。
我个人习惯,在做这一步之前,先看看子类构造函数里有没有“副作用”。比如,有没有在构造函数里调用其他方法?如果有,上移之后可能会改变调用顺序,导致行为不一致。
13.4 函数下移(Push Down Method)
有上就有下。函数下移,就是把父类里的某个函数,只放到需要它的子类里。
什么时候需要下移?当父类里的函数,只有少数子类用到时。
// 重构前
class Employee {
get quota() {
return 100;
}
}
class Salesman extends Employee {
// 使用 quota
}
class Engineer extends Employee {
// 不使用 quota
}
// 重构后
class Employee {
// quota 已下移
}
class Salesman extends Employee {
get quota() {
return 100;
}
}
class Engineer extends Employee {
// 没有 quota
}
你想想看,如果Engineer根本不需要quota,却还要继承它,是不是很奇怪?这就是“接口污染”。
核心原则:父类只放所有子类都需要的。如果某个方法只有部分子类需要,那就下移。
13.5 字段下移(Push Down Field)
字段下移和函数下移的逻辑一样。如果父类里的字段,只有部分子类用到,那就把它下移到对应的子类里。
// 重构前
class Employee {
protected String region;
}
class Salesman extends Employee {
// 使用 region
}
class Engineer extends Employee {
// 不使用 region
}
// 重构后
class Employee {
// region 已下移
}
class Salesman extends Employee {
protected String region;
}
class Engineer extends Employee {
// 没有 region
}
这里要注意:字段下移后,父类里对这个字段的引用也要一并处理。否则,编译都过不了。
13.6 提炼子类(Extract Subclass)
提炼子类,和前面的“下移”正好相反。它是从父类里,把一部分行为提炼出来,形成一个独立的子类。
什么时候需要提炼?当父类里的某些方法,只有特定场景下才用到时。
// 重构前
class Employee {
get quota() {
return 100;
}
get commission() {
return 0.1 * this.sales;
}
}
// 重构后
class Employee {
get quota() {
return 100;
}
}
class Salesman extends Employee {
get commission() {
return 0.1 * this.sales;
}
}
你看,我们把commission方法提炼到了Salesman子类里。这样,Employee类就变得更纯粹了。
我的经验:提炼子类之前,先问问自己:“这个子类真的有必要存在吗?”有时候,一个简单的条件判断就能解决问题,没必要搞出一个新类来。
13.7 知识体系总览
为了让你更直观地理解这些重构手法之间的关系,我画了一张图:
13.8 避坑指南
说了这么多,最后分享几个我踩过的坑:
- 不要一次性做太多:我曾经在一个大项目里,一口气做了十几个上移和下移。结果呢?测试全挂了。后来我学乖了,一次只做一个重构,跑一遍测试,再继续。
- 注意多态:如果父类里的方法被子类重写了,上移之前一定要确认:重写后的逻辑是否还适用?
- 小心循环依赖:字段上移后,如果父类和子类之间出现了循环引用,那就麻烦了。我建议在重构之前,先用工具分析一下依赖关系。
- 测试,测试,再测试:这句话我说多少遍都不为过。没有测试的重构,就是耍流氓。
我曾经犯过的错:有一次,我把一个字段上移到了父类,结果忘了改子类里的构造函数。那个字段在子类里被初始化了两次,一次在父类,一次在子类。最后查了半天才发现。嗯,从那以后,我每次做完字段上移,都会检查所有子类的构造函数。
好了,这一章的内容就到这里。继承体系重构,说白了就是“该上就上,该下就下”。别怕改代码,怕的是不改。