1. 一个让我决定写这篇文章的代码评审场景上个月做代码评审我打开一个数据导出模块看到一屏令人头疼的继承结构一个叫BaseExporter的基类底下挂了十几个子类有JsonExporter、CsvExporter、PdfExporter还有几个叫不上名字但显然只是想要其中一个方法的类。最扎眼的是一处代码子类覆写了父类的方法但方法体里什么都没有就一行throw new NotImplementedException()。问业务方为什么这么写答案是“这个子类不需要那个功能但不继承就没法拿到其他字段”。这种场景我见过太多次了。继承与组合可能是面向对象设计里最容易被程序员搞反的一对关系。大家提起“代码复用”就想到继承觉得子类 extends 父类就等于把父类的方法全拿过来多省事。但省事的代价往往要后面加倍偿还父类构造函数稍微改一下十几个子类一起编译失败想给某个子类单独加行为结果所有子类都跟着变为了用一个方法被迫绑定了父类的一整堆字段和依赖。后来我把这套代码里的大部分继承关系拆成了组合。所谓组合就是让一个类持有另一个类的实例通过调用它的方法来完成功能。JsonExporter不再继承BaseExporter而是变成“一个导出器里组合了一个 JSON 格式化器、一个通知器、一个日志器”。改动之后新增一种导出格式时不再需要动任何既有代码只要再写一个实现接口的小类就能接进去。这个项目经历让我特别想认真聊聊继承和组合到底应该怎么选为什么成熟团队会把“组合优先”当成默认原则。这篇文章不是要否定继承。继承本身没有原罪问题是大多数情况下我们把它用错了。我想从语义、运行机制、代码评审信号、重构案例几个层面把这两者的关系拆开揉碎讲清楚。如果你正在为一个类要不要继承另一个类而犹豫这篇文章正好能帮你把决策依据理顺。2. 继承的真正语义它承诺的是契约不是帮你少写代码2.1 一切要从 is-a 和里氏替换谈起很多人理解继承只停留在“子类可以复用父类的方法和字段”这一层。这句话没错但它漏掉了继承在面向对象类型系统里真正核心的承诺子类是父类的一个子类型。只要有函数接收父类对象你传入子类对象它的行为必须能被父类的契约所接受。这就是里氏替换原则Liskov Substitution Principle它才是判断能不能用继承的试金石。我举个例子。假设你有一个Bird类里面定义了Fly()方法。你给它加一个子类Ostrich鸵鸟鸵鸟不会飞于是你在重写Fly()时直接让方法抛异常。表面上看代码能编译也符合“鸵鸟是一种鸟”的日常直觉。但如果你有一个方法public void LetBirdFly(Bird bird) { bird.Fly(); }当传入一只鸵鸟时程序就崩了。这说明Ostrich并不是Bird的合格子类型它破坏了父类的行为契约。这种情况下正确的设计不是让Ostrich继承Bird而是把“会飞”这件事抽成一个能力。代码里看到子类重写方法为空、抛异常、或者用大量if (this is Ostrich)来判断类型时基本可以判定继承关系已经变形了。2.2 脆弱基类问题你改父类凭什么儿子全倒我见过一个很典型的项目框架团队维护一个核心基类业务团队在这个基类下挂了几百个自定义控件。本来大家约定好基类里有几个protected字段留给子类用后来基类版本升级一个字段类型从ListT改成了IReadOnlyListT编译期立刻全公司哀嚎。更难受的是那种编译能过、运行期出错的基类内部悄悄调整了某个私有方法的实现逻辑子类的外部行为跟着变因为子类在不知不觉中依赖了父类的实现细节。这就是继承著名的脆弱基类问题。父类和子类之间的耦合比很多代码规范里写的“高内聚低耦合”要隐蔽得多。子类表面上只覆写了几个virtual方法但为了用到父类的字段、构造函数、模板方法里的流程实际依赖了父类的整个实现。一旦父类发生任何变化影响范围会沿着继承树扩散。你没法把一个子类单独拎出来测试因为它的行为跟父类的初始化流程、字段状态、私有方法全都绑在一起。继承到底“脆弱”在哪它把复用代码和建立类型关系这两件事强行捆绑了。你原本只是想复用一个SaveToFile()方法但继承语法迫使你必须声明“这个类就是父类的一种”于是父类的全部能力、缺陷、依赖统统跟着传了下来。2.3 多继承的混乱与“菱形问题”告诉我们什么继承不仅在纵向上脆弱在横向上也容易出问题。C 支持多继承于是有了著名的菱形继承A是基类B和C都继承A然后D同时继承B和C。这时候D里到底有几份A的实例如果B和C都重写了A的同一个方法D调用它时走的是哪条路径为了解决这个问题C 搞出了虚继承Java 和 C# 则干脆取消了类的多继承只留下接口多实现。Python 则靠 C3 线性化算法计算一个方法解析顺序但这个顺序写复杂了同样容易让人懵。为什么菱形问题这么难缠因为它暴露出一个本质矛盾如果继承要求子类和父类之间存在严格的“is-a”关系一个类怎么可以同时“是”两个东西而且这两个东西在更上层还是同一个东西真实世界里的角色是可以叠加的一个人既是程序员又是讲师但在类型系统里搞这种多重身份总要付出额外的复杂度。组合就没有这个烦恼一个人可以组合一个程序员身份对象再组合一个讲师身份对象两个身份之间没有任何继承式的路径纠缠。我讲这些并不是劝大家别用继承而是想说清楚一个认知继承这套机制本身远比表面看起来复杂它承载的语义重量很重。你每次写下一个:或extends不只是在“复用”而是在向整个类型系统承诺一种稳定的替换关系。如果只是想从别的类身上拿点现成方法继承的吨位太大了。3. 组合为什么被当成默认答案has-a 的灵活性与最小暴露3.1 从运行时的角度理解组合组合的语法朴素得有点不起眼一个类里声明另一个类的字段构造函数或属性赋值然后调用它的方法。没有extends关键字没有virtual和override就是平铺直叙的“我有一个助手对象”。但正是这种朴素给了组合两个继承给不了的优点。第一个优点是运行时可变。继承关系在编译期就锁死了JsonExporter永远是BaseExporter的子类这一点从程序开始运行到结束都不会变。组合不一样一个对象里的组件字段可以在运行时被替换。比如一个OrderProcessor类里组合了一个IPaymentGateway字段测试时注入假网关生产环境换成真网关部署后还可以通过配置随时切换支付渠道。这种面向接口的替换能力是现代依赖注入容器、插件体系、策略模式的基础。第二个优点是最小暴露。继承会把父类的所有public和protected成员都暴露给子类很多时候子类只想用其中一个字段却被强制接受了父类的一整套内部约定。组合允许你像用一台机器一样使用另一个对象只看它对外提供的按钮不关心它内部的几百个零件。你甚至可以用一个私有字段的类型来约束它让它只出现在类内部不泄漏到外边。3.2 接口加组合依赖抽象而不是依赖具体类组合真正发光是在和接口搭配的时候。接口负责定义能力边界组合负责把具体的实现装进对象里。比如你要做一个报表服务需要把数据格式化成不同样式。如果走继承路线你可能会写JsonReport : JsonFormatter以后要支持 XML又得写XmlReport : XmlFormatter格式和业务类死死绑在一起。组合路线长这样public interface IFormatter { string Format(ReportData data); } public class JsonFormatter : IFormatter { ... } public class XmlFormatter : IFormatter { ... } public class Report { private readonly IFormatter _formatter; public Report(IFormatter formatter) { _formatter formatter; } public string Render(ReportData data) { return _formatter.Format(data); } }Report并不“是”一种格式它只是“持有”一个格式器。以后想给Report增加 Markdown 格式根本不用碰Report类新增一个MarkdownFormatter实现IFormatter然后在组装的地方传进去就行。类之间的依赖从“我最懂父类的内部实现”变成了“我只认这个接口契约”耦合度明显降下来。这里有个特别重要的点接口本身也是一种继承接口继承但它不携带实现所以没有脆弱基类问题。我们常说的“面向接口编程”本质是建立在组合 接口这套组合拳之上的。3.3 组合的代价与误用组合当然不是银弹。它最直接的代价是代码量变多。一个类如果组合了三个组件就得显式写清楚三个依赖的构造和调用逻辑而继承可能一个: BaseClass就全搞定了。很多开发者正是在这一步打了退堂鼓觉得组合太啰嗦继承看起来多清爽。但请想清楚那个“清爽”是把复杂度转移给了继承树代价是后面每一次维护都要在整棵树上摸爬滚打。组合的“啰嗦”是显式的复杂度一眼能看明白继承的“清爽”是隐式的复杂度不出问题则已一出问题就是连锁反应。组合还有一种常见误用为了复用某个类的个别方法强行把整个类塞进来结果引入了一堆不需要的依赖。比如一个StringUtils类里有 20 个静态工具方法你只想要一个IsEmail但把它塞进CustomerService对象后测试时还得处理StringUtils构造函数的依赖——虽然实际上你根本用不到那些依赖。这种误用说明一个道理组合也需要你隔离好边界组件不是越大越好职责单一才是硬道理。权衡下来我的经验是组合的“缺点”是编程时的体感继承的“缺点”是维护时的灾难。编程时的多敲几行键盘跟维护时追查继承链的烧脑相比实在太划算了。4. 判断分水岭什么样的情况下你该换成组合4.1 先问三个问题is-a、复用、契约面对一个候选的继承关系我一般会先问自己三个问题任何一个回答卡壳就说明该走组合了。第一个问题是它两个类之间真的是“is-a”吗这不是背“猫是动物”那种教科书例句而是要放到业务上下文里检验。比如Manager继承Employee听起来没问题“经理是员工”。但如果一个员工升职当经理之后不再有固定工时、不再拿时薪而Employee类里到处是HoursWorked、HourlyRate这类字段这个继承关系就开始扭曲了。此时让Manager组合一个EmployeeProfile作为自己的属性反而更贴合现实。第二个问题是你到底是为了复用实现还是为了表达类型关系很多时候你只是想复用BaseExporter里的SendNotification()方法。如果是这样请把“复用”这个念头单独处理要么把方法提取成独立的Notifier类组合进来要么做成静态工具方法要么做成扩展方法。一句话别为了让代码少写几行就拿整个继承树做赌注。第三个问题是把子类替代父类去使用会不会有人意外发现行为不对劲如果一个类继承了父类但别人传它的时候没法按父类的行为来预期说明它根本不配做父类的子类型。经典的“正方形不是长方形”问题就是例子你把Square设为Rectangle的子类改Width时Height也变了调用方以为是独立的长宽结果被悄悄联动这就是契约崩坏的信号。4.2 代码评审里的危险信号清单代码评审是判断继承滥用的第一现场。我给自己列过一个“危险信号清单”看到以下任何一种情况我都会建议同事重新考虑设计继承层次深度超过三层。三层以上的继承树阅读代码的你基本上已经无法在脑子里同时维护所有层级的行为了。子类重写父类方法时方法体里写着throw new NotImplementedException()或直接返回null。这种所谓“重写”其实是在否定父类契约。父类有超过十个实例字段而某个子类实际只用到其中一两个。这代表子类为了挑一件衣服买下了整个衣柜。子类里的方法大量调用base.SomeMethod()并且每一步都要小心翼翼地确认父类的内部状态。这说明父类的流程根本不是为这个子类设计的。为了测试某个子类被迫 new 出父类的各种依赖比如数据库连接、消息队列客户端。继承把不必要的外部依赖传染给了子类测试。修改父类代码会导致大量子类编译失败或测试失败。这不是“正常影响”而是耦合过高的警报。4.3 决策对照表为了方便记忆我把判断时常用到的维度整理成一个对照表维度适合继承适合组合目标表达“子类是父类的一种”需要多态替换复用某个对象的能力或动态改变行为关系is-a稳定且不会被推翻has-a对象之间是拥有/协作关系可变性关系在编译期固定组件可以运行时替换、注入耦合度子类与父类实现强耦合只依赖接口或组件公开能力暴露范围会继承父类全部可访问成员只暴露你组合进来的对象方法测试难度需要处理父类依赖和状态可以单独 mock 组件扩展方式新增子类扩展容易影响基类新增组件实现通常不影响已有类这张表不是软件真理更像一个经验坐标。绝大多数场景下你只要对着一照答案已经很清楚了。5. 一次真实重构从“万能基类”到组合管线5.1 原代码的继承设计回到我开头说的数据导出模块。当时的老代码大概长这样public class BaseExporter { protected readonly ILogger _logger; protected readonly Liststring _errors new(); protected BaseExporter(ILogger logger) { _logger logger; } public void Export(Data data) { _logger.Log(开始导出); var result DoExport(data); File.WriteAllBytes(GetOutputPath(), result); SendNotification(导出完成); } protected virtual byte[] DoExport(Data data) { return data.RawBytes; } protected void SendNotification(string message) { // 发送邮件通知 } } public class JsonExporter : BaseExporter { public JsonExporter(ILogger logger) : base(logger) { } protected override byte[] DoExport(Data data) { return Encoding.UTF8.GetBytes(JsonSerializer.Serialize(data)); } }看起来一切都挺合理基类负责导出流程子类负责具体格式。但真实业务跑起来之后问题全冒出来了第一不是所有导出都需要发送通知。CsvExporter是给内部统计脚本用的属于批处理任务每次导出完还要发邮件把运维烦得不行。但因为它继承了BaseExporterSendNotification就在那儿只能靠一个bool配置字段去关掉关掉又导致逻辑分支增多。第二新增导出格式的成本很高。我要加一个ExcelExporter但 Excel 的生成需要引入一个第三方库这个库又要求额外的初始化参数。于是我只能往BaseExporter的构造函数里再塞一个参数。一个基类的构造函数被改成五六个参数时所有现有子类的调用方全都得跟着改。第三单元测试极其痛苦。每个子类测试都要先 new 一个ILogger实现还要确保File.WriteAllBytes不会真的写到磁盘上。那些只想测DoExport格式转换的子类被迫跑一遍完整的基类导出流程测试跑得又慢又容易受外部环境影响。5.2 重构过程拆分职责注入依赖重构的思路没有任何高深技巧就是四个字各管各的。我先定义了三个边界清晰的组件导出器、通知器、日志器然后写一个轻量级的编排者把它们串起来。public interface IExporter { byte[] Export(Data data); } public class JsonExporter : IExporter { public byte[] Export(Data data) { return Encoding.UTF8.GetBytes(JsonSerializer.Serialize(data)); } } public interface INotifier { void Notify(string message); } public class EmailNotifier : INotifier { /* 发送邮件 */ } public class SilentNotifier : INotifier { /* 什么都不做 */ } public class ExporterPipeline { private readonly IExporter _exporter; private readonly ILogger _logger; private readonly INotifier _notifier; public ExporterPipeline(IExporter exporter, ILogger logger, INotifier notifier) { _exporter exporter; _logger logger; _notifier notifier; } public void Run(Data data) { _logger.Log(开始导出); var result _exporter.Export(data); File.WriteAllBytes(GetOutputPath(), result); _notifier.Notify(导出完成); } }改动之后需要发通知的导出场景就在组装时传入EmailNotifier不需要的传入SilentNotifier再也不用在导出器内部做配置判断了。新增 Excel 导出时只需要实现IExporterExcel 相关依赖全都在ExcelExporter内部解决完全不会影响到其他导出器。ExporterPipeline只负责流程编排它不关心具体导出格式是什么。这里还有个值得注意的细节我把File.WriteAllBytes也留在了Pipeline里而不是塞进IExporter。为什么因为“导出文件”是流程的一部分而“如何把业务数据转成字节”是格式器的职责。让IExporter只返回byte[]测试时就完全不需要接触文件系统我可以传入一份内存数据直接断言返回的字节内容。一个接口的方法职责越小使用它的人越自由这是组合设计天然带来的好处。5.3 重构后的效果与测试变化重构之后我写过一次对比总结效果非常直观新增一种导出格式从“修改基类构造函数 修改所有子类调用方”降为“新增一个实现IExporter的类”开闭原则的体验拉满。通知渠道从邮件扩展到短信、企业微信只需要实现新的INotifier导出器完全无感知。单元测试JsonExporter的测试变成了纯函数式验证给它一个Data断言输出的 JSON 字符串不会再意外触发日志、文件写入、通知发送。阅读成本任何人看到ExporterPipeline的构造函数就知道导出一份数据需要哪三样东西逻辑清晰得不需再看父类内部实现。这次重构给我最大的感悟是继承解决不了的组合也未必全能解决但组合至少把每个类需要承担的职责摆在了明面上。明面上的依赖再多都好过暗藏在一棵巨大继承树里的隐式耦合。6. 继承并没有死模板方法、装饰器与接口继承的边界6.1 模板方法模式继承的最后一块舒适区把组合当默认选项不代表继承要被扔进垃圾桶。继承最适合的场景是模板方法模式父类定义算法的整体骨架把其中某些步骤留成abstract或virtual子类只负责填充可变的步骤。这种时候子类和父类确实是严格的 is-a 关系而且继承在这里的优势很明确——它把不可变的流程和可变的策略集中在一个结构里。public abstract class DataParser { public Data Parse(byte[] input) { var rawBytes ReadBytes(input); var sanitized Sanitize(rawBytes); return BuildData(sanitized); } protected abstract byte[] ReadBytes(byte[] input); protected abstract Data BuildData(byte[] sanitized); }两个子类JsonParser和XmlParser都继承DataParser各自实现读写细节但解析流程永远保持一致。这种模式下父类的流程确实适用于所有子类没有哪个子类需要“重写为空”或“抛异常”。如果你发现自己要在一个模板方法继承里重写大半个算法那说明模板已经名存实亡该考虑把公共流程抽成独立类再用组合让不同的处理器协作。6.2 装饰器模式用组合做继承做不到的事如果说模板方法是继承的舒适区那装饰器模式就是组合最有代表性的主场。装饰器的核心思想是用一个包装对象包裹原始对象在不修改原始类的情况下给它增加能力。用继承也可以给类加能力但继承只能在编译期一次性选定装饰器可以在运行期一层一层地叠加。public class GzipDecorator : IExporter { private readonly IExporter _inner; public GzipDecorator(IExporter inner) { _inner inner; } public byte[] Export(Data data) { var original _inner.Export(data); return GzipCompress(original); } }我可以自由组合new GzipDecorator(new JsonExporter())或者new EncryptionDecorator(new GzipDecorator(new JsonExporter()))。如果当年我用继承来实现压缩、加密、日志可能写出来的就不是三个类而是CompressedJsonExporter、EncryptedJsonExporter、CompressedEncryptedJsonExporter这种排列组合爆炸的类爆炸现场。继承的组合数量是指数级的组合的排列数量是线性的。这就是为什么在 Java 的 I/O 流设计里BufferedInputStream、GZIPInputStream都用装饰器叠加而不是为每种组合单独写一个类。6.3 接口继承与实现继承别把两种“继承”混为一谈还有一个特别常见的认知混淆把接口继承和类继承混为一谈。public class JsonExporter : IExporter里的冒号和public class JsonExporter : BaseExporter里的冒号在 C# 里写起来很像但语义完全不同。前者继承的是能力契约后者继承的是实现细节。接口继承是安全的因为它不携带字段、构造函数、方法实现自然不会产生脆弱基类问题类继承是危险的因为它把整个父类实现拖下了水。我在评审时经常看到一个坏习惯工程师为了满足某个接口不得不写一个类去继承某个已经存在的实现类因为那个实现类碰巧有一模一样的方法。比如ITaxCalculator接口有一个Calculate(Order order)方法而某个PromotionTaxCalculator类恰好有这个方法于是他们让PromotionTaxCalculator继承VipTaxCalculator来“实现”接口。这其实是用类继承冒充接口继承硬生生制造了一个子类。正确做法是让PromotionTaxCalculator直接实现ITaxCalculator或者内部组合VipTaxCalculator而不是继承它去蹭那个已经有的方法。理解了这两类继承的区别很多“该用继承还是组合”的争论就消解了大半。我们反对的从来不是“接口继承”而是那种为了省代码被迫接受的无意义类继承。接口加组合的组合拳本质上是在告诉你类型关系用接口继承来表达行为复用用组合来完成各走各的道不要混在一起。我个人做了这么多年代码评审现在的习惯非常稳定默认组合除非我能明确说出“这个子类确实是一个更具体的父类并且父类只负责骨架子类只填实现”。看到继承层次超过三层、子类重写方法为空、父类构造函数频繁膨胀这几种信号时我第一反应就是建议重构成组合。每次重构完代码量往往不仅没变多反而因为拆掉了大量无效父类依赖而更精简。如果你下次也在犹豫是否要加一个extends不妨先试着把继承关系改写成“包含一个”假如这个类里组合一个父类类型的字段代码是不是反而更清楚了想通了这一点你大概率已经找到了更优的设计。最后分享一个判断小技巧写代码时不要问自己“我怎么让这个类复用那个类的代码”而要问“如果这两个类的关系不是父子我该怎么设计”。这个视角切换会把你从继承的惯性思维里拉出来。继承与组合从来不是非此即敌关键是把它们放到各自合适的位置上。