第58章:代码坏味道:被拒绝的遗赠 - 实战以委托取代继承
说实话,看到“被拒绝的遗赠”这个坏味道名字,我第一反应是——这名字起得真形象。它说的是什么呢?就是子类只继承了父类的一部分能力,剩下的那些方法、属性,它要么用不上,要么干脆抛异常说“我不支持”。
我在项目中遇到过好几次这种场景。有一次接手一个老系统,发现有个Bird基类,下面有个Penguin子类。企鹅不会飞,但Bird里有个fly()方法。结果你猜怎么着?Penguin.fly()直接抛了个UnsupportedOperationException。嗯,这就是典型的“被拒绝的遗赠”。
什么是“被拒绝的遗赠”
简单说,就是子类不需要父类给它的某些东西。它被迫接受了,但又用不上,只能想办法“拒绝”。
表现形式通常有两种:
- 子类重写父类方法,直接抛异常——比如上面说的企鹅不会飞
- 子类继承了大量用不上的字段和方法——造成代码臃肿,理解困难
你想想看,如果继承体系里到处都是这种“拒绝”行为,那继承的初衷就完全被扭曲了。继承本应是“is-a”关系,但这里变成了“我勉强算是你,但你的东西我不全要”。
为什么会出现这种坏味道
我个人习惯把原因归结为三类:
| 原因 | 说明 |
|---|---|
| 设计时过度抽象 | 为了复用代码,强行把不相关的类塞进同一个继承体系 |
| 需求变化导致 | 最初设计合理,但后来新增的子类打破了原有约定 |
| 偷懒心理 | 觉得“继承一下省事”,没仔细考虑语义是否合理 |
我记得有一次,团队里有个新人为了复用一段日志逻辑,让OrderService继承了Logger类。结果OrderService里全是业务代码,跟日志八竿子打不着。这就是典型的“为了复用而继承”,最后代码变得特别拧巴。
重构手法:以委托取代继承
解决这个坏味道的核心思路就一句话:把继承关系改成组合关系。说白了,就是“我有一个”代替“我是一个”。
具体怎么做?我分三步来讲。
第一步:识别被拒绝的部分
先找出子类中哪些方法是被“拒绝”的。通常有两种标记:
- 方法体直接抛
UnsupportedOperationException - 方法体为空,或者返回一个默认的“无意义”值
比如这个例子:
// 坏味道示例
public class Bird {
public void fly() {
System.out.println("飞起来了");
}
public void layEggs() {
System.out.println("下蛋了");
}
}
public class Penguin extends Bird {
@Override
public void fly() {
throw new UnsupportedOperationException("企鹅不会飞");
}
// layEggs() 正常使用
}
这里fly()就是被拒绝的遗赠。企鹅不需要飞,但被迫继承了。
第二步:提取接口或抽象类
把公共的行为提取出来。注意,这里不是简单地把fly()删掉,而是重新梳理职责。
我建议的做法是:
- 把“会飞”这个行为抽象成接口
Flyable - 把“会下蛋”这个行为保留在
Bird基类中
// 重构第一步:分离职责
public interface Flyable {
void fly();
}
public abstract class Bird {
public abstract void layEggs();
}
public class Sparrow extends Bird implements Flyable {
@Override
public void fly() {
System.out.println("麻雀飞起来了");
}
@Override
public void layEggs() {
System.out.println("麻雀下蛋了");
}
}
public class Penguin extends Bird {
// 不再需要实现 fly()
@Override
public void layEggs() {
System.out.println("企鹅下蛋了");
}
}
第三步:用委托替代继承(核心)
有时候,子类需要的不是“实现某个接口”,而是“复用某个类的行为”。这时候就该用委托了。
举个例子,假设我们有一个Engine类,Car想复用它的能力。如果用继承:
// 错误的继承
public class Car extends Engine {
public void start() {
super.start();
System.out.println("汽车启动");
}
}
这明显不合理——汽车不是一个引擎。正确的做法是用委托:
// 以委托取代继承
public class Car {
private Engine engine;
public Car(Engine engine) {
this.engine = engine;
}
public void start() {
engine.start();
System.out.println("汽车启动");
}
}
你看,这样语义就对了:汽车拥有一个引擎,而不是汽车是一个引擎。
核心原则:继承用于“是什么”的关系,组合用于“有什么”的关系。当你不确定时,优先选组合。
实战案例:订单系统重构
我在一个电商项目中遇到过类似的场景。有个BaseOrder类,里面包含了订单的基本操作。后来业务扩展,增加了GiftOrder(礼品订单)和SubscriptionOrder(订阅订单)。
问题来了:GiftOrder不需要cancel()方法(礼品一旦生成不能取消),而SubscriptionOrder不需要modifyAddress()(订阅地址由用户中心统一管理)。
重构前的代码大概长这样:
public class BaseOrder {
public void create() { /* ... */ }
public void cancel() { /* ... */ }
public void modifyAddress() { /* ... */ }
public void pay() { /* ... */ }
}
public class GiftOrder extends BaseOrder {
@Override
public void cancel() {
throw new UnsupportedOperationException("礼品订单不能取消");
}
}
public class SubscriptionOrder extends BaseOrder {
@Override
public void modifyAddress() {
throw new UnsupportedOperationException("订阅订单不能修改地址");
}
}
重构方案是这样的:
- 把
BaseOrder拆成接口和默认实现 - 把可变的行为提取成独立的策略类
- 用委托的方式组合这些策略
// 重构后
public interface Order {
void create();
void pay();
}
public interface Cancellable {
void cancel();
}
public interface AddressModifiable {
void modifyAddress();
}
public class GiftOrder implements Order {
// 委托给具体的实现
private OrderCreation creation = new DefaultOrderCreation();
private Payment payment = new DefaultPayment();
@Override
public void create() {
creation.create(this);
}
@Override
public void pay() {
payment.pay(this);
}
// 没有实现 Cancellable,所以不能取消
}
public class SubscriptionOrder implements Order, Cancellable {
private OrderCreation creation = new DefaultOrderCreation();
private Payment payment = new DefaultPayment();
private Cancellation cancellation = new DefaultCancellation();
@Override
public void create() {
creation.create(this);
}
@Override
public void pay() {
payment.pay(this);
}
@Override
public void cancel() {
cancellation.cancel(this);
}
// 没有实现 AddressModifiable,所以不能修改地址
}
我的经验:重构这种代码时,别急着一步到位。先画一张类图,把“哪些方法被拒绝”标出来。你会发现,被拒绝的方法往往暗示着职责边界划分有问题。
知识体系图
下面这张图展示了“被拒绝的遗赠”从识别到重构的完整路径:
避坑指南:我曾经在重构一个支付系统时,把继承改成委托后,发现单元测试覆盖率从85%掉到了60%。为什么?因为原来的继承关系让测试可以轻松mock父类方法。改成委托后,需要额外注入依赖。所以重构时别忘了同步更新测试代码。
什么时候该用委托,什么时候该保留继承
我个人的判断标准很简单:
- 保留继承:子类确实“是一种”父类,并且会使用父类的所有行为。比如
Square继承Rectangle,只要正方形不违反矩形的行为约定。 - 改用委托:子类只想复用父类的部分能力,或者子类与父类在语义上不是严格的“is-a”关系。
举个例子,Stack和Vector的关系。Java早期让Stack继承Vector,但栈的行为是LIFO,而向量支持随机访问。这就是个糟糕的继承设计。正确的做法应该是让Stack内部持有一个Vector或ArrayList,用委托来实现。
嗯,说到这个,我记得Effective Java里也专门批评过这个设计。大师们的观点是一致的:组合优于继承,这可不是随便说说的。
总结
“被拒绝的遗赠”这个坏味道,说白了就是继承用错了地方。解决它的核心手法就是“以委托取代继承”。
重构时记住三步走:
- 识别被拒绝的方法
- 提取接口或抽象类,分离职责
- 用委托组合替代继承
最后送你一句话:继承是强耦合,委托是弱耦合。在软件设计里,弱耦合永远比强耦合更安全。
公众号:蓝海资料掘金营,微信deep3321