
继承体系里那件小事为什么你的“正方形”会把圆的逻辑搞崩这是我在项目中踩了不少坑之后最想跟C#开发者分享的东西——里氏替换原则。很多人在面试时能背出LSP的定义但写代码时照样用new关键字把基类方法藏得严严实实或者让子类抛出一个“不支持”的异常。这篇文章咱们把这个概念掰开揉碎结合C#的语言特性、设计模式和实际工程场景把里氏替换从理论变成能直接指导你写代码的家伙。不管你是刚学会继承的新手还是已经写了几年业务代码的老鸟这篇都能让你对“继承”这两个字有新的体感。1. 里氏替换到底在说什么1.1 一句话定义与核心直觉里氏替换原则Liskov Substitution PrincipleLSP是SOLID原则里的“L”由Barbara Liskov在1987年提出。她的原话很长但落到工程上就是一句话任何用到基类对象的地方都可以换成子类对象而程序行为不会产生错误。这句话听起来像是废话但实际做起来非常难。难就难在“行为不会产生错误”这六个字——它不是编译期的问题而是运行期、逻辑层面的问题。C#里编译器只保证类型安全不保证语义正确。你把一个子类实例赋给基类变量编译器会说“没问题”但运行起来可能立刻抛异常、算错结果或者悄悄给你的数据埋下一颗雷。我举个生活化的类比你把一张“能自动剪辑视频的相机”当作普通相机用拍照、调参数、按快门这些功能没问题可如果有人把“会爆炸的相机”也当作普通相机塞进包里那问题就大了。里氏替换要求的是后者这种“替换后不出事”的保证。1.2 继承体系正在背叛你很多人学面向对象时老师告诉你“继承就是is-a的关系”——猫是动物正方形是矩形所以都该用继承。这句话只对了一半。is-a描述的是“是什么”的关系LSP要求的是“能怎么用”的关系。你可以在现实中大方地说“正方形是一种矩形”但放到代码里正方形和矩形的继承关系可能就是个炸药包。看个经典的例子public class Rectangle { public virtual int Width { get; set; } public virtual int Height { get; set; } public int CalculateArea() { return Width * Height; } } public class Square : Rectangle { public override int Width { set { base.Width value; base.Height value; } } public override int Height { set { base.Width value; base.Height value; } } }这写法看着挺灵巧正方形的长宽永远相等设置宽的时候把高也改了完美维持了正方形的不变量。但问题来了——如果你有一段代码针对矩形做了“设置宽为5设置高为10然后断言面积等于50”把它换成Square这段代码就会计算失败因为Square的设置顺序会让高和宽互相覆盖最终宽度和高度都变成了10面积是100。这就是里氏替换的典型崩塌子类在“自己看来”完全正确却破坏了基类使用者的合理期望。Rectangle的使用者完全有权利认为“Width和Height是两个独立的属性”Square却把这个权利剥夺了。你不需要让Square去报错或崩溃它只是安安静静地给了个错误结果就已经违反了LSP。1.3 为什么C#程序员必须格外注意C#是一门强调类型安全的语言又有完整的面向对象特性这让LSP在C#里既容易实现也容易踩坑。容易实现是因为C#有abstract、virtual、interface、sealed这些关键字给了你足够的表达工具容易踩坑是因为C#里有一个专门为了“隐藏基类成员”而存在的new修饰符还有override和new的混用问题。另外C#和.NET生态里有大量框架依赖LSP依赖注入容器替你解析接口和实现类ORM框架替你把实体类映射到数据库序列化框架替你把对象变成JSON。这些框架都默认你的类型替换是安全的。一旦LSP被破坏报错的地方往往离真正的源头非常远排查起来相当痛。2. 子类的四大纪律里氏替换的行为约束很多讲LSP的资料喜欢说“子类不能修改基类的某些行为”但具体哪些行为能改、哪些不能改往往语焉不详。我结合多年经验把LSP拆成四条可以实操的“纪律”你写子类的时候逐条套基本能避开90%的坑。2.1 子类不得加强前置条件前置条件就是“调用方法之前必须满足的条件”。基类说“这个方法接收任意整数”子类说“这个方法只接收正整数”这就是加强了前置条件。从运行结果来看原来能正常传的-1到子类这里就炸了。实际代码里最常见的表现是子类在方法入口处增加参数校验抛出一堆基类没提过的异常。public class Calculator { public virtual int Add(int a, int b) a b; } public class PositiveOnlyCalculator : Calculator { public override int Add(int a, int b) { if (a 0 || b 0) throw new ArgumentException(参数必须为非负数); return a b; } }这段代码的问题在于调用方可能基于基类的契约在循环里不停传负数来计算温度差值、坐标偏移等业务数据。替换成PositiveOnlyCalculator之后代码突然在运行期爆炸。哪怕不抛异常仅仅是提前return 0也会让调用方拿到一个和自己预期不符的值这种“安静的错误”更加隐蔽。实操建议子类的方法签名要和基类完全一致而且方法的实际行为要对所有合法输入保持一致。如果你确实需要一个“非负整数计算器”正确的做法不是在子类里拦而是把约束提升到类型层面比如用uint或者在构造时用参数明确表示“本计算器只接受非负数”。2.2 子类不得削弱后置条件后置条件是“方法执行完之后必须成立的条件”。基类说“调用Save()之后数据一定写入了数据库”子类说“我可能写入失败但我假装成功返回了”。这就是削弱后置条件调用方拿着一个“成功”的返回值去继续做业务最后发现数据丢了。再来看一个例子public interface IUserRepository { User GetUserById(int id); } public class CachedUserRepository : IUserRepository { public User GetUserById(int id) { var user _cache.Get(id); if (user null) return null; // 接口实现都这样写大家不都这样么 return user; } }如果接口的契约是“按ID查询找不到时抛出异常或返回一个明确的空对象”那么直接return null就是一种削弱。调用方如果从没把null当作合法返回值来防御那么在拿到null之后去访问user.Name就是一次NullReferenceException。很多C#开发者觉得自己写不出LSP错误实际上null泛滥用就是最常见的LSP破坏。2.3 子类必须维持基类的不变量不变量是“不管怎么操作对象始终要保持的性质”。矩形的“宽高独立”、银行账户的“余额不能为负”、订单的“状态只能从待支付到已支付”这些都是不变量。子类继承基类的时候天然继承了这些不变量你可以在子类里扩展但不能把基类的不变量破坏掉。刚才的正方形案例就是破坏不变量的典范基类矩形的“宽和高度量独立”被破坏了。你也许觉得“正方形宽高相等是很自然的事”但在LSP的尺度上你以为在强化约束实际上是在违背基类使用者的期望。遵循里氏替换原则的正方形设计应当把正方形做成矩形的“兄弟”而不是“儿子”public abstract class Quadrilateral { public abstract int CalculateArea(); } public class Rectangle : Quadrilateral { public int Width { get; set; } public int Height { get; set; } public override int CalculateArea() Width * Height; } public class Square : Quadrilateral { public int SideLength { get; set; } public override int CalculateArea() SideLength * SideLength; }这样一改两个类可以平级都派生于抽象的四边形基类。处理“几何形状面积”的代码可以面向Quadrilateral操作但没人能再写“把Square当Rectangle用”的代码了——因为根本继承不了。如果业务确实需要“正方形也是矩形”的关系那就要么把Shape设计为不可变对象不可变对象天然更容易满足LSP要么放弃宽度、高度这种独立属性模型。2.4 不得抛出基类未预期的异常子类方法可以抛异常吗可以C#里不禁止。但你需要问自己这个异常类型调用方基于基类的签名能预料到吗基类声明了IOException可能抛出那子类抛IOException没问题基类没提过异常子类抛InvalidOperationException调用方没有对应的catch程序就崩了。更隐蔽的是“受检异常”思维在C#里的缺失。Java里有受检异常编译器会强制你处理C#没有这个强制力所以我们更要自律。一个合理的做法是尽量抛那些从调用方视角来看“可以恢复、可以理解”的异常或者在方法文档里写清楚可能抛出的异常类型。如果你在子类里抛了一个基类不可能抛出的自定义异常FatalDatabaseException而所有基类调用点的异常处理都只catch了Exception类型的上层几类那么你可能会捕获不到或者把可恢复的异常强行升级成了终结性异常这也是LSP问题。3. C#语言层面的里氏替换实战说完了理论这章我们来点硬核的。C#里有一堆语言机制与LSP直接相关用得好如虎添翼用不好就是地雷阵。3.1 关键字与访问修饰符override、new、sealed如何选择这是新手最容易困惑的地方都是重写基类的方法override和new到底有什么不同override是真正的“多态重写”。调用方持有基类引用实际调用时会走虚方法表执行子类的方法逻辑。new则是“声明隐藏”——它是在子类里另起炉灶定义了一个和基类方法同名的新方法但这个方法只会在你使用子类类型引用时被调用如果你把子类赋给基类引用调用的还是基类的原方法。public class BaseEntity { public virtual string GetDescription() { return Base Entity; } } public class OrderEntity : BaseEntity { public new string GetDescription() { return Order #123; } } // 使用场景 public void Print(BaseEntity entity) { Console.WriteLine(entity.GetDescription()); // 输出: Base Entity } var order new OrderEntity(); Print(order);调用方期待“实体类型”的GetDescription表现出一致的行为结果却在编译期就出现了分叉——BaseEntity引用走的是基类方法。这种写法很容易导致“我看代码觉得没问题测试发现就是不对”的状况。我的经验是除非你有100%的把握并且有极强的理由否则不要用new去隐藏基类成员。如果你真的想让子类有不同行为请把基类成员设成virtual然后override。sealed则是个被低估的LSP助手。很多时候我们写了一个类不希望任何人继承它去改行为直接把它sealed掉比写一堆代码防御别人“乱重写”要优雅得多。在.NET基类库中string、decimal都是sealed的这样你不可能搞出一个“行为异常的string”来破坏别人的代码。3.2 接口与抽象类LSP的最佳宿主里氏替换原则最天然的实现方式是接口。接口定义了一个“行为契约”任何实现接口的类都必须满足这个契约。当你面向接口编程时替换实现类通常不会出事因为接口本身就是抽象不存在“基类有某些合理默认行为”的问题。public interface IMessageSender { void Send(Message message); } public class EmailSender : IMessageSender { public void Send(Message message) { // 发送邮件 } } public class SmsSender : IMessageSender { public void Send(Message message) { // 发送短信 } }这是典型的“依赖倒置里氏替换”组合拳。上层代码只依赖IMessageSender具体是邮件还是短信由注入的实例决定。任何新加了IMessageSender的实现只要能正确发送消息替换进去就是安全的。抽象类比接口多了一些“默认实现”的味道。如果基类的一个抽象方法已经通过virtual提供了默认行为子类可以选择覆盖也可以选择不覆盖。这个选择本身不违反LSP只要覆盖时仍然满足四大纪律。抽象类的风险在于容易出现“为了复用代码而生搬硬套继承”——两个类仅仅因为代码相近就被做成父子关系最后导致父类的方法对子类其实没有业务意义。这在LSP看来就是一种“牵强的is-a”。3.3 泛型的协变与逆变类型安全版的里氏替换C#的泛型支持协变out和逆变in这是很多教程一带而过的知识点但它们其实是LSP在类型系统层面的扩展。比如IEnumerableout T可以把IEnumerableOrder当作IEnumerableBaseEntity来用因为out保证了“T只能用作输出”而输出的Order完全是可以当作BaseEntity使用的——这正是LSP的语义。反过来Actionin T可以把ActionBaseEntity当作ActionOrder来用因为这时的参数方向是相反的。这给我们一个启发在设计泛型接口时主动用out/in标注类型参数的方向等于在设计层面就把LSP给类型化了。编译器会在你写出不安全的替换时直接报错这比“运行期出bug再排查”高到不知道哪里去了。3.4 异常机制对LSP的隐形约束很多.NET框架代码喜欢使用ApplicationException作为自定义异常的基类然后所有自定义异常都继承它。但这其实是个比较粗放的方案。如果基类方法没有明确声明抛什么异常子类抛出具体业务异常时你能保证调用方一定处理得了吗我的建议是在设计接口或抽象类时就把“允许抛出的异常”写进XML文档注释里。子类实现时要么抛出文档里列出的异常及其派生异常要么抛出一个调方能够用通用策略处理的上层异常比如InvalidOperationException。在.NET 6中还有ICustomExceptionHandler这类接口帮你统一包装异常边界用得好能把LSP异常的破坏面收窄很多。4. 里氏替换在真实项目中的三种崩塌现场前面聊了语言机制这里我想分享几个真实项目中碰到的问题。这些场景都不是教科书里那种“正方形vs矩形”而是真实的业务代码里会出现的LSP坍塌。4.1 仓储模式里的“假替换”我参与过一个电商项目数据层用EF Core仓储接口定义为IRepositoryT提供Add、FindById、SaveChanges等方法。后来为了性能引入了一个内存缓存仓库实现碰到写入操作会先写缓存、再异步刷新数据库。结果上线后订单服务经常出现“订单保存了但列表查不到”的诡异问题。排查下来发现缓存仓库重写了SaveChanges()把原本的同步保证改成了“后台异步刷库”。问题就出在这里原接口的契约是“调用SaveChanges()之后修改立即可见”缓存仓库为了性能削弱了这个后置条件。这完全就是LSP的典型崩塌——一个看似“更好的”实现因为破坏了基类约定导致了整个系统的数据一致性风险。实操建议如果你需要缓存能力不要直接在仓储实现里破坏同步语义。合理的做法是把缓存做在仓储的外面也就是“装饰器模式”里先调真实的仓储保存再把缓存更新或者明确把缓存仓库当作一种“弱一致性实现”来设计并对调用方严格隔离避免用到强一致性场景里。4.2 事件和回调中的类型替换C#的事件模型是另一个容易踩LSP坑的地方。比如你定义了OrderPlacedEventArgs它继承自EventArgs。有一个全局事件订阅方法参数是object sender, EventArgs e内部判断e是OrderPlacedEventArgs就执行逻辑。后来有人调整了基类事件参数的结构导致某些子类事件参数不再继承OrderPlacedEventArgs而是直接继承EventArgs。结果所有原本能正确识别消息的订阅方突然对这个事件毫无反应而且编译期完全发现不了。这种情况的根因是你依赖了“事件参数是某个派生类”这一事实而后来对类型体系的调整破坏了这个事实但没同步调整所有订阅者的逻辑。里氏替换原则不只需要子类遵守契约也需要所有“用基类进行判断”的代码想清楚自己依赖的是能力还是类型。如果真的需要能力应该提取接口来判断而不是依赖运行时类型。4.3 默认参数与重载决议的陷阱C#的方法重载和默认参数机制也与LSP纠缠不清。看下面这段public class MessageProcessor { public virtual string Process(string message, bool isUrgent false) { return $[Normal] {message}; } } public class UrgentMessageProcessor : MessageProcessor { public override string Process(string message, bool isUrgent false) { return $[Urgent] {message}; } }表面上没问题。但假如调用方使用的是MessageProcessor processor new UrgentMessageProcessor(); processor.Process(hello);编译器在编译基类引用调用时会根据基类方法的默认参数来填充参数值这里都是false看上去没有坑。可如果基类后来把默认值改成了true而子类没同步改那么运行时virtual派发机制会让子类方法接收到的值和你“预想”的基类默认值不一致吗其实在C#里默认参数是由调用点静态决定的跟子类无关。所以这里还算安全。真正容易出事的是“可选参数重载”public class BaseHandler { public virtual void Handle(string command) { ... } } public class DerivedHandler : BaseHandler { public override void Handle(string command) { ... } public void Handle(string command, int retryCount 3) { ... } }有个开发者在业务代码里用derivedHandler.Handle(cmd)本意是调用基类里那个虚方法。但由于子类里新增了带默认参数的重载编译器会把调用解析到Handle(string, int)上面去了。如果这两个方法行为不一致就是一次隐蔽的行为错配。这种问题更容易出现在“同一个逻辑不同签名”的场景里而且IDE和编译器都不会给任何警告。好在C#现在推荐在重载上使用[OverloadResolutionPriority]这类修饰符来明确优先级但这只是补救真正避免问题的方法是子类不要轻易添和基类方法签名“近似”的重载。5. 保证里氏替换的工程化手段聊了这么多案例和理论核心问题变成在日常开发中怎么用工程手段确保LSP不被破坏这就涉及设计模式、单元测试、依赖注入和代码审查四个方面。5.1 抽象出真正稳定的基类LSP的根基是“基类的行为是稳定、可预测的”。如果你的基类经常改签名、改默认行为、改异常策略那子类就永远在追着基类打补丁替换安全根本无从谈起。所以设计基类或接口时需要做到这么几件事接口的方法语义要写清楚参数是什么范围、返回值是什么含义、允许抛什么异常、有没有副作用。基类尽可能提供不可变的默认实现virtual方法要么有明确的默认行为要么直接做成abstract强制子类实现不要留一个“实现了一半、吊在半空”的默认方法。把稳定的部分和不稳定的部分分开稳定的走继承、多态不稳定的走组合、委托不要把未知变化塞进基类里。.NET这套生态系统里典型的例子是Stream类。它提供了Read、Write、Seek、Flush等方法每个派生类都严格遵守这些语义FileStream、MemoryStream、CryptoStream之间替换时不会产生语义错乱。这正是LSP在基类库层面的示范。5.2 契约测试让机器守卫LSP你靠人工审查很难保证每个子类都严格遵守基类契约尤其项目大了之后。业界的一种做法是“契约测试”Contract Test——针对基类或接口写一套标准测试然后用这套测试去验证每一个实现类。在C#里最直接的方法是用一个抽象测试基类public abstract class MessageSenderContractTests { protected abstract IMessageSender CreateSender(); [Fact] public void Send_WithValidMessage_ShouldMarkMessageAsSent() { var sender CreateSender(); var message new Message(hello); sender.Send(message); Assert.True(message.IsSent); } [Fact] public void Send_WithNullMessage_ShouldThrowArgumentNullException() { var sender CreateSender(); Assert.ThrowsArgumentNullException(() sender.Send(null)); } } public class EmailSenderTests : MessageSenderContractTests { protected override IMessageSender CreateSender() new EmailSender(); } public class SmsSenderTests : MessageSenderContractTests { protected override IMessageSender CreateSender() new SmsSender(); }这样设计的好处是任何新增的实现类只要继承了MessageSenderContractTests并传入自己的实例这套契约测试就会验证这个实现是否遵守接口约定。一旦子类违背了前置、后置或不变量CI直接红灯。这套思路在大型项目中价值非常高因为人工review会随代码量增长而失效。5.3 依赖注入容器与多态的分工DI依赖注入容器天然支持面向接口编程但也会掩盖一些LSP问题。比如你在IServiceCollection里注册了IMessageSender的实现运行时拿到的是EmailSender还是SmsSender对上层透明。这恰恰是LSP的好处。不过要注意一点依赖注入只解决“替换”在组装期的连接问题不解决替换后的行为正确性问题。容器不会帮你去验证子类的行为是否符合契约它有可能会把一个错误的实现注入进来导致运行起来才出问题。工程上的建议是所有面向接口的服务实现类必须搭配契约测试。设计时尽量缩短抽象链少用“套娃”式继承。多层继承会放大LSP风险任何一个中间层破坏了基类约束下面所有子类都会跟着遭殃。如果在构造函数里通过依赖注入拿到一个接口实例不要在代码里再去判断它是不是某个具体类型一旦你这样做了就说明你依赖的“能力”并没有被接口充分建模这个接口设计是有问题的。5.4 Code Review中的LSP检查清单我在代码评审时会特别注意这些点子类是否在方法入口增加了基类没有的参数校验子类是否对特定输入返回了与基类不同的默认值子类是否悄悄把同步改成了异步把强一致改成了最终一致子类是否有大量“空实现”或“抛NotImplementedException”的方法如果有那么接口可能被分解成粒度更细的接口否则大部分实现者都在被迫去实现一个自己用不上的方法。基类的virtual方法是否有明确的文档说明“子类可以做什么、不可以做什么”按照这个清单走一遍基本上能防住九成的LSP破坏。剩余的一成靠契约测试和自动化测试去兜底。6. 那些年被误解的里氏替换最后讲几个常见的认识误区每个都能单独拿出来当“面试题”或者“Code Review被怼现场”。6.1 “子类替代基类就是多态”不完全对。多态描述的是运行时能根据实际类型调用不同方法是一种机制LSP描述的是一种“替换安全性”是使用规则。你完全可以写一个“能多态替换但逻辑错误”的代码。反过来一个满足多态运行的代码也不一定满足LSP。多态是载体LSP是契约。6.2 “继承层面满足is-a就满足LSP”这是最普遍的误区。前面正方形与矩形的例子已经证明在词汇语义上正方形确实是矩形的一个子类但在行为契约上它不是。面向对象里的继承应该以“行为可替换”为准绳而不是以“词汇分类”为准绳。6.3 “小心点就能保证LSP”你不可能靠小心。只要系统里继承层级超过三层、实现类超过五个靠人脑去追踪每个基类方法有哪些子类在重写、是否全部遵守契约几乎是不可能的。所以我后面才强调契约测试的重要性。LSP的守护不是靠“决心”是靠“机制”。有个很有意思的现象很多开发者在写代码时默认“正确”是理所当然的但LSP提醒我们“正确”需要被定义、被测试、被维护。你的接口文档里写没写“这个方法不允许传null”你的子类实现里守没守这条只要有一个环节偷懒替换就可能是定时炸弹。6.4 “接口隔离和LSP是一回事”接口隔离原则ISP要求“类不应该被迫依赖它用不到的接口方法”LSP要求“替换不能改变程序正确性”。两者确实有关联——如果接口粗粒度太大子类往往需要通过“空实现”来凑数这种空实现往往是LSP破坏的温床。但二者解决的问题层面不同ISP关心的是接口设计的一次性LSP关心的是继承/实现体系的长期稳定性。举个例子如果一个接口定义了Fly()方法而大多数鸟的类都不会飞那么让所有鸟都继承这个接口就会迫使鸵鸟去实现Fly(){ throw ... }。这在合同上已经埋下了LSP隐患。正确的做法是把接口按能力拆分IBird只表达“有名字”IFlyable表达“会飞”只有真正会飞的才实现IFlyable。这不是LSP本身但它消除了大量LSP被破坏的机会。7. 一个完整的重构案例从LSP崩塌到安全替换为了让你更直观地看到“怎么把不满足LSP的代码重构为满足LSP的代码”我准备了一个贴近实际的小案例。假设你要做一个订单通知系统目前有两种通知方式邮件和短信。最初代码长这样public class Notification { public string Recipient { get; set; } public string Content { get; set; } public virtual void Send() { // 默认实现记录日志 Console.WriteLine($Log notification to {Recipient}); } } public class EmailNotification : Notification { public override void Send() { // 直接连SMTP发送 SmtpClient.Send(Recipient, Content); } } public class SmsNotification : Notification { public override void Send() { // 直接用短信网关发送 SmsGateway.Send(Recipient, Content); } }这段代码看上去没什么大问题但仔细想Notification的默认实现是打日志子类却直接发真实通知。如果有人创建了一个Notification并调用Send()他可能以为只是在测试日志功能结果发了一封真实邮件出去。这种“默认实现和子类行为严重不一致”的情况就是LSP的隐患。更好的设计是public interface INotificationSender { void Send(NotificationContext context); } public class EmailSender : INotificationSender { public void Send(NotificationContext context) { ... } } public class SmsSender : INotificationSender { public void Send(NotificationContext context) { ... } } public class LogOnlySender : INotificationSender { public void Send(NotificationContext context) { ... } }在这个设计里INotificationSender的契约就是“把通知内容派发到某个渠道”每个实现类自行决定渠道。NotificationContext则是一个不可变的数据传输对象封装收件人和内容。使用者可以根据环境选择任意实现而不用管具体渠道测试时甚至可以用LogOnlySender来模拟不产生任何真实外发。这样的模型才真正满足了“替换前后行为符合预期”的要求。重构的要点是把“做的事情是什么”提升为抽象把“具体怎么做”留在实现里。抽象层不猜测任何具体渠道行为实现层不辜负基类契约。我的几点体感最后说点我在实际项目中的体会。虽然LSP是面向对象设计的基础原则但真正对它有敬畏感是在几次线上故障之后。那些故障的共同点都是有人“好心”加了一个更高效的实现或者为业务加了一层新逻辑然后破坏了原有调用方对基类的合理预期。问题都不在编译期全在运行期而且往往要过很久才暴露。所以我后来养成了一个习惯写继承或实现接口时先停下来问自己三个问题。这个类的所有方法基类契约写了什么我的子类有没有改变这些方法的输入范围、返回值含义、异常行为如果有人持有一个基类引用并调用这些方法他的代码逻辑在我的子类替换后会得到同样的结果吗如果三个问题里有一个回答不上来我就会停下来重读接口代码重写契约文档或者干脆拆接口。这个过程确实会多花时间但它给我省下的排查故障的时间要多得多。尤其在做库、做框架、做公司内部公共类库的人LSP更应该写进你的代码规范和checklist里。你的代码是给别人调用的别人拿着你的基类写了无数业务逻辑你在新版本里调整基类行为时就是在对所有调用方做一次无声的LSP测试——要么大家都好要么大家在升级后集体爆炸。我做过的比较理想的方式是基类每动一次就把所有实现类的契约测试全部跑一遍红灯多了就知道破坏面有多大。这篇文章没有把所有知识压缩成一个“记住这几句话就完了”的总结。你真正要做的是去自己写一遍正方形和矩形的反例自己写一遍契约测试然后在一个小项目里试着把某个继承体系拆成接口加组合。有了那种“替换时不必担心出问题”的体验你才算真正把里氏替换原则内化成自己的技术直觉。