跳到主要内容

合成复用原则(Composite Reuse Principle, CRP)

核心思想​

定义
尽量使用对象组合(has-a),而不是继承(is-a)来达到复用的目的。

Favor object composition over class inheritance.

引用自:《Design Patterns: Elements of Reusable Object-Oriented Software》

软件复用的手段有很多,最常见的就是继承复用和组合/聚合复用两种:

  • 继承复用:通过 extends 复用父类的属性和方法。子类可以直接获得父类的实现,编写简单直接。
  • 组合复用:在一个类中持有另一个类的对象引用,通过调用被持有对象的方法来完成功能,也就是常说的 has-a 关系。

初学面向对象时,我们总是被教导"继承是面向对象的三大特性之一,要多用继承"。但继承真的越多越好吗?合成复用原则告诉我们:在不必要的情况下,优先使用组合而不是继承。

为什么优先使用组合而不是继承​

继承复用的问题​

继承虽然是面向对象的核心特性,但作为复用手段,它有明显的缺点:

1. 破坏封装性

子类依赖于父类内部实现的细节。只要父类的方法签名或内部逻辑发生变化,子类就可能被破坏,即使子类本身一行代码都没有改。比如父类把一个方法的实现从"直接返回"改成"先做校验再返回",所有子类的行为都会跟着变。

2. 耦合度高,形成"脆弱基类"(Fragile Base Class)

父类和子类被牢牢绑定在一起。父类的一个小改动,可能引起子类链上一连串的连锁反应。修改一个处于继承链底部的类,往往牵一发而动全身,这种设计很难维护。

3. 静态复用,缺乏灵活性

继承关系在编译期就确定了,运行时无法改变。一个类继承了父类,就不能在运行时更换"父类"来改变行为。

4. 无法复用父类中"不需要"的部分

子类会继承父类的所有属性和方法,哪怕其中有些是子类根本用不到的。这会让子类承担不该有的职责,产生"继承污染"。

组合复用的优点​

维度继承复用组合复用
耦合度高,子类依赖父类实现细节低,只依赖被组合对象的公开接口
封装性破坏封装,父类实现暴露给子类黑盒复用,不关心内部实现
灵活性编译期静态绑定,运行时无法改变运行时可以动态替换依赖对象
扩展性父类变更影响整条继承链通过替换组件扩展功能,符合开闭原则
复杂度继承层级深,逻辑分散对象关系清晰,职责单一

一句话总结:继承是"白盒复用",组合是"黑盒复用"。白盒复用把实现细节暴露给子类,黑盒复用只通过接口打交道,耦合更弱、更安全。

实例1:不会飞的鸟——继承带来的尴尬​

假设我们要设计一个动物系统,直觉上我们会这样建模:定义一个 Bird 父类,里面有 eat() 和 fly(),然后让各种鸟类继承它。

问题来了:鸵鸟(Ostrich)和企鹅(Penguin)都不会飞。但为了继承 Bird,它们不得不"实现" fly()。于是代码变成了这样:

public class Bird {
private String name;

public void eat() {
System.out.println("正在进食");
}

public void fly() {
System.out.println("正在飞行");
}
}

public class Ostrich extends Bird {
// 鸵鸟不会飞,只能把父类的方法覆盖成异常
@Override
public void fly() {
throw new UnsupportedOperationException("鸵鸟不会飞");
}
}

public class Penguin extends Bird {
@Override
public void fly() {
throw new UnsupportedOperationException("企鹅不会飞");
}
}

这样的设计有两个明显的坏处:

  1. 调用方无法信任父类契约。Bird bird = new Ostrich(); bird.fly(); 会在运行时抛出异常。调用方明明面对的是"鸟",却可能得到"飞不起来"的鸟,父类的方法契约被破坏,这是典型的里氏代换原则被违反的表现。
  2. 被迫实现不想要的职责。鸵鸟和企鹅为了继承,背上了 fly() 这个包袱,属于上文中说的"继承了不需要的部分"。

用组合和接口隔离来重构​

正确的做法是:把"会飞"这个能力抽象成接口,只有真正会飞的鸟才实现它;再把"鸟"的公共特征通过组合来组织。

重构后的设计:

public interface Flyable {
void fly();
}

public class Bird {
private String name;

public void eat() {
System.out.println("正在进食");
}
}

public class Sparrow extends Bird implements Flyable {
@Override
public void fly() {
System.out.println("麻雀正在飞行");
}
}

public class Ostrich extends Bird {
// 鸵鸟不需要实现 Flyable,也不会再出现"不会飞却要飞"的尴尬
}

这里我们同时运用了两个原则:

  • 接口隔离原则:Bird 不强迫所有子类实现 fly(),把能力按需提供给实现类。
  • 组合/接口复用:Flyable 以接口的形式把"飞行能力"复用到需要它的类上,而不是通过继承硬塞给所有鸟类。

可以看到,通过面向接口 + 组合的方式,我们既复用了公共行为,又没有把不相关的职责强加给子类。

实例2:汽车的组合设计​

再来看一个更贴近实际业务的例子:设计一个 Car。

如果使用继承来"复用"发动机和轮胎,我们可能会写出 EngineCar extends Engine 这样的代码——但这显然是荒谬的,因为 Car 和 Engine 之间根本不是 is-a 关系,而是 has-a 关系。正确的做法是使用组合:

对应代码:

public class Engine {
public void start() {
System.out.println("发动机启动");
}

public void stop() {
System.out.println("发动机停止");
}
}

public class Wheel {
public void rotate() {
System.out.println("车轮转动");
}
}

public class GPS {
public void navigate() {
System.out.println("正在导航");
}
}

public class Car {
// 组合:Car 持有 Engine、Wheel、GPS 的引用
private final Engine engine = new Engine();
private final Wheel[] wheels = {new Wheel(), new Wheel(), new Wheel(), new Wheel()};
private GPS gps;

public void start() {
engine.start();
for (Wheel wheel : wheels) {
wheel.rotate();
}
}

public void stop() {
engine.stop();
}

// 通过 setter 动态注入,运行时可以替换
public void setGps(GPS gps) {
this.gps = gps;
}
}

组合带来的好处非常明显:

  1. 职责单一:Engine、Wheel、GPS 各管各的事,Car 只负责组装和编排。
  2. 灵活替换:想换一台更大马力的发动机?新增一个 TurboEngine,Car 不用改任何代码。想让汽车支持自动驾驶导航?注入一个新的导航组件即可。
  3. 符合开闭原则:Car 对扩展开放(通过替换组件),对修改关闭(不需要改 Car 本身)。

如果是继承,上述任何一个替换需求都意味着要新建一个 Car 的子类,或者修改 Car 的代码,继承层级会越来越深,越来越难维护。

什么时候仍然可以使用继承​

合成复用原则是"尽量"使用组合,而不是"禁止"使用继承。继承在以下场景中仍然是合理的选择:

  1. 真正的 is-a 关系:子类确实是父类的一种,且父类的所有能力子类都天然具备(如 Sparrow 继承 Bird)。
  2. 父类非常稳定:父类的接口和实现几乎不会变化,不存在"脆弱基类"的风险。
  3. 复用的是行为契约而非实现细节:继承抽象类/模板方法,子类只扩展特定步骤。
  4. 继承层级浅:不要超过两层,避免过深的继承链带来的耦合。

判断标准其实很简单:问自己一句"它俩之间是 is-a 还是 has-a?"。如果是 has-a,就用组合;只有明确是 is-a 且父类稳定时,才考虑继承。

总结​

  • 合成复用原则的核心是**优先使用组合(has-a)而不是继承(is-a)**来复用代码。
  • 继承是白盒复用,耦合强、静态、易破坏封装、容易产生脆弱基类;组合是黑盒复用,耦合弱、动态、可替换、符合开闭原则。
  • 面向接口编程配合组合,是比盲目继承更安全、更灵活的复用方式。
  • 组合也不是银弹:过度组合会产生大量对象和方法,增加系统的理解成本。能用组合就用组合,该用继承时也不要刻意回避,两者结合才是正解。

推荐阅读​