ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

组合优先于继承:面向对象设计中的代码复用与重构实战指南

组合优先于继承:面向对象设计中的代码复用与重构实战指南 如果只能挑一条面向对象设计的原则带进项目里我会毫不犹豫选这句话组合优先于继承。这不是一句口号而是我在真实项目里见过继承的代价之后得出的结论。有一次为了给HashMap增加“记录最后访问时间”的功能团队直接把它包进了LinkedHashMap的子类里后来要同时支持LRU、LFU等不同淘汰策略发现整个继承结构把扩展锁死了。改来改去要么父类方法被悄悄覆盖后行为漂移要么子类之间的差异根本没法复用最后只能推倒重构把继承层级删掉改成内部持有一个Map成员的普通类再用策略接口去切换淘汰逻辑。代码量少了一半测试反而更简单了。这个原则的字面意思很简单需要复用代码时优先用对象组合一个类持有另一个类的实例而不是用类继承去强行建立父子关系。但它背后的道理以及落地时的各种取舍远比这句话复杂得多。这篇内容我会从一次真实的Code Review教训讲起拆解继承的三大隐性陷阱再手写代码对比组合怎么解掉这些问题最后给出几个我实际在用的判断标准。适合刚接触OOP的新人、被继承层级折磨过的老手以及要在团队里做设计规范的人。1. 一个把项目拖垮的继承案例来自真实Code Review的教训1.1 从“简单需求”到“依赖倒置”先把这个故事说完整。当时我们做一个数据缓存模块需求是记录每个 key 的最后访问时间用于后续的淘汰统计。有个同事选择继承LinkedHashMap重写removeEldestEntry来实现 LRU。代码大概长这样public class LruCacheK, V extends LinkedHashMapK, V { private final int maxSize; public LruCache(int maxSize) { super(16, 0.75f, true); this.maxSize maxSize; } Override protected boolean removeEldestEntry(Map.EntryK, V eldest) { return size() maxSize; } }第一版上线后一切正常。后来产品提了新需求某些缓存数据不能淘汰必须保留另外一批数据要用 FIFO 策略。这个时候问题来了——怎么扩展有人提议再写FifoCache extends LinkedHashMap再加一个NeverExpireCache extends LinkedHashMap。可是“某些 key 不淘汰”和“LRU 淘汰”怎么共存写在父类里那FifoCache就不适用。写在子类里又要覆盖一堆方法。更隐蔽的坑还不在这。LinkedHashMap内部有afterNodeAccess、afterNodeInsertion这类钩子方法如果你在子类里重写了put、get之类的公共方法很难确定父类内部到底哪个流程会调到你重写的版本哪个流程会绕过。后来加了一个“访问计数”功能有个同事重写了get方法结果发现containsValue里面也能走某种读取路径计数直接翻倍。排查了两天才定位到是父类内部其他方法调用了被覆盖的get。这就是典型的脆弱基类问题。1.2 为什么“能用”和“可扩展”是两码事这个案例里最值得思考的一点是第一版用继承的时候代码确实能跑Bug 也少。问题从来不出在“已经实现的功能”上而出在“未来可能要实现的功能”上。软件工程领域有个残酷现实需求变化的频率和方向往往超出最初的建模假设。继承在做设计时把类和类之间的关系在编译期就焊死了子类能做什么、不能做什么被父类的内部实现牢牢限制住。很多人说“继承是白盒复用组合是黑盒复用”这个说法放在这个例子里特别贴切。继承一个LinkedHashMap意味着子类能看到并依赖父类的内部机制比如removeEldestEntry的调用时机、内部节点访问顺序的维护逻辑一旦父类在版本升级时调整了内部逻辑子类的行为就可能在无声无息中改变。而组合一个Map成员外部根本不关心这个Map到底是HashMap还是TreeMap只依赖Map接口这个稳定的契约。契约稳定实现随便换这才是可扩展性真正的来源。1.3 那次重构我们到底做了什么最终的解决方案不是去扩展LinkedHashMap而是定义了一个淘汰策略接口public interface EvictionStrategy { void recordAccess(Object key); void recordInsertion(Object key); boolean shouldEvict(int currentSize); }然后分别写了LruStrategy、FifoStrategy、NoEvictionStrategy。缓存类本身只负责持有Map和EvictionStrategy不再继承任何具体集合类public class CacheK, V { private final MapK, V store new HashMap(); private final EvictionStrategy strategy; public Cache(EvictionStrategy strategy) { this.strategy strategy; } public V get(K key) { V value store.get(key); if (value ! null) { strategy.recordAccess(key); } return value; } public void put(K key, V value) { strategy.recordInsertion(key); store.put(key, value); if (strategy.shouldEvict(store.size())) { evict(); } } }重新梳理后新加一种淘汰策略只需要新增一个EvictionStrategy实现类缓存类一行不改。这就是组合优于继承最直观的收益功能差异被抽象成接口具体实现由外部注入整体代码遵循开闭原则——对扩展开放对修改关闭。如果你在项目里见过那种“每接一个需求就改一次基类”的类你就知道这个收益有多重要了。2. 继承的三大隐性陷阱脆弱基类、菱形问题与语义错位2.1 脆弱基类父类的每一次改动都是一次对子类的暗杀“脆弱的基类问题”在圈内几乎是人尽皆知的老坑但我发现很多初级开发者并没有真正理解它有多危险。它最阴险的地方在于父类作者认为自己在改自己的私有实现实际上他改的是所有子类共同依赖的行为契约。我用一个生活化的类比解释。你把房子租出去合同里写着“公共走廊由房东维护”。某天你想美化一下公共区域在走廊尽头装了一面镜子。结果镜子反射的光线每天早上都会透过某个租客的门缝晃醒他。在你的视角里你只是在装修自己的房子在所有租客的视角里他们的生活被你的装修计划强制改变了。继承关系就是这种合同父类是房东子类是租客父类一旦动工所有子类都得跟着受影响。写代码的场景更具体。假设你有一个Bird基类public class Bird { public void fly() { double speed calculateWingSpeed(); System.out.println(flying at speed); } protected double calculateWingSpeed() { return 10.0; } }Penguin子类想复用fly()的逻辑但它的翅膀速度计算方式不同于是重写了calculateWingSpeed()返回 0.01。某天你为了支持一种新的风筝鸟修改了fly()内部对calculateWingSpeed()的调用频率或传参方式。你压根不会想到企鹅的飞行数据也被连带改掉了。如果企鹅这个类不在你的视野范围内这个 Bug 可能要等线上出问题才会被发现。这种问题没法靠单元测试提前预防因为你不可能知道世界上有多少子类、每个子类如何依赖父类的内部方法。这也是为什么 GoF 的《设计模式》在开篇就用大量篇幅强调优先使用对象组合而不是类继承。组合模式下Bird内部持有一个FlyBehavior接口企鹅飞不起来就注入一个NoFly实现父类的变化根本传不到子类身上。2.2 菱形问题多继承的结构性死结与接口的无奈菱形问题diamond problem是面向对象领域的老生常谈。当两个子类继承同一个父类而一个类同时继承这两个子类时父类的方法就产生了路径歧义到底应该调用哪一个分支传来的实现C 用虚继承来缓解Java 选择不允许多继承类只允许实现多个接口。但接口多实现不是万能药它只约定“有这个能力”不约定“能力怎么实现”。当两个接口的方法签名相同、语义却恰好相反时实现类依然左右为难。举个例子。接口Readable定义void read()接口Writable也定义void read()但语义是“标记为已读”。你做一个Message类同时实现两个接口编译器只要求你写一个read()。那你到底实现哪一个这是语法层面合法、设计层面一塌糊涂的典型。继承体系擅长表达树状结构可现实世界的对象关系往往是网状结构树去表达网必然有表达不了的时候。Java 的默认方法default method又给这个坑加了点料。两个接口的默认方法同名实现类必须手动解决冲突否则编译过不了。如果默认方法本身有复杂的调用链解决冲突时的选择会直接影响后续维护。很多人遇到这种问题第一反应是“我用继承不是挺好吗”但真正的问题根源在于你在用编译期的静态关系去强行建模运行期才能确定的动态行为。组合在这个维度上有天然优势因为它是运行期的对象装配不是编译期的类绑定。2.3 语义错位is-a 关系的虚假承诺第三个坑是语义错位。判断能不能用继承教科书会告诉你“子类是父类的一种”也就是 is-a 关系成立。但很多人忽略了一个关键这个 is-a 是在什么层面上成立如果是在数学定义上成立行为约束上不成立那继承照样会翻车。最经典的例子就是椭圆和圆。几何学上圆是椭圆的一个特例is-a 关系成立。但如果你定义一个Ellipse类提供setWidth(double w)和setHeight(double h)两个方法允许宽高分别变化然后让Circle extends Ellipse问题立刻爆发——圆形要求宽高永远相等setWidth会造成一个宽 10、高 8 的“圆”不变性被破坏所有依赖圆的算法全部出错。这里 is-a 只在数学定义里成立在行为约束里完全不成立。更容易理解的例子是 JDK 里的经典失误。java.util.Stack继承java.util.Vector从数据结构定义上看栈是“只能在一端操作元素的线性表”向量是“任意位置可访问的动态数组”。强行让栈继承向量造成了一个至今仍被人诟病的结果你可以对Stack调用get(int index)和add(int index, E element)栈的“后进先出”原则成了笑话。这个设计已被普遍认为是早期 JDK 的错误但为了兼容性至今没有移除。所以判断 is-a 关系是否成立真正的标准不是“它是不是一种”而是“它能不能完全替代父类而不违反父类的任何行为规则”。这就是里氏替换原则的核心子类对象应该能够替换父类对象且不破坏程序的正确性。如果做不到哪怕数学上是“一种”也应该选用组合。这就是为什么“组合优先于继承”往往是救命的那个原则。3. 组合凭什么能解决这些陷阱接口约定加对象委托的实现拆解3.1 组合的本质从“我是一个”到“我有一个”要理解组合为什么能解掉上面那些问题先要看清它到底做了什么。组合的本质是一个类持有另一个类的实例通过调用被持有对象的方法来复用能力。它建立的是 has-a有一个关系而不是 is-a是一个关系。Car内部有一个Engine成员变量想加速时调用engine.accelerate()Car 不是 Engine但 Car 的加速能力可以委托给 Engine。这个方向的转变带来三个直接影响。第一内部细节被彻底封装外部只通过公开方法交互父类变化传不到子类身上。第二被组合的对象在运行期可以替换把燃油引擎换成电动引擎只要符合同一个接口Car 自身代码不用改。第三类之间的关系从编译期固定变成运行期可选择这给代码带来了灵活性也是策略模式、装饰器模式、状态模式等一批设计模式的底层结构基础。继承则是另一套逻辑子类通过 extends 直接获得父类的内部字段和方法这种复用是透明的、强耦合的。它是“白盒复用”的典型子类像看家底一样看到父类的实现细节。组合是“黑盒复用”你只调用别人公开的行为不关心内部怎么实现。黑盒的好处是隔离容易替换白盒的好处是省事拿来即用。但省事和稳定之间大多数时候你得选一个。3.2 用代码对比继承版鸟类模型 vs 组合版鸟类模型上面这些概念用代码对比例子能看得最清楚。仍然构造“动物与飞行能力”这个需求但这次注意观察两种方案在“新增一只蝙蝠”时的差异。继承方案最直觉的写法是这样public class Animal { public void eat() { System.out.println(eating); } } public class Bird extends Animal { public void fly() { System.out.println(flying); } } public class Duck extends Bird { Override public void fly() { System.out.println(duck flying); } } public class Penguin extends Bird { Override public void fly() { throw new UnsupportedOperationException(penguin cant fly); } } public class Bat extends Animal { public void fly() { System.out.println(bat flying); } }这里已经出现很糟糕的迹象了。企鹅继承Bird但因为它不会飞只能重写fly()并抛出异常。它获得了不该获得的能力破坏了父类的行为契约。蝙蝠是哺乳动物按这个继承树只能单独从Animal分出去但仍然要写一份和Bird.fly()类似的逻辑。以后再来一只鸭子能游泳、一只鸵鸟不会飞、一只老鹰能飞能捕猎这个树的复杂度会迅速失控。组合方案重构一下核心是把“飞行行为”抽象成接口然后注入到对象中public interface FlyBehavior { void fly(); } public class CanFly implements FlyBehavior { Override public void fly() { System.out.println(flying); } } public class NoFly implements FlyBehavior { Override public void fly() { System.out.println(cant fly); } } public class Animal { private FlyBehavior flyBehavior; public Animal(FlyBehavior flyBehavior) { this.flyBehavior flyBehavior; } public void performFly() { flyBehavior.fly(); } public void setFlyBehavior(FlyBehavior flyBehavior) { this.flyBehavior flyBehavior; } } public class Duck extends Animal { public Duck() { super(new CanFly()); } } public class Penguin extends Animal { public Penguin() { super(new NoFly()); } }“不会飞”不再靠抛异常表达而是靠注入NoFly这个行为对象。要做到“新增蝙蝠”只需要做一个Bat extends Animal构造函数里同样注入CanFly即可不需要动任何其他类。甚至运行期可以动态改变飞行能力duck.setFlyBehavior(new NoFly())这在继承方案里是完全做不到的。这就是策略模式在实际设计中的应用——它把一个类的行为变化点抽出来用组合接口委托的方式代替继承。3.3 组合的代价代码变多、间接性变强但长期成本更低肯定有人要说了这个组合方案写起来比继承方案啰嗦多了。接口要新建实现类要新建构造函数要注入代码量肉眼可见地增加了。这个观察没错。组合在做“最初建模”的时候确实比继承多出不少样板代码。但软件工程盯的是长期维护成本不是第一版的编码速度。继承方案的维护成本随着需求增长是爆炸式的。每个新需求都可能动到父类动到父类就波及所有子类测试回归范围越滚越大。组合方案的维护成本增长是线性的新需求一般只影响一个接口实现类其他类不用碰。前期多写的那一百行代码后期节省的是无数次线上故障排查的时间。很多团队不敢重构继承结构就是因为继承链上的类太多牵一发动全身。而组合结构因为依赖接口重构时可以只重写某个实现类或者替换一个成员对象隔离性很强。这也是我在实际项目里投组合一票的根本原因。4. 组合不是银弹什么场景下继承反而是更好的选择组合优先于继承重点在“优先”不在“绝对”。如果只听前半句很容易走向另一个极端把继承当成洪水猛兽甚至为了避免继承做出更离谱的设计。我见过有人为了不用继承硬生生造了四五个接口、七八个转发类代码读起来要跨七个间接层才能理解一次调用。这跟继承滥用相比只是换了一种折磨方式。真正成熟的设计者会在合适的场景里拥抱继承。4.1 真正稳定且纯粹的 is-a 关系第一种适合继承的场景是关系极其稳定的 is-a。典型例子是 Java 集合框架里的AbstractList和ArrayList。ArrayList is a AbstractList这个关系过去几十年没有变过未来大概率也不会变而且父类和子类都是同一个类库作者维护不是外部失控代码。这种情况下子类继承父类来复用实现既没有语义错位也没有脆弱基类的风险因为父类的演进方向完全可控。判断 is-a 是否“纯粹”关键看里氏替换原则是否成立。ArrayList对象在所有需要AbstractList的地方都能正常工作行为完全符合父类契约这才叫纯粹的 is-a。如果你判断一个关系可能在未来走形比如“圆是椭圆”这种只在数学上成立、行为上随时会翻车的就别用继承。4.2 模板方法模式父类定骨架子类填细节第二种适合继承的场景是模板方法模式。父类定义一个算法流程的骨架把其中某些步骤延迟到子类实现。这个场景下父类作为模板提供主流程子类只按契约填充差异步骤两者是协作关系而不是“父类被多个子类共享实现”的关系。典型例子依然是AbstractList的iterator()方法。它内部会调用子类实现的get(int index)和size()从而自动获得一个完整的迭代器。子类只需要实现两个抽象方法就能复用整套迭代逻辑。这比组合更紧凑因为模板方法针对的是“稳定骨架 可变步骤”的模式。如果子类之间的差异只是几个步骤的具体实现组合反而会把这些步骤拆成散落的策略对象读起来更绕。用模板方法时有个纪律要守住父类不要把业务逻辑全部堆进一个方法然后让子类继承。父类只负责编排和约定所有可变点都做成抽象方法或受保护方法让子类去覆盖。如果父类把太多可变逻辑写死在自己方法体里那和脆弱基类问题没有区别。4.3 需要访问 protected 成员、匿名内部类等语言机制的场合第三种场景是 Java 语言机制上的硬性限制。组合拿不到父类的protected成员。如果某个抽象基类设计了受保护的状态字段且子类的业务逻辑必须依赖这些字段强行改成组合会破坏访问控制反而逼得子类去维护一份并不应该自己维护的数据。这种情况继承是更务实的选择。另外匿名内部类、lambda 表达式在本质上也是“轻量级子类”的语法糖。比如new Thread(() - ...)背后的Runnable实现就是通过接口和内部类机制在创建行为对象这其实已经是一种“组合思维”的体现了。你不能把这些场景都替换成对象组合因为它们本身就是构建组合的必要工具。4.4 判断继承是否合理的“三连问”在项目里决定用哪种方案我会先做三个判断题这个 is-a 关系在未来三年内会不会被需求变化击穿如果答案是不会继承可以考虑。父类的职责是稳定的骨架还是可能会被频繁修改的实现细节如果父类很可能频繁变化继承就是给未来埋雷。继承层级会不会超过三层如果超过三层第一直觉应该是重构而不是继续往下加子类。这三个问题里只要有一个答案是犹豫的我就会切到组合。设计决策最怕的不是选错而是不加思考地默认选择某一种。很多人拼命用继承只是因为 IDE 里写 extends 太顺手了根本没过脑子。5. 实战对照非原生下拉框的定位与 Java 集合扩展的实现选择5.1 前端为什么下拉框变成 divulli 组合而不是继承出几十种下拉框Web 自动化测试里经常遇到一个让人抓狂的场景用 Selenium 定位下拉框时原生select标签可以用Select类直接selectByVisibleText()但页面里有一类下拉框根本不是select而是用divulli手动拼出来的。每次定位都要先点击目标元素再循环扫描列表比较文本最后点击选项。很多人在这一步选择写一次性代码过程相当痛苦。这个问题背后的设计哲学恰好也是组合优先于继承。现代前端框架React、Vue在构建可复用组件时采用的组合方式而不是让组件之间大量使用 extends。为什么因为 UI 形态的差异是爆炸性的下拉框有本地搜索的、有多选的、有级联的、有带图标的、有虚拟滚动的。如果每一种都从“基础下拉框”继承父类任何一次样式或行为改动都会引发整棵继承树的连锁反应。而组合允许你把“输入框”“列表”“搜索逻辑”“选中状态”拆成独立组件然后像积木一样拼装各自维护各自的边界。所以当你用 Selenium 定位divulli组合出来的下拉框时本质上是在和一个按组合模式构建的 UI 打交道。它的 DOM 层级天然比原生select深类名更多变结构更容易被样式调整影响。我的建议是不要为每个页面写死一套定位脚本而是封装一个组合式的选择器工具内部持有WebElement引用把“展开、扫描、点击”的逻辑集中在一处。下面是一个简化版的思路核心是把通用交互封装成类而不是为每一种下拉框写子类public class SmartSelect { private final WebElement hostElement; public SmartSelect(WebElement hostElement) { this.hostElement hostElement; } public void selectByText(String text) { hostElement.click(); ListWebElement items hostElement.findElements(By.xpath(./following-sibling::div//li)); for (WebElement item : items) { if (item.getText().trim().equals(text)) { item.click(); return; } } throw new IllegalArgumentException(option not found: text); } }为了配合这个类每一处非原生下拉框只需要提供它的宿主节点剩下的展开和选择逻辑都被封装起来。当一个页面上有十个不同结构的自定义下拉框你要做的不是写十个子类而是让每个页面对象组合一个SmartSelect内部处理各自的 DOM 差异。这不正是“组合”在自动化测试里的落地用法吗行为变化点被抽象成方法页面对象通过成员引用来复用能力而不是通过继承来复制能力。5.2 后端为什么用组合包装 Map而不是继承扩展 HashMap再看后端的例子。假设系统里需要一个“带日志审计的 Map”就是所有 put、remove 操作都记录日志。第一种写法是继承HashMap并重写putpublic class LoggedMap extends HashMapString, String { Override public String put(String key, String value) { System.out.println(put: key); return super.put(key, value); } }看起来很简洁对吧但这个方案有一个非常隐蔽的坑继承HashMap后类的使用者可以直接调用putAll()、clear()、computeIfAbsent()、merge()等一系列方法。其中putAll()在Map接口里有默认实现HashMap自己也维护内部数组它完全可能不走put方法而直接操作内部结构。如果你把putAll()当成“多次调用 put”的入口期望每次都有日志那结果往往会漏掉一部分操作。这就是覆盖父类公共方法时对父类内部调用链不可知的代价。组合的写法则是把HashMap作为私有成员通过接口方法定义自己的行为public class LoggedMap implements MapString, String { private final MapString, String delegate new HashMap(); Override public String put(String key, String value) { System.out.println(put: key); return delegate.put(key, value); } // 其余 Map 方法全部转发给 delegate }这里有一个现实麻烦Map接口方法很多要实现完整转发确实要写不少模板代码。我自己的处理方式是用装饰器模式配合动态代理或者在项目里直接引入现成的装饰器集合类避免手写几百行转发。关键在于组合方案的行为是可预测的任何修改集合内容的行为都会走到你定义的转发方法里你可以统一做审计。而继承方案里你怎么知道父类有一个方法绕过你的重写直接操作了底层数组你不知道只能靠测试去撞。这个例子很好地回答了“为什么 JDK 自己都经常用组合充当内部实现”的原因组合能让行为的扩展点成为外显的、可控的接口方法而继承则让扩展点散落在父类的内部调用链里。6. 从“优先”到“熟练”几个可以直接上手的判断标准最后分享一套我自己在项目里反复使用的判断路径也相当于把前面的内容压缩成一张检查单。每当你准备写一个 extends 时按顺序过一遍这些问题多数情况下会得到靠谱的结论。第一步说自己要建立的类关系。如果你描述的句子中出现“是一种”比如“下拉框是一种控件”继承可以纳入考量。但请继续追问是数学定义上的“是一种”还是行为替换上的“是一种”如果是前者基本可以放弃继承。如果出现的是“包含一个”“持有一个”“内部有一份”那就走组合。第二步问未来会不会变。这里的“变”指的是类的能力边界。假设未来新增一个需求需要给父类增加一个方法或修改父类的某个方法实现你能不能接受所有子类一起变如果能接受继承尚可考虑如果不能接受组合的隔离性会省很多事。对未来的判断不需要特别远一年左右足矣。大多数继承陷阱都是在一两个迭代内爆发的。第三步看复用的层级。如果只是想复用三五个方法接口加委托的组合方式是最轻量的没必要建父子类。如果要复用一套完整的算法骨架而且子类之间的差异只集中在某几个步骤上模板方法式的继承是合理的。前者复用的是“能力”后者复用的是“结构”两者场景不同。第四步数一数继承层级。经验法则是超过三层就要警惕超过五层基本就是在给后人埋雷。看到这种继承链时先别急着加新的子类把底层的共同行为抽取成接口再用组合把具体实现注入进来通常比继续往下 extends 要健康得多。继承层级越深理解成本越高测试成本也越高运行期行为越难预测。第五步也是很多人会忽略的写单元测试的难度。继承方案中父类的 protected 状态和方法会被子类依赖测试子类时得初始化整套父类逻辑组合方案中成员对象可以直接 mock测试只关注当前类的行为逻辑隔离性非常好。一个类如果很难被单独测试这个设计往往已经在冒坏味道了。回到开头那个给HashMap加日志的例子。按照这套标准过一遍描述关系是“带日志的 Map”不是“Map 的一种日志形态”组合已占优未来变化方向不明确测试时希望只测日志逻辑、不关心底层HashMap内部实现组合又占优继承层级虽然只有一层但父类行为不可控。三个信号都指向组合结果当然选组合。最后说一条我自己的实操心得如果你决定用继承请务必在 code review 时明确说清楚“这个 is-a 关系为什么不会变”。说不清楚默认切组合。这不是教条而是用最少的思考成本避免最大的维护风险。设计上的省事最终都会以维护上的费事加倍偿还。希望这篇内容能帮你少踩一些继承的坑也欢迎在实战中继续验证这套判断标准再摸索出属于你自己团队的那套取舍逻辑。
返回列表