
干这行十几年我见过太多把继承用成万能锤的代码。点开一个类的继承结构满屏的 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) 方法直接暴露了出来StackString 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 定义身份具体到底怎么算价格交给 Pricerpublic 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 方法把校验放进去也别让外部随便摸内部零件。我这些年最深刻的体会是继承像写死的一纸婚约关系在领证那天就锁死后面任何变化都得通过离婚重组来完成组合像搭乐高今天想要什么能力就往上面拼什么零件明天不需要了拆下来换一个房子还是那栋房子结构却可以随时调整。这也是组合优先于继承能活二十多年的原因——它不是教你放弃继承而是教你在绝大多数场景下先把可以拆装这四个字想明白再决定要不要把关系焊死。最后再给一个小技巧下次写新类之前先写接口再想想这个类的行为有哪些可能变化的维度。每找出一个可能变的维度就意味着一个应该用组合去承载的变化点。把这个动作养成习惯你就不需要再背组合优先于继承这句话了。