20、大型重构(上):梳理并分解继承体系、将过程化设计转化为对象设计
继承体系,怎么就“烂”了?
说实话,我见过太多被继承“玩坏”的项目。
一开始,设计师画了个漂亮的类图。动物→哺乳动物→狗,三层继承,清晰明了。但半年后呢?需求一多,有人往“狗”里塞了个“会飞的狗”,有人给“哺乳动物”加了“下蛋”方法。最后这个继承树,长得跟榕树的气根似的,谁都不敢动。
为什么会这样?
因为继承本身是一种强耦合。子类依赖父类的实现细节。一旦父类变了,所有子类都得跟着改。我有个项目,一个基类改了构造函数的参数,结果下面二十多个子类全部报错。那感觉,就像多米诺骨牌倒了一片。
所以,大型重构的第一步,就是梳理继承体系。说白了,就是看看这棵树哪些枝干该砍,哪些该嫁接。
识别“坏”继承的几种味道
我个人习惯,先闻味道。代码不会骗人,有问题的继承体系,通常有这些特征:
- 子类只用到了父类的一部分方法——那说明继承关系可能不对,应该用组合。
- 父类有大量受保护字段——子类直接操作父类数据,耦合太深。
- 继承层次过深——超过三层,我就要警惕了。五层以上,基本是灾难。
- 子类重写了父类的大部分方法——那你还继承它干嘛?
核心原则:继承应该表达“is-a”关系,而不是“has-a”或“reuse-code”。如果只是为了复用代码而继承,趁早改成组合。
分解继承体系:从“树”到“林”
我常用的手法,叫“把一棵大树,拆成一片小树林”。
具体怎么做?看个例子。
假设我们有个Employee类,下面有Engineer、Manager、Salesman。每个子类都有calculatePay()方法,但实现完全不同。同时,它们又共享一些公共字段,比如name、id。
这种结构,其实可以拆成两层:
- 一层是“员工基本信息”,用组合或简单继承。
- 另一层是“薪酬策略”,用策略模式。
代码重构后,大概长这样:
// 不再用继承,改用组合
public class Employee {
private String name;
private String id;
private PayStrategy payStrategy; // 策略对象
public double calculatePay() {
return payStrategy.calculate();
}
}
// 策略接口
public interface PayStrategy {
double calculate();
}
// 具体策略
public class EngineerPay implements PayStrategy {
public double calculate() { /* 工程师算法 */ }
}
public class ManagerPay implements PayStrategy {
public double calculate() { /* 经理算法 */ }
}
你看,继承体系被分解了。每个策略类只关心自己的计算逻辑,互不干扰。想加新的薪酬类型?加个策略类就行,不用动Employee。
我的经验:分解继承体系时,先找出“变化点”和“不变点”。把不变的部分留在基类,把变化的部分抽出去。我曾经把一个7层的继承树,拆成了3个独立的策略族,代码量少了40%,bug率直线下降。
过程化设计:一段“不堪回首”的往事
我记得刚入行那会儿,接手过一个老系统。一个函数两千行,全是if-else。数据结构和行为完全分离。你想知道一个订单的状态?得去查三个不同的数组。你想修改一个逻辑?得先搞清楚这堆全局变量谁在写谁在读。
这就是典型的过程化设计。说白了,就是C语言的写法,硬塞进了Java里。
过程化设计的特征很明显:
- 数据是数据,方法是方法,各玩各的。
- 大量使用switch或if-else来判断类型。
- 全局变量满天飞。
- 一个函数做太多事,职责不单一。
这种代码,改一个地方,往往牵一发动全身。我那时候最怕的就是“加一个新功能”,因为不知道会炸到哪里。
将过程化设计转化为对象设计
怎么改?核心就一句话:把数据和操作数据的行为,封装到一起。
来看个具体例子。假设我们有个过程化的订单处理:
// 过程化风格
public class OrderProcessor {
public double calculateTotal(String orderType, double amount) {
if (orderType.equals("NORMAL")) {
return amount;
} else if (orderType.equals("DISCOUNT")) {
return amount * 0.9;
} else if (orderType.equals("VIP")) {
return amount * 0.8;
}
return amount;
}
}
这种代码,每加一种订单类型,就得改calculateTotal方法。而且orderType是字符串,拼写错了就出bug。
改成对象设计后:
// 面向对象风格
public interface OrderType {
double calculate(double amount);
}
public class NormalOrder implements OrderType {
public double calculate(double amount) {
return amount;
}
}
public class DiscountOrder implements OrderType {
public double calculate(double amount) {
return amount * 0.9;
}
}
public class VipOrder implements OrderType {
public double calculate(double amount) {
return amount * 0.8;
}
}
// 使用
OrderType type = new VipOrder();
double total = type.calculate(100.0);
你看,每个订单类型自己管自己的计算逻辑。想加新类型?加个类就行,不用改现有代码。这就是开闭原则——对扩展开放,对修改关闭。
注意:不要为了“面向对象”而面向对象。如果只有两三种类型,而且几乎不变,那用过程化也没什么。我见过有人把简单的if-else硬拆成十几个类,反而增加了复杂度。重构的度,要把握好。
重构的节奏:小步快跑
大型重构最忌讳的就是“一步到位”。我刚开始做重构时,总想一次性把整个继承体系拆完。结果呢?改到一半,测试全挂,回退都来不及。
后来我学乖了。每次只改一小块,改完就跑测试。哪怕只是把一个方法从子类提到父类,或者把一个字段从父类下放到子类,都值得单独提交一次。
具体节奏:
- 先画出现状图——把当前的继承体系画出来,标注哪些类有问题。
- 确定目标结构——想清楚最终要拆成什么样。
- 从叶子节点开始拆——先动那些影响最小的子类。
- 每步都跑测试——确保没有破坏现有功能。
- 逐步替换调用方——不要一次性改所有引用。
我曾经重构过一个支付模块的继承体系,前后花了三周。每天只改一两个类,但每天都能保证代码可运行。最后一天,我把旧的基类删掉,整个系统跑得稳稳的。那种感觉,比写新代码还爽。
一张图看懂本章核心
下面这张图,总结了继承体系分解和过程化转对象的核心逻辑:
避坑指南
最后,分享几个我踩过的坑:
- 不要一次性拆太多。我曾经试图在一个迭代里拆掉整个继承体系,结果代码冲突不断,测试覆盖不全。后来改成每次只拆一个分支,稳多了。
- 警惕“过度设计”。不是所有过程化代码都需要转成对象。如果那个逻辑几乎不变,或者只有两三个分支,保持原样反而更好。重构的目的是让代码更容易改,不是让代码更“高级”。
- 先写测试,再动手。没有测试的重构,就像走钢丝不带安全绳。我吃过这个亏,现在每重构一个类,先把它的行为用测试固定住。
- 注意性能影响。策略模式会引入额外的对象和间接调用。如果是在热点路径上,要考虑性能损耗。不过大多数业务系统,这点损耗可以忽略。
一个小技巧:重构继承体系时,可以先用“提取接口”或“提取超类”作为过渡。不要一步到位拆成组合,先让代码结构清晰起来,再慢慢调整。我习惯用“三步法”:先提取接口,再替换调用方,最后删除旧继承关系。
嗯,大型重构的上半部分就到这里。记住,重构不是炫技,是为了让代码更好维护。下一节,我们会继续聊大型重构的其他手法,包括如何分离职责、如何处理跨横切关注点。但今天的内容,已经足够你开始动手了。