YCBlogs 面向对象设计精讲:为什么"多用组合少用继承"——组合优于继承的实战剖析

原创2026-10-09 21:03:431,422 阅读
文章标签:教程技术博客文档

YCBlogs 面向对象设计精讲:为什么"多用组合少用继承"——组合优于继承的实战剖析

导读:本篇文章以 YCBlogs 仓库中《多用组合和少继承》笔记为核心骨架,系统剖析"组合优于继承"这条经典设计原则的来龙去脉:继承在哪些场景下会失控、组合+接口+委托如何优雅化解继承难题、以及实际项目中到底该如何权衡组合与继承。读者读完可以掌握"鸟类继承爆炸"这类问题的重构手法,理解装饰者、策略、模板等设计模式与两种关系的内在关联,并能在日常编码中做出有依据的选型判断。

在面向对象编程的世界里,有一条流传甚广的经典设计原则——组合优于继承(Favor Composition over Inheritance),也常被表述为"多用组合少用继承"。它在 Java、Android 等以继承为主要复用手段的静态语言生态中尤其具有现实意义:extends 用起来顺手,但一旦继承层次失控,代码的可维护性会以肉眼可见的速度劣化。本文基于 design/06.面向对象思想/07.多用组合和少继承.md 的核心脉络,结合仓库中面向对象四大特性、接口 vs 抽象类、基于接口而非实现编程、迪米特原则等姊妹篇,以及多个设计模式文档,把一个看似"口号化"的原则讲透、讲实操。


01. 前沿:为什么"组合优于继承"值得专门讨论

在面向对象编程中,继承是面向对象四大特性之一(封装、抽象、继承、多态,详见 design/06.面向对象思想/02.四大特性详细说明.md),用来表示类之间的 is-a 关系,其最直接的收益是代码复用:把多个子类共有的属性和方法抽取到父类中,子类通过 extends 直接复用。

然而,正因为继承的"复用"太容易获得,开发者往往会过度使用继承,导致三个典型问题:

  1. 继承层次过深:为了搞清一个类有哪些方法、属性,必须沿着"父类 → 父类的父类 → …"一路向上追溯,代码可读性直线下降;
  2. 继承关系过复杂:多维度行为(会不会飞、会不会叫、会不会游泳、会不会下蛋)交织在一起时,类数量呈指数级膨胀,这就是著名的"组合爆炸";
  3. 破坏封装:子类实现强依赖父类实现,两者高度耦合,父类一旦改动,所有子类的逻辑都会受牵连。

所以业内出现了两种声音:一种认为继承是"反模式",应尽量少用甚至不用;另一种则主张辩证看待。本文的观点并不极端——"多用组合少用继承"喊得响,恰恰是因为继承长期被过度使用。问题的关键从来不是"禁用继承",而是搞清楚:继承在什么场景下会失控?组合相比继承有哪些不可替代的优势?什么情况下又必须使用继承?


02. 为何不推荐使用继承:从"鸟类设计"看继承失控的全过程

2.1 第一层困境:父类方法的"特例化"问题

假设我们要设计一个关于鸟的类体系。将"鸟类"这个抽象概念定义为一个抽象类 AbstractBird,所有更细分的鸟(麻雀、鸽子、乌鸦…)都继承它。

大部分鸟都会飞,那能不能在 AbstractBird 中直接定义一个 fly() 方法?答案是否定的。因为存在鸵鸟、企鹅这样的特例——它们不会飞。如果鸵鸟继承了带 fly() 的父类,它就"具有"飞行的行为,这显然不符合现实认知。

一个常见的补救思路是:在子类中重写(override)fly() 方法,直接抛出异常,代码如下:

public class AbstractBird {
  //...省略其他属性和方法...
  public void fly() { //... }
}

public class Ostrich extends AbstractBird { //鸵鸟
  //...省略其他属性和方法...
  public void fly() {
    throw new UnSupportedMethodException("I can't fly.'");
  }
}

这种设计虽然"能用",但不够优美:

  • 除了鸵鸟,不会飞的鸟还有很多(比如企鹅),每一个都要重复重写 fly() 抛异常,徒增编码工作量;
  • 更重要的是,它违背了最小知识原则(Least Knowledge Principle,又称迪米特法则)——把本不该暴露的 fly() 接口暴露给了外部调用者,增加了类在使用过程中被误用的概率。关于迪米特法则如何支撑"高内聚、松耦合",可进一步阅读 design/07.代码设计原则/07.迪米特原则介绍.md。

2.2 第二层困境:抽象类越拆越多

既然问题出在"会飞的鸟"和"不会飞的鸟"混在一个父类里,那顺着继承的思路往下拆:从 AbstractBird 再派生出两个更细分的抽象类——AbstractFlyableBird(会飞的鸟类)和 AbstractUnFlyableBird(不会飞的鸟类)。

让麻雀、乌鸦等会飞的鸟继承 AbstractFlyableBird,让鸵鸟、企鹅等不会飞的鸟继承 AbstractUnFlyableBird。此时继承关系变成了三层,整体还比较简单、层次较浅,算是一种可以接受的设计。

但继续加难度:如果除了关注"会不会飞",还关注"会不会叫"呢?两个行为搭配会产生四种情况:

会飞? 会叫? 需要的抽象类
会 会 AbstractFlyableTweetableBird
不会 会 AbstractUnFlyableTweetableBird
会 不会 AbstractFlyableUnTweetableBird
不会 不会 AbstractUnFlyableUnTweetableBird

需要再定义四个抽象类。如果再考虑"是否会下蛋"这个行为维度,类的数量将呈指数级增长——组合爆炸就此发生。

2.3 继承失控的根因总结

从上面层层递进的例子可以归纳出继承最大的问题:

  • 可读性变差:继承层次深、关系复杂时,要搞清楚某个类有哪些方法、属性,必须逐层阅读父类代码,一直追溯到最顶层;
  • 破坏封装:父类的实现细节被暴露给子类,子类实现依赖父类实现,两者高度耦合,父类代码一旦修改,就会影响所有子类逻辑;
  • 维护成本飙升:每增加一个行为维度,就要新增一批抽象类/子类,代码数量与复杂度双双失控。

这也正是"为什么不推荐使用继承"的核心答案:继承层次过深、继承关系过于复杂,会直接影响到代码的可读性和可维护性。


03. 组合的优势:接口 + 组合 + 委托三件套

组合相比继承有哪些优势?答案是:利用组合(Composition)、接口(Interface)、**委托(Delegation)**三个技术手段,可以一块儿解决继承存在的问题。

3.1 第一步:用接口表达"行为特性"

在 design/06.面向对象思想/05.接口vs抽象类比较.md 中已经讲过:抽象类表示 is-a 关系(是一种),而接口表示 has-a 关系(具有某种行为特性),接口是对方法的一种抽象,相当于"协议/契约"。

针对"会飞""会叫""会下蛋"这些行为特性,可以分别定义 Flyable、Tweetable、EggLayable 接口,只让具备对应行为的鸟去实现它:

public interface Flyable {
  void fly();
}
public interface Tweetable {
  void tweet();
}
public interface EggLayable {
  void layEgg();
}

public class Ostrich implements Tweetable, EggLayable {//鸵鸟
  //... 省略其他属性和方法...
  @Override
  public void tweet() { //... }
  @Override
  public void layEgg() { //... }
}

public class Sparrow implements Flyable, Tweetable, EggLayable {//麻雀
  //... 省略其他属性和方法...
  @Override
  public void fly() { //... }
  @Override
  public void tweet() { //... }
  @Override
  public void layEgg() { //... }
}

这样一来,继承层次被彻底"拍平"了:鸵鸟只实现 Tweetable 和 EggLayable,麻雀多实现一个 Flyable,每个类只需要实现自己真正具备的行为。不管新增多少行为维度(会飞、会叫、会游泳、会下蛋…),都只是往接口清单里加一个接口的问题,不会引发抽象类数量爆炸。

3.2 第二步:用"能力类 + 委托"消除接口的代码重复

不过接口有一个天生的局限:接口只声明方法,不定义实现(详见 design/06.面向对象思想/05.接口vs抽象类比较.md)。这意味着每个会下蛋的鸟都要自己实现一遍 layEgg(),而实现逻辑可能是完全相同的——代码重复问题随之而来。

解决思路:针对每个接口,再定义一个对应的能力实现类(FlyAbility 实现 fly()、TweetAbility 实现 tweet()、EggLayAbility 实现 layEgg()),然后通过组合(把能力对象作为成员变量持有)+ 委托(把方法调用转发给能力对象)来复用逻辑:

public interface Flyable {
  void fly();
}
public class FlyAbility implements Flyable {
  @Override
  public void fly() { //... }
}
//省略Tweetable/TweetAbility/EggLayable/EggLayAbility

public class Ostrich implements Tweetable, EggLayable {//鸵鸟
  private TweetAbility tweetAbility = new TweetAbility(); //组合
  private EggLayAbility eggLayAbility = new EggLayAbility(); //组合
  //... 省略其他属性和方法...
  @Override
  public void tweet() {
    tweetAbility.tweet(); // 委托
  }
  @Override
  public void layEgg() {
    eggLayAbility.layEgg(); // 委托
  }
}

这里的核心动作有两个:

  • 组合:Ostrich 内部持有 TweetAbility、EggLayAbility 对象,通过成员变量建立 has-a 关系;
  • 委托:tweet()、layEgg() 方法体内不写具体逻辑,而是转调能力对象的方法,实现逻辑的真正复用。

3.3 继承的三个作用都能被替代

归纳来看,继承主要有三个作用:

继承的作用 替代方案
表示 is-a 关系 用组合 + 接口的 has-a 关系替代
支持多态特性 利用接口实现(参考 design/06.面向对象思想/02.四大特性详细说明.md 中"用接口实现多态"的 Iterator 例子)
代码复用 通过组合 + 委托实现

所以从理论上讲,通过组合、接口、委托三个技术手段,完全可以替换掉继承,在项目中不用或少用继承,特别是一些复杂的继承关系。这套"面向行为特性建模"的思路,也正是 design/06.面向对象思想/06.接口而非实现编程.md 中"基于接口而非实现编程"原则的落地形态——上游依赖稳定的接口契约,实现细节封装在能力类中,替换实现时上游代码几乎不用改动。


04. 组合 Vs 继承:到底该怎么选

尽管我们鼓励"多用组合少用继承",但必须清醒认识到:组合并不完美,继承也并非一无是处。

4.1 组合的代价:细粒度拆分带来的复杂度

从上面的例子可以看出,继承改写成组合,意味着要做更细粒度的类拆分——要定义更多的接口、更多的能力类。类和接口的增多,会或多或少增加代码的复杂程度和维护成本。

因此,实际项目开发中,还是要根据具体情况来选择:

  • 适合大胆使用继承:类之间的继承结构稳定(不会轻易改变)、继承层次浅(比如最多两层继承关系)、继承关系不复杂;
  • 适合用组合替代继承:系统越不稳定、继承层次很深、继承关系复杂。

4.2 设计模式中的"站队":组合系与继承系

一些经典设计模式会固定地使用组合或继承:

模式 使用的关系 仓库参考文档
装饰者模式(Decorator) 组合(持有 Component 实例并委托) design/04.结构型模式/03.1装饰者设计模式介绍.md
策略模式(Strategy) 组合(Context 持有 Strategy 引用) design/02.行为型模式/02.1策略者设计模式.md
组合模式(Composite) 组合 design 目录结构型模式相关文档
模板方法模式(Template Method) 继承(抽象类定义骨架,子类实现基本方法) design/02.行为型模式/03.1模版方法模式上.md

以装饰者模式为例,其 Decorator 角色持有一个 Component 对象实例,并定义一个与构件接口一致的接口,方法体内委派给被装饰的构件——这正是"组合 + 委托"的教科书式应用(参见 design/04.结构型模式/03.1装饰者设计模式介绍.md 中的 Decorator 代码)。模板方法模式则相反,它天然依赖继承:抽象类用 templateMethod() 定义算法骨架,用 abstractMethod() 等基本方法迫使子类实现剩余逻辑(参见 design/02.行为型模式/03.1模版方法模式上.md)。这说明:两种关系各有适用领地,盲目二选一反而是不专业的。

4.3 场景一:仅为代码复用而生硬造父类 → 用组合

继承可以实现代码复用,但不是所有能复用的情况都适合用继承。有时从业务含义上看,A 类和 B 类并不具有继承关系——既不是父子关系,也不是兄弟关系。比如 Crawler 类和 PageAnalyzer 类都用到了 URL 拼接、分割的功能,但两者业务上毫无"血缘"。

如果仅仅为了代码复用,生硬地抽象出一个 Url 父类让两者继承,不熟悉设计背景的同事看到"两个业务无关的类继承了同一个只含 URL 操作的父类",会觉得代码莫名其妙、难以理解。这种场景用组合更合理、更灵活:

public class Url {
  //...省略属性和方法
}

public class Crawler {
  private Url url; // 组合
  public Crawler() {
    this.url = new Url();
  }
  //...
}

public class PageAnalyzer {
  private Url url; // 组合
  public PageAnalyzer() {
    this.url = new Url();
  }
  //..
}

组合在这里的优势是语义清晰:Crawler 是"拥有一个 Url 工具",而不是"是一个 Url 工具",has-a 关系如实反映了业务本质,也避免了伪造 is-a 关系造成的理解障碍。

4.4 场景二:必须使用继承的特殊情况

也有一些特殊场景要求我们必须使用继承:如果不能改变一个函数的入参类型,而入参又非接口,为了支持多态,只能采用继承实现。

比如 FeignClient 是一个外部框架类,我们没有权限修改它,但希望重写它在运行时执行的 encode() 方法:

public class FeignClient { // feign client框架代码
  //...省略其他代码...
  public void encode(String url) { //... }
}

public void demofunction(FeignClient feignClient) {
  //...
  feignClient.encode(url);
  //...
}

public class CustomizedFeignClient extends FeignClient {
  @Override
  public void encode(String url) { //...重写encode的实现...}
}

// 调用
FeignClient client = new CustomizedFeignClient();
demofunction(client);

demofunction() 的入参类型写死为 FeignClient(非接口),想要注入自定义实现、实现多态,唯一的途径就是继承 FeignClient 并重写方法。此时组合无能为力——这不是"组合优于继承"的例外,而是组合根本无法触及的边界。


05. 总结一下:三个问题的最终答案

1. 为什么不推荐使用继承?

继承是面向对象的四大特性之一,用来表示类之间的 is-a 关系,可以解决代码复用问题。虽然继承有诸多作用,但继承层次过深、过复杂,会影响到代码的可维护性(可读性变差、封装被破坏、父子类高度耦合)。在这种情况下,应该尽量少用,甚至不用继承。

2. 组合相比继承有哪些优势?

继承主要有三个作用:表示 is-a 关系、支持多态特性、代码复用。这三个作用都可以通过组合、接口、委托三个技术手段达成:

  • is-a 关系 → 组合 + 接口的 has-a 关系;
  • 多态特性 → 接口实现;
  • 代码复用 → 组合 + 委托。

除此之外,利用组合还能解决层次过深、过复杂的继承关系影响代码可维护性的问题——鸟类设计中"组合爆炸"的场景,用接口 + 能力类 + 委托可以优雅地拍平成一层。

3. 如何判断该用组合还是继承?

尽管鼓励多用组合少用继承,但组合也不是完美的(需要更细粒度的类拆分,增加类与接口数量),继承也并非一无是处。实际开发中应依据以下标准权衡:

  • 用继承:类之间继承结构稳定、层次浅(最多两层)、关系不复杂;或者遇到无法修改函数入参类型且入参非接口、必须靠继承实现多态的特殊场景;
  • 用组合:系统不稳定、继承层次深、关系复杂;或两个类仅存在代码复用诉求但业务上无 is-a 关系。

此外,装饰者模式、策略模式、组合模式固定使用组合关系,模板方法模式固定使用继承关系——组合不完美,继承也不是一无是处。控制好两者的副作用、发挥各自优势,在不同场合恰当选择,才是追求的境界。


延伸阅读

本文所在的"面向对象思想"系列,以及仓库中与之相互印证的文档,推荐按以下顺序阅读,形成完整的设计知识闭环:

登录后查看全文
YCBlogs