YCBlogs 面向对象设计精讲:为什么"多用组合少用继承"——组合优于继承的实战剖析
YCBlogs 面向对象设计精讲:为什么"多用组合少用继承"——组合优于继承的实战剖析
导读:本篇文章以 YCBlogs 仓库中《多用组合和少继承》笔记为核心骨架,系统剖析"组合优于继承"这条经典设计原则的来龙去脉:继承在哪些场景下会失控、组合+接口+委托如何优雅化解继承难题、以及实际项目中到底该如何权衡组合与继承。读者读完可以掌握"鸟类继承爆炸"这类问题的重构手法,理解装饰者、策略、模板等设计模式与两种关系的内在关联,并能在日常编码中做出有依据的选型判断。
在面向对象编程的世界里,有一条流传甚广的经典设计原则——组合优于继承(Favor Composition over Inheritance),也常被表述为"多用组合少用继承"。它在 Java、Android 等以继承为主要复用手段的静态语言生态中尤其具有现实意义:extends 用起来顺手,但一旦继承层次失控,代码的可维护性会以肉眼可见的速度劣化。本文基于 design/06.面向对象思想/07.多用组合和少继承.md 的核心脉络,结合仓库中面向对象四大特性、接口 vs 抽象类、基于接口而非实现编程、迪米特原则等姊妹篇,以及多个设计模式文档,把一个看似"口号化"的原则讲透、讲实操。
01. 前沿:为什么"组合优于继承"值得专门讨论
在面向对象编程中,继承是面向对象四大特性之一(封装、抽象、继承、多态,详见 design/06.面向对象思想/02.四大特性详细说明.md),用来表示类之间的 is-a 关系,其最直接的收益是代码复用:把多个子类共有的属性和方法抽取到父类中,子类通过 extends 直接复用。
然而,正因为继承的"复用"太容易获得,开发者往往会过度使用继承,导致三个典型问题:
- 继承层次过深:为了搞清一个类有哪些方法、属性,必须沿着"父类 → 父类的父类 → …"一路向上追溯,代码可读性直线下降;
- 继承关系过复杂:多维度行为(会不会飞、会不会叫、会不会游泳、会不会下蛋)交织在一起时,类数量呈指数级膨胀,这就是著名的"组合爆炸";
- 破坏封装:子类实现强依赖父类实现,两者高度耦合,父类一旦改动,所有子类的逻辑都会受牵连。
所以业内出现了两种声音:一种认为继承是"反模式",应尽量少用甚至不用;另一种则主张辩证看待。本文的观点并不极端——"多用组合少用继承"喊得响,恰恰是因为继承长期被过度使用。问题的关键从来不是"禁用继承",而是搞清楚:继承在什么场景下会失控?组合相比继承有哪些不可替代的优势?什么情况下又必须使用继承?
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 关系。
此外,装饰者模式、策略模式、组合模式固定使用组合关系,模板方法模式固定使用继承关系——组合不完美,继承也不是一无是处。控制好两者的副作用、发挥各自优势,在不同场合恰当选择,才是追求的境界。
延伸阅读
本文所在的"面向对象思想"系列,以及仓库中与之相互印证的文档,推荐按以下顺序阅读,形成完整的设计知识闭环:
- design/06.面向对象思想/01.面向对象思想说明.md:面向对象编程的总体认知;
- design/06.面向对象思想/02.四大特性详细说明.md:封装、抽象、继承、多态四大特性的本质与实现机制;
- design/06.面向对象思想/05.接口vs抽象类比较.md:is-a 与 has-a 的语法与设计差异,组合替代继承的语法基础;
- design/06.面向对象思想/06.接口而非实现编程.md:接口作为"协议/契约"的解耦实践;
- design/07.代码设计原则/07.迪米特原则介绍.md:最小知识原则与"高内聚、松耦合";
- design/04.结构型模式/03.1装饰者设计模式介绍.md 与 design/02.行为型模式/02.1策略者设计模式.md:组合关系的模式级应用;
- design/02.行为型模式/03.1模版方法模式上.md:继承关系的正面典型案例。