第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("订阅订单不能修改地址");
    }
}

重构方案是这样的:

  1. BaseOrder拆成接口和默认实现
  2. 把可变的行为提取成独立的策略类
  3. 用委托的方式组合这些策略
// 重构后
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”关系。

举个例子,StackVector的关系。Java早期让Stack继承Vector,但栈的行为是LIFO,而向量支持随机访问。这就是个糟糕的继承设计。正确的做法应该是让Stack内部持有一个VectorArrayList,用委托来实现。

嗯,说到这个,我记得Effective Java里也专门批评过这个设计。大师们的观点是一致的:组合优于继承,这可不是随便说说的。

总结

“被拒绝的遗赠”这个坏味道,说白了就是继承用错了地方。解决它的核心手法就是“以委托取代继承”。

重构时记住三步走:

  1. 识别被拒绝的方法
  2. 提取接口或抽象类,分离职责
  3. 用委托组合替代继承

最后送你一句话:继承是强耦合,委托是弱耦合。在软件设计里,弱耦合永远比强耦合更安全。


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