# 里氏替换原则 概念: - 里氏替换原则(Liskov Substitution Principle,LSP)说的是: ==所有使用父类的地方,都应该能够无障碍地替换成子类,而程序行为仍然正确== - 简单理解: ==子类必须真的能当父类来用,而不是“语法上能继承,逻辑上却不成立”== 通常用法: 1. 判断一个继承关系到底合不合理 2. 约束子类重写父类成员时,不要破坏原有规则 3. 提醒自己:如果继承后总要“特殊处理”,那通常应该改成组合或接口 核心理解: 1. 子类可以扩展父类功能 2. 但==不能破坏父类原本承诺的行为== 3. 如果父类能做的事,子类却突然“不支持”了,那往往就违反了里氏替换原则 ---- # 经典反例 错误设计: ```csharp class Bird { public virtual void Fly() { Console.WriteLine("鸟会飞"); } } class Sparrow : Bird { public override void Fly() { Console.WriteLine("麻雀飞走了"); } } class Penguin : Bird { public override void Fly() { throw new NotSupportedException("企鹅不会飞"); } } void LetBirdFly(Bird bird) { bird.Fly(); } LetBirdFly(new Sparrow()); // 正常 LetBirdFly(new Penguin()); // 出错 ``` 问题: - `LetBirdFly(Bird bird)` 这个函数相信:只要传进来的是 `Bird`,就能 `Fly()` - 但 `Penguin` 继承了 `Bird` 后,却把“会飞”这个规则破坏了 - 这就说明:==`Penguin` 其实不应该直接继承这种“默认会飞”的 Bird== ---- # 更合理的改法 改法一:把“鸟”和“会飞”拆开 ```csharp abstract class Bird { public string Name { get; set; } } interface IFlyable { void Fly(); } class Sparrow : Bird, IFlyable { public void Fly() { Console.WriteLine("麻雀飞走了"); } } class Penguin : Bird { public void Swim() { Console.WriteLine("企鹅在游泳"); } } void LetBirdFly(IFlyable bird) { bird.Fly(); } LetBirdFly(new Sparrow()); // 正常 // LetBirdFly(new Penguin()); // 编译期就不允许,更合理 ``` 这样做的好处: 1. `Bird` 只表达“它是一只鸟” 2. `IFlyable` 只表达“它会飞” 3. 不会飞的鸟就不用为了继承而硬实现一个错误行为 ---- # 怎么判断有没有违反 可以拿下面几个问题自检: 1. 子类是不是父类的一种? - 如果读起来都别扭,那继承关系大概率有问题 2. 把子类对象传到父类参数的位置,原有代码还能正常工作吗? - 如果经常要加 `if (对象是某某子类)`,设计就开始危险了 3. 子类有没有把父类原本支持的行为变成“不支持”? - 例如:父类能 `Fly()`,子类却直接抛异常 4. 子类有没有偷偷加强限制? - 父类允许任意正数,子类却只允许 1 到 10 5. 子类有没有把父类承诺的结果变弱? - 父类保证成功,子类却经常返回空结果或失败 ---- # 违反里氏替换原则的常见信号 1. 子类重写后直接抛 `NotSupportedException` 2. 子类必须靠很多 `if/else` 特判才能安全使用 3. 继承只是为了复用代码,但语义上并不是“is-a”关系 4. 子类把父类的规则改掉了,而不是扩展 补充: - “能继承”不等于“该继承” - 很多违反 LSP 的代码,表面上编译没问题,真正出问题的是业务逻辑 ---- # 和继承的关系 里氏替换原则不是反对继承,而是告诉你: - ==继承必须建立在真正的替换关系上== - 如果子类不能稳定替换父类,就不要硬继承 - 这时通常优先考虑: - 组合 - 接口 - 更细的抽象拆分 所以可以把它和[[继承]]一起记: - 继承解决“复用和扩展” - 里氏替换原则约束“这个继承是不是合理” ---- # `is` 和 `as` 关键词怎么结合理解 先说结论: - `is` 和 `as` 是==类型判断 / 类型转换工具== - 它们能帮你在运行时判断“这个对象现在到底是什么类型” - 但它们==不能证明一个继承设计就合理== ## `is` 作用: - 判断对象是否兼容某个类型 - 也常用于模式匹配 例子: ```csharp Bird bird = new Sparrow(); if (bird is Sparrow) { Console.WriteLine("这是麻雀"); } if (bird is IFlyable flyable) { flyable.Fly(); } ``` 理解: - `bird is Sparrow` 的意思是:当前这个对象是不是 `Sparrow` - `bird is IFlyable flyable` 不仅判断类型,还顺手把结果保存到变量 `flyable` ## `as` 作用: - 尝试把对象安全转换成某个引用类型或可空值类型 - 转换失败不会抛异常,而是返回 `null` 例子: ```csharp Bird bird = new Sparrow(); IFlyable flyable = bird as IFlyable; if (flyable != null) { flyable.Fly(); } ``` 补充: - `as` 常用于引用类型 - 如果转换失败,结果是 `null` - 对普通非可空值类型不能直接用 `as` ## 它和里氏替换原则的关系 `is` / `as` 本身没有错,但如果你发现代码里总是这样: ```csharp void Handle(Bird bird) { if (bird is Sparrow) { // 一套逻辑 } else if (bird is Penguin) { // 另一套逻辑 } } ``` 那通常说明一件事: - 你嘴上写的是“我接收 `Bird`” - 但你心里真正依赖的却是“具体子类是谁” 这往往意味着: 1. 抽象不够稳定 2. 多态没有真正用起来 3. 继承关系可能不够合理 4. 有概率正在逼着代码违反里氏替换原则 更好的方向一般是: - 让父类 / 接口直接提供调用方真正需要的能力 - 或者把能力拆到更合适的接口里 一句话记: - `is` / `as` 是“识别类型”的工具 - 里氏替换原则是“约束继承是否合理”的原则 - ==如果父类代码里到处都要靠 `is` / `as` 分辨子类,通常该反过来检查抽象设计== ---- # 一句话记忆 ==子类可以扩展父类,但不能打破父类原来的承诺== 再压缩一点: ==父类能出现的地方,子类也必须能正常出现== ---- # 面试/复习时怎么说 可以这样答: “里氏替换原则的意思是,子类对象必须能够替换父类对象,并且不影响程序原本的正确性。 如果一个子类继承了父类,却把父类原本支持的行为改成异常、限制更多条件,或者导致调用方需要额外特殊判断,那这个继承关系通常就不合理。 这时候应该考虑重新抽象,或者改成接口和组合。” ---- # 注意 1. 里氏替换原则是==设计原则==,不是语法规则 2. 它常和[[继承]]、[[多态vob|多态]]、[[接口]]一起理解 3. `is` / `as` 可以辅助做类型判断,但不能代替抽象设计 4. 只要你发现“某个子类总是不太像父类”,就该警惕是不是违反了 LSP #核心 #设计原则