1. 继承的“舒适区”为什么成了维护期的雷区
1.1 当年为什么大家都爱继承
不夸张地说,过去二十多年里,几乎所有从 Java、C++、C# 入门的人,学到的第一套“面向对象设计”就是三个词:封装、继承、多态。继承被包装成代码复用的终极武器——你写了一个 Animal 基类,猫和狗都继承它,eat()、sleep() 这些公共逻辑只写一份,子类只需要覆盖差异部分。听起来很美,课程作业里也确实很简单。
我在刚入行时接手过一个中等规模的 Java 后台系统,里面最典型的一个继承结构是这样的:AbstractPayment 作为基类,里面放了支付渠道的公参、签名工具方法、回调验签逻辑,然后 AlipayService、WechatPayService、BankCardService 逐一继承。最初几个月开发确实快,新渠道进来只要继承 AbstractPayment,补几个抽象方法就行,代码量很少。这种“省事”让整个团队默认继承是好的,直到线上出现第一个诡异 bug:某个新渠道的验签逻辑覆盖了基类里的 verifySign(),顺手把另一个渠道的信号也影响了。
这个场景几乎每个用过继承的人都有印象——你以为改的是自己子类的行为,实际上改的是整个家族共享的契约。继承的“复用”是把代码和行为一起绑死在一个不断膨胀的基类里,短期省事,长期却把整个系统压在了“基类永远正确”这个假设上。
1.2 脆弱基类问题:改一行代码炸一片下游
《Effective Java》里 Joshua Bloch 直接给出过一句很难听但很真实的建议:“继承没有打破封装,而是让子类依赖于父类的实现细节。” 这句话就是继承最大的隐患:脆弱基类问题(Fragile Base Class Problem)。
具体表现为,你修改基类的一个 protected 字段、调整一个方法的内部顺序,甚至只是给基类新增一个方法,都有可能让所有子类行为发生变化,而且往往是运行时才暴露。因为子类重写了某个钩子方法,基类内部调用的时机不对,父类代码的一行“小改动”在你的子类里引发连锁反应。这种故障在 Java 和 C++ 这种深继承体系里非常经典,尤其是当你维护一个已经被上百个类继承的公共基类时,你根本不知道哪些子类在依赖你“内部碰巧如此”的行为。
这时候,你面对的不是“继承好不好”的哲学问题,而是“谁能安全地改动基类”的工程问题。一个项目只要继承层级超过三层,基类的每一次变更都像给地雷换引信。代码复审时最怕看到的就是“这行改动不影响其他子类”这种自信,经验告诉我们,这句话通常会在三个月后被打脸。
1.3 菱形继承与多态命名空间里的糊涂账
除了脆弱基类,经典继承还有另一个臭名昭著的坑——菱形继承。C++ 里的 A 是基类,B 和 C 都继承 A,然后 D 同时继承 B 和 C,这时候 D 里到底有几份 A 的数据?如果没有虚继承,就会有歧义和重复存储;用虚继承,又引入了构造顺序的复杂规则。Java 8 之前干脆不允许实现多继承,接口默认方法出来之后,菱形问题换个马甲又回来了:两个接口都定义同名 default 方法,实现类必须手动指定用哪一个。
更麻烦的是行为命名空间的冲突。继承体系里,一个方法是“覆盖”还是“重载”本身就容易搞混;当两个基类各有一个语义类似但细节不同的 run() 时,子类想同时保留两者,处理成本远高于收益。这套复杂度最终沉淀为维护者脑子里的“不成文规则”——哪些方法不能调、哪些依赖顺序、哪些继承关系绝对不能动。
用我自己的话说:继承这套抽象,把“结构关系”和“行为契约”强行绑在了一棵树上,树的每一根枝条都能影响其他枝条。你想表达的是“这个类的能力集合”,结果被迫接受了“这个类的全部血统”。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 组合的底层逻辑:把“能做什么”和“是什么”拆开
2.1 组合的本质是 has-a 而非 is-a
面向对象设计的经典原则里有一句劝世良言:“优先使用组合而非继承”。继承表达的是 is-a(猫是一种动物),组合表达的是 has-a(猫拥有一张嘴)。在现代业务系统里,绝大多数需求变更都是“能力组合方式变了”,而不是“物种分类变了”。
就拿支付系统来说,过去我们会认为 WechatPayService 是一种 AbstractPayment,所以继承。但实际业务里,一个订单的支付行为包含一大堆相互独立的能力:签名、验签、加密、风控、日志、对账、渠道适配、第三方回调处理。如果把这些全部塞进一个继承树,每个新渠道要同时确认自己在每个环节的行为差异——签名方式、回调格式、超时策略,几乎不可能通过重写一两个方法来灵活表达。换成组合,每个能力是一个独立的 trait/interface,渠道类只负责把需要的能力按需装配起来。
组合的核心思路是:先按能力拆出细粒度的接口,再用对象把它们拼起来。每个接口只承诺一件清晰的事,类之间的依赖变成“我需要什么”而非“我是什么”。
2.2 行为复用 vs 状态复用:组合为什么更贴业务
继承复用的不仅是行为,还有状态——你继承了 AbstractPayment,同时也就继承了它内部的一堆字段,哪怕你的子类根本用不到。这种状态复用看起来方便,实际上污染了整个系统的数据流。子类之间共享的 mutable 状态越多,系统越不可预测。
组合在这里的天然优势是状态隔离。每个组合成员各自管理自己的状态,外层对象只通过接口与它们交互。这样就把“共享可变状态”降到了最低,而共享可变状态正是并发 bug 和隐性耦合的第一来源。Rust 语言特别强调所有权和借用,本质也是想把“谁拥有数据、谁能修改数据”这件事在编译期说清楚。组合这种小接口、成员独立的风格,跟 Rust 的所有权模型天然契合。
业务层面,组合更贴近一个事实:功能不是靠“继承血统”获得的,而是靠“插件拼装”实现的。今天这个支付渠道要支持 A 风控,明天另一个要支持 B 风控,后天产品说全部渠道统一走
