C#中里氏替换原则:继承体系里的行为契约与工程实践
2026/9/16 3:47:38 网站建设 项目流程

继承体系里那件小事:为什么你的“正方形”会把圆的逻辑搞崩?这是我在项目中踩了不少坑之后,最想跟C#开发者分享的东西——里氏替换原则。很多人在面试时能背出LSP的定义,但写代码时照样用new关键字把基类方法藏得严严实实,或者让子类抛出一个“不支持”的异常。这篇文章,咱们把这个概念掰开揉碎,结合C#的语言特性、设计模式和实际工程场景,把里氏替换从理论变成能直接指导你写代码的家伙。不管你是刚学会继承的新手,还是已经写了几年业务代码的老鸟,这篇都能让你对“继承”这两个字有新的体感。

1. 里氏替换到底在说什么

1.1 一句话定义与核心直觉

里氏替换原则(Liskov Substitution Principle,LSP)是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#有abstractvirtualinterfacesealed这些关键字给了你足够的表达工具;容易踩坑是因为C#里有一个专门为了“隐藏基类成员”而存在的new修饰符,还有overridenew的混用问题。

另外,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如何选择

这是新手最容易困惑的地方:都是重写基类的方法,overridenew到底有什么不同?

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基类库中,stringdecimal都是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在类型系统层面的扩展。

比如IEnumerable<out T>可以把IEnumerable<Order>当作IEnumerable<BaseEntity>来用,因为out保证了“T只能用作输出”,而输出的Order完全是可以当作BaseEntity使用的——这正是LSP的语义。反过来,Action<in T>可以把Action<BaseEntity>当作Action<Order>来用,因为这时的参数方向是相反的。

这给我们一个启发:在设计泛型接口时,主动用out/in标注类型参数的方向,等于在设计层面就把LSP给类型化了。编译器会在你写出不安全的替换时直接报错,这比“运行期出bug再排查”高到不知道哪里去了。

3.4 异常机制对LSP的隐形约束

很多.NET框架代码喜欢使用ApplicationException作为自定义异常的基类,然后所有自定义异常都继承它。但这其实是个比较粗放的方案。如果基类方法没有明确声明抛什么异常,子类抛出具体业务异常时,你能保证调用方一定处理得了吗?

我的建议是:在设计接口或抽象类时,就把“允许抛出的异常”写进XML文档注释里。子类实现时,要么抛出文档里列出的异常及其派生异常,要么抛出一个调方能够用通用策略处理的上层异常(比如InvalidOperationException)。在.NET 6+中还有ICustomExceptionHandler这类接口帮你统一包装异常边界,用得好能把LSP异常的破坏面收窄很多。

4. 里氏替换在真实项目中的三种崩塌现场

前面聊了语言机制,这里我想分享几个真实项目中碰到的问题。这些场景都不是教科书里那种“正方形vs矩形”,而是真实的业务代码里会出现的LSP坍塌。

4.1 仓储模式里的“假替换”

我参与过一个电商项目,数据层用EF Core,仓储接口定义为IRepository<T>,提供AddFindByIdSaveChanges等方法。后来为了性能,引入了一个内存缓存仓库实现,碰到写入操作会先写缓存、再异步刷新数据库。结果上线后,订单服务经常出现“订单保存了,但列表查不到”的诡异问题。

排查下来发现,缓存仓库重写了SaveChanges(),把原本的同步保证改成了“后台异步刷库”。问题就出在这里:原接口的契约是“调用SaveChanges()之后,修改立即可见”,缓存仓库为了性能削弱了这个后置条件。这完全就是LSP的典型崩塌——一个看似“更好的”实现,因为破坏了基类约定,导致了整个系统的数据一致性风险。

实操建议:如果你需要缓存能力,不要直接在仓储实现里破坏同步语义。合理的做法是把缓存做在仓储的外面,也就是“装饰器模式”里:先调真实的仓储保存,再把缓存更新;或者明确把缓存仓库当作一种“弱一致性实现”来设计,并对调用方严格隔离,避免用到强一致性场景里。

4.2 事件和回调中的类型替换

C#的事件模型是另一个容易踩LSP坑的地方。比如你定义了OrderPlacedEventArgs,它继承自EventArgs。有一个全局事件订阅方法,参数是object sender, EventArgs e,内部判断eOrderPlacedEventArgs就执行逻辑。后来有人调整了基类事件参数的结构,导致某些子类事件参数不再继承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类。它提供了ReadWriteSeekFlush等方法,每个派生类都严格遵守这些语义,FileStreamMemoryStreamCryptoStream之间替换时不会产生语义错乱。这正是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.Throws<ArgumentNullException>(() => 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测试——要么大家都好,要么大家在升级后集体爆炸。我做过的比较理想的方式是:基类每动一次,就把所有实现类的契约测试全部跑一遍,红灯多了就知道破坏面有多大。

这篇文章没有把所有知识压缩成一个“记住这几句话就完了”的总结。你真正要做的是去自己写一遍正方形和矩形的反例,自己写一遍契约测试,然后在一个小项目里试着把某个继承体系拆成接口加组合。有了那种“替换时不必担心出问题”的体验,你才算真正把里氏替换原则内化成自己的技术直觉。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询