干这行十几年,我见过太多把继承用成"万能锤"的代码。点开一个类的继承结构,满屏的 extends,整个项目像一株藤蔓互相缠绕的老树,改一个父类的字段,隔天微信群就炸了——七八个模块同时报错。于是团队里开始流行一句话:组合优先于继承(composition over inheritance)。这话最早出自《设计模式》那本书,二十多年过去,它仍然是面向对象设计里被引用最多、却也被误解最深的一条原则。
这篇文章我打算把这条原则彻底讲透:继承到底错在哪,组合到底好在哪里,什么样的情况下你才应该继续用继承。我会用真实业务代码演示一次从继承到组合的重构,也会把配套的策略模式、装饰器模式讲清楚。适合正在维护一个"继承树越长越离谱"的老项目、或者刚学完 OOP 三大特性但一到设计就上手的开发者。
先给一句大白话结论:继承是"我是你",组合是"我有你"。听起来简单,但做起来牵扯到耦合、封装、运行时灵活性、可测试性一大堆东西。
1. 继承出问题,从来不是因为"继承"这个词
1.1 教科书里的"封装继承多态",为什么落到项目里就变味
每个学 Java 或者 C++ 的人,入门三大特性都是封装、继承、多态。教材上画着一只动物,下面派生出猫、狗、鸟,然后让它们各自叫唤——非常美好。但真实的业务系统根本不是动物园,真实的需求是"一个订单有普通价、会员价、促销价、满减价、组合价",是"一条消息能发短信、发推送、发邮件、全部都要发"。
这些需求有一个共性:它们不是简单的"一个类是另一个类的子类型",而是"同一个对象在不同维度上有多重身份"。继承表达不了这种多重身份,因为继承是单维度的树,而业务是交叉的图。
我在项目里见过最典型的翻车代码是这么写的:
public class DiscountProduct extends Product { // 打折商品 private double discountRate; @Override public double getPrice() { return basePrice * discountRate; } } public class VipProduct extends DiscountProduct { // 会员商品:在打折基础上再享受会员折扣 private double vipRate; @Override public double getPrice() { return super.getPrice() * vipRate; } }然后促销活动加一个"满300减50",产品经理说:这个可以叠加会员折扣,也可以不叠加。请问你怎么写?是在 DiscountProduct 下面再派生一个满减类?那 VipProduct 要不要也满减?要不要再派生 VipDiscountFullReductionProduct?类的数量像野草一样疯长,每加一个促销维度,整个类树就要翻一倍。
这就是继承最核心的缺陷:它把"复用"和"分类"绑在了一起,而真实世界里的能力组合是笛卡尔积式的,不是树状式的。
1.2 三个经典翻车现场:Stack、Properties 和你手下的业务代码
第一个经典翻车是 Java 标准库里 Stack 继承 Vector。Stack 本来应该实现"后进先出",但它继承了 Vector 之后,Vector 的 add(int index, E element) 方法直接暴露了出来:
Stack<String> stack = new Stack<>(); stack.push("a"); stack.push("b"); stack.add(0, "插队"); // 暴力插入栈底 System.out.println(stack.pop()); // 输出了 "插队",栈的约束直接被打穿第二个是 java.util.Properties 继承 Hashtable。Properties 本来只允许 String 类型的键值,但继承 Hashtable 之后,put(Object, Object) 依然可用,绕过 setProperty 方法往里塞非字符串对象,程序跑着跑着 ClassCastException 就冒出来了。
第三个就在你手边。我接手过一个老系统,订单类继承关系足足有四层:BaseOrder → PayableOrder → RefundableOrder → VIPRefundableOrder,每一层加两个字段给上一层打补丁。后来业务方要支持"先退款后支付"的流程,整个团队面面相觑——这个流程根本放不进任何一层子类里,最后只能靠一堆 if (order instanceof XXX) 硬扛。
这三个例子共同指向一个结论:继承的问题不在于关键字本身,而在于它带来的三类破坏——
- 破坏封装:子类能访问、重写父类的内部实现,父类稍一变,子类连锁反应,这就是教科书上说的"脆弱的基类问题"。
- 编译期锁死:继承是在写代码的时候就决定死了,运行时不换路。
- 结构违反直觉:只要出现"两个子类行为需要叠加"的情况,继承树必然膨胀。
2. 组合的本质:把"是什么"换成"有什么"
2.1 一句大白话解释组合
组合的意思特别简单:一个类不再拼命从父类拿能力,而是把能力委托给分配给它的其他对象。你要会飞,就持有一个"会飞"的对象;你要会叫,就持有一个"会叫"的对象。能力不是继承来的,是组装来的。
我拿硬件电路打个比方就通了。想做一个既有高输入阻抗又有强驱动能力的放大电路,不会有人让 BJT 去继承 JFET——因为两者根本不是父子关系。懂电路的人会直接用一只 JFET 加一只 BJT,搭成组合电路,各自干各自擅长的活。软件里的组合也是这个思路:不要试图让一个类"什么都是",而是让几个职责单一的小类配合,能干出更复杂的活。
再比如组合导航。只靠 GPS 有信号遮挡就定位漂移,只靠惯性导航越跑误差越大,把两者数据融合到一起,组合出来的位置误差反而最小。这不是谁继承谁的问题,而是把各自的长处组合起来。
2.2 一张对比表看清继承与组合
| 对比维度 | 继承 | 组合 |
|---|---|---|
| 关系表述 | "is-a":猫是一种动物 | "has-a":汽车有一台引擎 |
| 耦合强度 | 编译期强耦合,子类绑定父类 | 运行时弱耦合,只认接口 |
| 复用粒度 | 以类为单位整块复用 | 以接口/小对象为单位灵活复用 |
| 行为可变性 | 写死,运行时不换 | 运行时可换、可堆叠 |
| 测试难度 | 必须构造父类上下文 | 每个协作对象可单独 mock |
| 类数量 | 随维度组合指数膨胀 | 随维度线性增长 |
| 层次深度 | 容易越挖越深 | 基本只是"一层对象包一层对象" |
2.3 组合为什么能尊重封装
关于这个表,我最想解释的是"耦合强度"这一行。继承是 OOP 里能制造的最强耦合,因为在编译期,子类就和父类的内部实现交织在一起了。父类今天加一个构造参数,明天所有子类全得改;父类今天改一个方法的返回值,明天所有子类重写的代码全部编译失败。
而组合的对象之间只通过公开接口对话。Product 只知道 Pricer 有一个 calc(double) 方法,它不知道 Pricer 内部是打折还是满减,更不会因为 Pricer 改了内部实现而跟着改。这就是封装:每个对象管好自己,对外只暴露契约。你在 Product 里可以随时把 Pricer 换成另一个实现,Product 一行代码都不用动。
提示:判断一个设计是不是尊重封装,就看"换掉内部实现时,外部能不能无感知"。继承做不到,组合可以。
3. 手把手:把一段"继承写废了"的代码重构为组合
3.1 业务场景:电商价格计算
我用一个非常贴近日常电商业务的例子。假设有个 Product 类,基础价格 100 元。业务上出现了这么几种价格规则:普通价、打折价、会员价、礼品价(价格为 0,只在满赠活动里出现)。业务方明确说:这些规则可以任意组合,打折 + 会员、礼品 + 打折,都可能出现。
3.2 继承版本代码与刺眼的三个痛点
用继承写第一版,很多新手会自然写成一个树:
public abstract class Product { protected double basePrice; public Product(double basePrice) { this.basePrice = basePrice; } public abstract double getPrice(); } public class NormalProduct extends Product { public NormalProduct(double basePrice) { super(basePrice); } @Override public double getPrice() { return basePrice; } } public class DiscountProduct extends Product { protected double discountRate; public DiscountProduct(double basePrice, double discountRate) { super(basePrice); this.discountRate = discountRate; } @Override public double getPrice() { return basePrice * discountRate; } } public class VipProduct extends DiscountProduct { private double vipRate; public VipProduct(double basePrice, double discountRate, double vipRate) { super(basePrice, discountRate); this.vipRate = vipRate; } @Override public double getPrice() { return super.getPrice() * vipRate; } }这版代码有三处明显的问题:
第一,规则组合爆炸。打折和会员两个维度就要三个类,如果再加满减、赠品、秒杀,类数量指数增长。产品经理每新增一个玩法,你就得闭着眼睛写一堆 VipDiscountFullReductionFlashSaleProduct。
第二,多重规则叠加靠 super 链,顺序写死。假如某天规则变成"先减满减金额、再算折扣",你只能再建一个新类,把整个 super 链条重写一遍。
第三,运行期想换个玩法没门。订单从普通变会员,你得 new 一个新的 Product 对象,所有字段重新导一遍,改个规则像搬家一样痛苦。
3.3 组合版本重构:策略模式登场
重构思路只有一句话:把"价格怎么算"从 Product 里抽出去,由 Product 持有它。
public interface Pricer { double calc(double basePrice); } public class NormalPricer implements Pricer { @Override public double calc(double basePrice) { return basePrice; } } public class DiscountPricer implements Pricer { private final double discountRate; public DiscountPricer(double discountRate) { this.discountRate = discountRate; } @Override public double calc(double basePrice) { return basePrice * discountRate; } } public class VipPricer implements Pricer { private final double vipRate; public VipPricer(double vipRate) { this.vipRate = vipRate; } @Override public double calc(double basePrice) { return basePrice * vipRate; } } public class GiftPricer implements Pricer { @Override public double calc(double basePrice) { return 0; } } public class Product { private double basePrice; private Pricer pricer; public Product(double basePrice, Pricer pricer) { this.basePrice = basePrice; this.pricer = pricer; } public double getPrice() { return pricer.calc(basePrice); } public void setPricer(Pricer pricer) { this.pricer = pricer; } }使用起来更灵活:
Product normal = new Product(100, new NormalPricer()); Product discount = new Product(100, new DiscountPricer(0.8)); Product vipDiscount = new Product(100, new DiscountPricer(0.8)); // 运行时切换规则:原本普通价的商品,现在变成会员折扣价 vipDiscount.setPricer(new VipPricer(0.9));看出区别了吗?在组合版本里,任何一个新规则来了,你只需要写一个新 Pricer 实现,Product 和已有规则一行都不用动。这和"新增一个维度就新增一层子类"相比,开发量完全不是一个量级。
3.4 重构的收益:从代码角度算一笔账
假设有 N 个价格维度,继承方案最坏情况下要写 2 的 N 次方个类,组合方案只需要 N 个策略类加一个 Product。3 个维度时,继承要 8 个类,组合只要 4 个;5 个维度时,继承要 32 个类,组合只要 6 个。这个账算完,谁该优先用谁就非常清楚了。
更关键的是维护成本。继承树的深层结构意味着任何一次业务变更都可能影响一整片类;组合的 Product 只是一个稳定的外壳,真正的变化被隔离在一个个策略对象里。测试时也更舒服:测 DiscountPricer 不用管 Product 是谁,测 Product 时传一个 MockPricer 就行。
提示:组合版本里我特意保留了 setPricer 方法,它对应的是运行时行为切换。如果你用 Spring,这一步通常交给依赖注入,把 Pricer 作为 Bean 按需装配,效果是一样的。
4. 组合的"两板斧":策略模式与装饰器模式
4.1 策略模式:把算法抽成可插拔的零件
上一节的价格计算,其实就是策略模式。策略模式的定义很朴素:定义一族算法,把它们各自封装起来,让算法可以互相替换。它和组合是天然的一对——策略对象就是被组合进主对象的"零件"。
为什么策略模式能解决继承解决不了的问题?因为在继承里,算法和业务对象是焊死的:Product 是什么类,就只能按什么算法算价格。在策略模式里,算法是外置的、可替换的、甚至可以共享的。同一个 DiscountPricer 对象可以被一百个 Product 引用,这在继承世界里想都不敢想。
实际项目中我通常还会给 Pricer 类做一点简化,避免类爆炸。如果策略只有一个方法且逻辑简单,完全可以用 lambda 代替:
Product p = new Product(100, price -> price * 0.8);这就是接口设计的价值:接口保持简单,实现可以怎么轻怎么来。
4.2 装饰器模式:用包裹代替层层继承
如果说策略模式解决的是"换算法",装饰器模式解决的是"堆能力"。看这个需求:需要一个数据源,既能读写文件,又能在读写时加密,也许还要压缩。用继承写,你得准备 FileDataSource、EncryptedFileDataSource、CompressedFileDataSource、EncryptedCompressedFileDataSource……又是排列组合爆炸。
装饰器的写法是反过来,用一层包一层的组合:
public interface DataSource { String read(); void write(String data); } public class FileDataSource implements DataSource { private final String path; public FileDataSource(String path) { this.path = path; } @Override public String read() { // 读取文件的真实逻辑 return "file content"; } @Override public void write(String data) { // 写入文件的真实逻辑 } } public class EncryptDataSource implements DataSource { private final DataSource inner; public EncryptDataSource(DataSource inner) { this.inner = inner; } @Override public String read() { return decrypt(inner.read()); } @Override public void write(String data) { inner.write(encrypt(data)); } } public class CompressDataSource implements DataSource { private final DataSource inner; public CompressDataSource(DataSource inner) { this.inner = inner; } @Override public String read() { return uncompress(inner.read()); } @Override public void write(String data) { inner.write(compress(data)); } }要"加密 + 压缩"时,只需要:
DataSource source = new EncryptDataSource(new CompressDataSource(new FileDataSource("data.txt")));这个写法把每个能力做成独立的装饰器,能力的叠加顺序由你决定,不用为了每种组合新建一个类。我每次用装饰器都会想到一句话:继承是往树上接枝条,装饰器是往管道上拧接头。接枝条只能按树种来,拧接头可以任意排列。
4.3 两种模式合起来看:为什么组合天生不怕变化
策略模式和装饰器模式,一个是"换零件",一个是"套外壳",本质都是把一个对象的可变部分拆出来,用组合去拼装。它们带来了一个共同的好处:变化被局部化了。
在我维护的项目里,所有崩溃几乎都发生在"多人同时改动一个继承树"的时刻。A 往父类加了个字段,B 的子类构造函数就失效了。而组合结构下,你改 EncryptDataSource 不会影响 CompressDataSource,产品的代码量再多,也是平行的,不会互相拖累。
5. 那继承真的就不能用了吗?当然不是
5.1 判断标准:是不是一个"稳定且真实的 is-a"
如果一篇讲"组合优先于继承"的文章最后说继承一律不用,那它一定没写过真实代码。继承有它不可替代的位置,关键在于识别出什么时候它是"合理的 is-a"。
我给自己定的标准是两条:其一,子类和父类的关系在业务语言里是确定的,比如「企鹅是一种鸟」这种亘古不变的话;其二,这个"是一种"关系不会因为某个新需求突然断裂。反过来,如果你今天觉得"企鹅是一种鸟,所以鸟会飞,企鹅怎么办"是个问题,那从根上说就不是继承的问题,而是你根本不该让"是否会飞"成为鸟类的父类能力——它应该被组合进去。
稳定的 is-a 一个典型例子是图形领域:正方形是一种矩形、矩形是一种多边形,几何关系千年不变。这种类型体系用继承表达,业务上非常自然,也基本没有变化风险。
5.2 抽象类和接口的正确用法
在 Java 里,接口(implements)和继承(extends)是两回事。接口定义的是一份契约,"谁实现了它就必须提供这些方法",它不是给你复用代码的,是给你约束行为的。所以"面向接口编程"和"组合优先于继承"根本不冲突——组合正是建立在接口之上。
抽象类则要谨慎。我的经验是,抽象类最适合的场景是"模板方法":父类定好流程骨架,把某些步骤留给子类细化。最典型的例子是 JDK 的 AbstractMap,它把 get、containsKey 等一堆方法基于 entrySet 实现好,子类只要实现 entrySet 就有了一整套 Map 行为。这种继承是合理的,因为父类和子类之间是"算法骨架与细节填充"的关系,而不是"父类能力被子类滥用"的关系。
我自己用抽象类还有一个纪律:抽象类的最大深度不超过两层。超过两层的继承,不管当初看起来多合理,维护到第三年一定会后悔。
5.3 混合使用:继承负责"是什么",组合负责"有什么"
实战里最舒服的设计,其实是两者混用:用继承或接口确立对象的类型身份,用组合赋予它可变化的行为。还是拿产品举例子:
public interface Product { double getPrice(); }Product 定义身份,具体到底怎么算价格,交给 Pricer:
public class StandardProduct implements Product { private final double basePrice; private final Pricer pricer; public StandardProduct(double basePrice, Pricer pricer) { this.basePrice = basePrice; this.pricer = pricer; } @Override public double getPrice() { return pricer.calc(basePrice); } }这个"接口定类型 + 字段存行为"的套路,其实在 Go 里叫组合,在 Java 里叫讲设计,在 Python 里用鸭子类型天然支持。语言不同,思想完全一样。
6. 常见问题与排查技巧实录
6.1 类越来越多怎么办
很多人一听"组合优先",转身写了一大堆接口和实现类,一个 Strategy 配一个 Factory 再配一个 Registry,三行代码的功能拆出十个类。这属于矫枉过正。
我处理类爆炸有几个土办法。第一,策略实现能用 lambda 就用 lambda,别每个策略都单独开一个文件。第二,策略按业务域分组,一个域的文件放一个包,别往一个目录里堆。第三,如果策略实在是又小又多,可以用枚举实现策略,把同一类规则收敛在一个枚举里。核心原则是:组合是降低复杂度的手段,不是增加复杂度的仪式。
6.2 组合与继承的边界到底在哪里
我整理过一个自查表,写代码之前先过一遍:
| 情况 | 建议 |
|---|---|
| 子类是父类的真实子类型,且关系稳定 | 用继承 |
| 需要复用父类代码,但子类不满足 is-a | 用组合 |
| 同一维度可能叠加多种规则 | 用组合 |
| 规则需要在运行时切换 | 用组合 |
| 框架强制要求继承(如抽象类模板方法) | 用继承,但控制在两层内 |
| 只想约束"必须有这些方法" | 用接口,别用抽象类 |
记住一句话:继承是为了"复用类型",组合是为了"复用能力"。能复用能力的时候,永远优先复用能力。
6.3 我踩过的坑和留下的纪律
最后分享几个真实踩过的坑。
第一个坑是"为了组合而组合"。以前我重构一个订单模块,把完全不会变化的字段也用对象包了一层,结果下游三百处代码全部要跟着改调用链。组合拆分的对象必须有自己的变化理由,没有变化点就不要拆。
第二个坑是"组合对象的生命周期没人管"。组合出来的对象谁创建、谁销毁、谁保证不为 null,这些在继承里不用想的事,在组合里必须想清楚。我的习惯是:能在构造函数里注入的,绝不在外部 set;能被容器管理的,绝不手动 new。
第三个坑是"为了图省事破坏封装"。有人把 Product 的 Pricer 改成 public 字段,方便测试时直接赋值,结果业务代码到处 setPricer,策略组合的顺序全乱了。对外暴露的行为入口越少越好,宁可写一个 changePricer 方法把校验放进去,也别让外部随便摸内部零件。
我这些年最深刻的体会是:继承像写死的一纸婚约,关系在领证那天就锁死,后面任何变化都得通过"离婚重组"来完成;组合像搭乐高,今天想要什么能力就往上面拼什么零件,明天不需要了拆下来换一个,房子还是那栋房子,结构却可以随时调整。这也是"组合优先于继承"能活二十多年的原因——它不是教你放弃继承,而是教你在绝大多数场景下,先把"可以拆装"这四个字想明白,再决定要不要把关系焊死。
最后再给一个小技巧:下次写新类之前,先写接口,再想想这个类的行为有哪些可能变化的维度。每找出一个"可能变"的维度,就意味着一个应该用组合去承载的变化点。把这个动作养成习惯,你就不需要再背"组合优先于继承"这句话了。