资讯动态

里氏替换原则详解:继承的正确使用与代码设计实践

发布时间:2026/10/13 17:11:54 来源:尧图企业网站定制
1. 里氏替换原则到底在说什么第一次看到“里氏替换原则”这六个字很多人脑子里冒出来的第一反应是这名字怎么这么学术是不是又是什么高深莫测的数学定理其实把它翻译成人话核心就一句话——子类必须能够替换掉父类并且替换之后程序的行为不能发生变化。你写代码的时候如果有一个地方接收的是父类对象那么你传任何一个子类对象进去代码都应该正常工作不需要额外加判断不需要改逻辑更不会突然报错或者产生莫名其妙的结果。这个原则是面向对象设计里“SOLID”五大原则中的第二个字母L就是它。很多人学设计模式的时候一上来就急着去看工厂模式、单例模式、观察者模式这些具体套路结果写着写着发现代码越写越乱继承关系越理越复杂改一个子类结果父类那边也跟着出问题。根子往往就出在没有吃透里氏替换原则。它不是一个可以“用”的模式而是一条约束继承使用的铁律。你遵守它继承体系就是清晰可靠的你违背它代码就会变成一颗定时炸弹而且爆炸的时间点往往是在上线之后。我见过不少项目初期为了赶进度随便找个类就继承子类里重写几个方法把父类的行为改得面目全非。当时跑起来没问题因为调用方只用了子类。可一旦某个公共模块需要接收父类类型把子类实例传进去问题就全暴露了。轻则逻辑走错分支重则直接抛异常。所以这篇文章不打算只给你背定义而是从实际编码场景出发把里氏替换原则拆开揉碎讲清楚它为什么重要、怎么判断有没有违反、违反了怎么改以及在实际项目里怎么落地。适合读这篇内容的人包括正在学习面向对象编程的初学者、写过一两年代码但总觉得继承用得不太对劲的开发者以及需要做代码审查或者架构设计的技术负责人。哪怕你平时用的是动态类型语言里氏替换原则一样适用只是表现形式不同而已。2. 为什么继承不是随便用的2.1 继承的本质是“是一种”而不是“像是一种”很多教程讲继承的时候喜欢用“动物”和“狗”的例子说狗是一种动物所以狗继承自动物。这个例子本身没问题但它太简单了简单到让人误以为只要两个类看起来有关系就可以用继承。实际项目里真正的判断标准要严格得多。继承表达的是一种行为契约。父类定义了“我能做什么”子类继承之后不仅要能做这些事而且做的方式必须符合父类做出的承诺。举个例子父类有一个方法叫“计算面积”它承诺返回一个非负数。那么子类重写这个方法之后也必须返回非负数。如果某个子类返回了负数那就违背了父类的承诺调用方拿到负数之后可能直接崩溃。这就是违反里氏替换原则的典型情况。我经常跟团队里的人说判断能不能用继承不要问“子类是不是父类的一种”而要问“子类能不能完全替代父类并且调用方察觉不到区别”。如果答案是否定的那就不要用继承改用组合或者接口。2.2 违反里氏替换原则的代价违反这个原则的代价往往不是立刻显现的而是随着代码规模增长逐渐暴露。初期可能只是某个分支判断多写了一个if后来变成每个调用点都要加类型检查再后来发现改一个子类会影响到其他子类最后整个继承树变成一团乱麻谁都不敢动。具体来说代价体现在三个方面。第一是调用方复杂度上升。本来只需要面向父类编程现在因为子类行为不一致调用方不得不判断具体是哪个子类然后走不同逻辑。第二是可测试性下降。父类的单元测试没法直接套用到子类上因为子类行为已经变了你得为每个子类单独写一套测试。第三是扩展成本增加。新增一个子类的时候你得把所有调用点都检查一遍看看新子类的行为会不会导致某个地方出问题。这些代价加起来就是技术债务。而里氏替换原则就是帮你避免这笔债务的第一道防线。2.3 一个反直觉的结论正方形不是长方形面向对象设计里有一个非常经典的例子就是“正方形继承长方形”。从数学角度看正方形确实是长方形的一种四条边、四个直角完全符合。但放到代码里这个继承关系会直接违反里氏替换原则。假设父类“长方形”有两个方法设置宽和设置高。它的行为契约是设置宽之后宽变了高不变设置高之后高变了宽不变。现在子类“正方形”继承它为了保持正方形四边相等设置宽的时候必须同时把高也设成一样的值设置高的时候同理。这时候如果调用方拿到一个“长方形”类型的引用先设置宽为5再设置高为10按照父类的契约期望得到宽5高10的长方形。但实际传进去的是正方形对象最终得到的是宽10高10的正方形。调用方的预期被彻底打破了。这个例子说明“是一种”的直觉判断在代码世界里经常是错的。真正决定能不能继承的是行为契约是否一致。正方形和长方形在数学上共享属性但在代码行为上不兼容所以不能用继承。3. 里氏替换原则的四个判断标准3.1 子类不能强化前置条件前置条件是方法执行之前必须满足的条件。父类的方法如果接受任意整数作为参数子类重写之后就不能只接受正整数。因为调用方按照父类的契约传入了负数子类却直接拒绝执行或者抛出异常这就违反了里氏替换原则。在实际编码中这种违反经常出现在参数校验上。比如父类的某个方法对参数没有做非空检查子类重写时加了一个“参数不能为空”的校验。表面上看是子类更严谨了但实际上调用方可能在某些场景下就是会传空值而且父类能处理。子类这么一改调用方就崩了。注意子类可以弱化前置条件也就是比父类更宽松但不能强化。父类要求参数大于0子类可以允许参数大于等于0这样调用方按照父类契约传参时子类一定能处理。3.2 子类不能弱化后置条件后置条件是方法执行之后必须保证的结果。父类方法承诺返回一个非空列表子类重写之后就不能返回null。父类方法承诺执行完之后某个状态会被更新子类就不能跳过这个更新。这个标准在业务代码里特别常见。比如父类有一个“保存订单”的方法承诺保存成功之后返回订单编号。子类重写之后如果保存失败就返回null而父类的调用方从来没有处理过null的情况直接拿返回值去拼接字符串或者做数据库查询结果就是空指针异常。3.3 子类不能破坏父类的不变量不变量是类在对象生命周期内始终保持为真的条件。比如一个“银行账户”类它的不变量是“余额不能为负”。子类继承之后无论怎么重写方法都不能让余额变成负数。如果子类新增了一个“透支”方法允许余额为负那就破坏了父类的不变量违反了里氏替换原则。不变量往往不会写在代码注释里而是隐含在类的设计和实现中。做代码审查的时候需要特别关注子类是否引入了父类没有的状态变化。3.4 子类不能改变父类方法的语义这一条最抽象但也最致命。父类的方法叫“计算折扣”语义是根据原价计算打折后的价格。子类重写之后如果改成“计算税费”虽然方法签名一样但语义完全变了调用方按照父类的理解去调用得到的结果就完全不对。语义的改变往往是最隐蔽的因为编译器不会报错运行时也不会抛异常只是结果错了。这种错误在测试覆盖不足的情况下很容易漏到生产环境。4. 实际编码中怎么落地4.1 用契约式设计来约束继承契约式设计是一种编程方法论核心思想是每个方法都有前置条件、后置条件和不变量。在支持契约式设计的语言里这些条件可以直接写在代码里由运行时或者静态分析工具来检查。在不支持的语言里可以通过文档注释和单元测试来模拟。我自己的做法是在父类的每个公开方法上用注释写清楚前置条件、后置条件和可能抛出的异常。子类重写的时候必须逐条对照确保没有强化前置条件、没有弱化后置条件、没有破坏不变量。这个方法看起来笨但实际用下来非常有效尤其是在团队协作中能避免很多因为理解不一致导致的bug。4.2 优先使用组合而不是继承很多违反里氏替换原则的场景根源在于滥用继承。如果两个类之间只是“像是一种”或者“有一部分行为相同”那就应该用组合。组合的灵活性更高也不会引入行为契约的约束。举个例子一个“日志服务”类和一个“缓存服务”类都需要“发送网络请求”的能力。如果让它们都继承一个“网络客户端”类那继承树会变得很怪而且以后网络客户端的任何变化都会影响到它们。更好的做法是把网络请求封装成一个独立的组件日志服务和缓存服务各自持有这个组件的实例需要的时候调用一下。这样既复用了代码又没有继承带来的耦合。4.3 用接口来定义行为契约接口是定义行为契约的最佳工具。接口只声明方法签名不提供实现所有实现类都必须遵守接口定义的契约。如果一个类实现了某个接口那么在任何使用该接口的地方都可以安全地替换成这个类的实例只要实现是正确的。在Java、C#这些语言里接口和抽象类都可以用来定义契约但接口更纯粹因为它不涉及状态和实现细节。我通常建议如果只是为了定义一组行为优先用接口如果需要共享一些默认实现再用抽象类。4.4 单元测试是最后的防线不管设计的时候考虑得多周全实际编码中还是可能不小心违反里氏替换原则。单元测试是发现这类问题的有效手段。具体做法是针对父类写一套测试用例然后让所有子类都跑这套测试。如果某个子类跑不过说明它违反了里氏替换原则。这套测试用例应该覆盖父类的所有公开方法包括正常情况、边界情况和异常情况。子类继承父类的测试类或者通过参数化测试的方式把子类实例传进去跑同一套逻辑。这样一旦子类行为不一致测试立刻就会失败。5. 常见违反场景与修复方案5.1 子类抛出父类没有声明的异常这是最常见的违反场景之一。父类的方法没有声明任何受检异常子类重写之后抛出了一个受检异常。调用方按照父类的契约没有做异常处理结果程序直接崩溃。修复方案有两种。一种是子类内部消化这个异常转换成父类允许的返回值或者非受检异常。另一种是修改父类的方法签名声明可能抛出的异常但这会影响到所有调用方成本较高。更好的做法是在设计父类的时候就考虑到子类可能遇到的异常情况提前在签名里声明。5.2 子类改变了方法的可见性父类的方法是public子类重写的时候改成了protected。这看起来只是缩小了可见范围但实际上违反了里氏替换原则。因为调用方通过父类引用调用这个方法时编译器认为它是public可以调用。但实际运行时子类把这个方法藏起来了导致调用失败。修复方案很简单子类重写方法时可见性不能比父类更严格。父类是public子类也必须是public父类是protected子类可以是protected或者public。5.3 子类返回了父类不认识的类型在支持协变返回类型的语言里子类重写方法时可以返回父类方法返回类型的子类型。这是允许的而且符合里氏替换原则。但如果子类返回了一个和父类返回类型完全无关的类型那就违反了。比如父类方法返回“List”子类重写之后返回“Set”。虽然都是集合但Set不允许重复元素调用方如果往返回的集合里添加重复元素就会失败。这种违反在动态类型语言里更容易出现因为没有编译期检查。5.4 子类修改了父类的内部状态父类有一个方法执行之后会把某个内部状态标记为“已完成”。子类重写之后跳过了这个状态更新。调用方通过父类引用调用这个方法然后检查状态发现还是“未完成”逻辑就走错了。修复方案是子类重写方法时要么调用父类的实现来保证状态更新要么自己手动更新状态。如果子类确实不需要这个状态那说明父类的设计有问题应该把状态管理抽离出来而不是让子类去破坏它。5.5 子类让父类的方法变得没有意义父类有一个“计算面积”的方法子类重写之后直接返回0。虽然方法签名没变也没有抛异常但这个方法实际上已经失去了意义。调用方拿到0之后可能会做除法运算导致除零异常。这种违反最难发现因为代码能跑只是结果不对。修复方案是重新审视继承关系如果子类确实不需要这个方法那就不应该继承父类而应该用组合或者接口。6. 排查与自查清单6.1 代码审查时的检查要点做代码审查的时候看到继承关系可以按照下面这个清单逐条检查检查项具体内容违反后果前置条件子类是否强化了参数校验调用方传参被拒绝后置条件子类是否弱化了返回值保证调用方拿到意外结果不变量子类是否破坏了类的恒定状态对象状态不一致异常子类是否抛出了父类未声明的异常调用方未处理导致崩溃可见性子类是否缩小了方法可见范围调用方无法访问语义子类是否改变了方法的业务含义逻辑错误难以发现这个表格可以直接用在代码审查清单里每次看到继承关系就过一遍。6.2 重构时的注意事项如果发现现有代码违反了里氏替换原则重构的时候要注意几点。第一不要一次性改太多先从一个子类开始验证方案可行之后再推广。第二重构之前先补测试确保现有行为被测试覆盖这样改完之后能快速验证有没有引入新问题。第三如果继承关系已经深入整个系统考虑用适配器模式或者策略模式来过渡而不是直接砍掉继承。6.3 我踩过的一个坑早期做项目的时候我设计了一个“支付方式”抽象类里面有一个“计算手续费”的方法。后来新增了一个“内部转账”的子类为了图省事直接继承了这个抽象类然后把“计算手续费”重写成返回0。当时觉得没问题内部转账确实没有手续费。但后来调用方拿到手续费为0之后做了一些除法运算来计算费率结果直接除零异常。这个坑让我明白子类重写方法时不仅要考虑返回值本身还要考虑调用方会怎么使用这个返回值。如果父类的契约隐含了“返回值大于0”这个假设子类就不能返回0。后来我把“计算手续费”拆成了两个方法一个返回手续费金额一个返回是否有手续费。这样内部转账子类就可以明确表示“没有手续费”调用方也能正确处理。7. 和其他设计原则的配合7.1 与开闭原则的关系开闭原则说的是对扩展开放、对修改关闭。里氏替换原则是实现开闭原则的基础。如果子类不能替换父类那么新增子类的时候就必须修改调用方代码这就违背了开闭原则。反过来如果严格遵守里氏替换原则新增子类时调用方完全不需要改动开闭原则自然就满足了。7.2 与依赖倒置原则的关系依赖倒置原则要求高层模块不依赖低层模块两者都依赖抽象。里氏替换原则保证了抽象和实现之间的替换是安全的。如果实现类不能替换抽象类那么依赖倒置就失去了意义因为高层模块依赖的抽象实际上不可靠。7.3 与单一职责原则的关系单一职责原则要求一个类只有一个引起变化的原因。如果一个类承担了太多职责它的子类就很容易在某些职责上违反里氏替换原则。比如一个类既负责数据读取又负责数据展示子类可能只想重写展示逻辑但读取逻辑的契约也被继承了容易出问题。把职责拆开之后继承关系会清晰很多。8. 不同语言里的实践差异8.1 静态类型语言在Java、C#这类静态类型语言里编译器会帮你检查一部分违反情况比如方法签名不匹配、可见性缩小等。但语义层面的违反编译器无能为力。所以静态类型语言里契约式设计和单元测试尤为重要。8.2 动态类型语言在Python、Ruby这类动态类型语言里没有编译期检查里氏替换原则完全靠开发者自觉。好处是灵活坏处是容易出问题。我的建议是在动态类型语言里用抽象基类或者接口来定义契约并且在文档里写清楚每个方法的前置条件、后置条件和不变量。单元测试也要覆盖父类的契约确保所有子类都遵守。8.3 函数式语言在函数式语言里继承用得比较少里氏替换原则的体现形式也不同。函数式语言更强调组合和类型类行为契约通过类型签名来保证。比如一个函数接受某个类型类的实例那么任何实现了该类型类的类型都可以传进去只要实现符合类型类的 laws。这其实就是里氏替换原则的另一种表达。9. 一个完整的重构案例假设有一个“文件处理器”抽象类定义了一个“处理文件”的方法前置条件是文件路径不能为空后置条件是返回处理后的文件内容字符串不变量是处理过程中不会修改原文件。现在有一个子类“加密文件处理器”重写“处理文件”方法时要求文件必须是加密格式否则抛出异常。这就强化了前置条件违反了里氏替换原则。重构方案是把“文件格式校验”从“处理文件”方法里抽出来放到一个独立的“格式校验器”组件里。“文件处理器”只负责处理不负责校验格式。调用方在使用之前先调用校验器检查格式然后再调用处理器。这样“加密文件处理器”就不需要强化前置条件了它只处理加密格式的文件如果传入了非加密格式行为由校验器负责。这个重构的关键在于把变化的部分和不变的部分分离。格式校验是变化的不同处理器要求不同处理逻辑是稳定的所有处理器都遵循同样的契约。分离之后继承关系就干净了。10. 最后的经验分享里氏替换原则看起来是一条约束实际上是一种保护。它保护的是调用方的预期保护的是系统的稳定性保护的是你未来改代码时不用提心吊胆。我刚开始写代码的时候觉得这些原则都是束缚后来踩的坑多了才明白束缚是为了让你在更大的范围内自由。如果你现在正在设计一个继承体系不妨先问自己一个问题如果我把所有子类都替换成父类程序还能正常工作吗如果答案是否定的那就说明设计有问题需要重新考虑。这个问题看起来简单但能帮你避开很多坑。另外不要为了用设计模式而用设计模式。里氏替换原则不是让你把所有继承都改成组合而是让你在用继承的时候多想一想。该用继承的时候用继承该用组合的时候用组合判断标准就是行为契约是否一致。这个判断能力需要在实际项目中慢慢积累写多了自然就有感觉了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑