Files
My-Notes/笔记/CSharp/核心/里氏替换原则.md
2026-06-21 09:51:55 +08:00

6.5 KiB
Raw Permalink Blame History

里氏替换原则

概念:

  • 里氏替换原则(Liskov Substitution PrincipleLSP)说的是: ==所有使用父类的地方,都应该能够无障碍地替换成子类,而程序行为仍然正确==
  • 简单理解: ==子类必须真的能当父类来用,而不是“语法上能继承,逻辑上却不成立”==

通常用法:

  1. 判断一个继承关系到底合不合理
  2. 约束子类重写父类成员时,不要破坏原有规则
  3. 提醒自己:如果继承后总要“特殊处理”,那通常应该改成组合或接口

核心理解:

  1. 子类可以扩展父类功能
  2. 但==不能破坏父类原本承诺的行为==
  3. 如果父类能做的事,子类却突然“不支持”了,那往往就违反了里氏替换原则

经典反例

错误设计:

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==

更合理的改法

改法一:把“鸟”和“会飞”拆开

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 的代码,表面上编译没问题,真正出问题的是业务逻辑

和继承的关系

里氏替换原则不是反对继承,而是告诉你:

  • ==继承必须建立在真正的替换关系上==
  • 如果子类不能稳定替换父类,就不要硬继承
  • 这时通常优先考虑:
    • 组合
    • 接口
    • 更细的抽象拆分

所以可以把它和继承一起记:

  • 继承解决“复用和扩展”
  • 里氏替换原则约束“这个继承是不是合理”

isas 关键词怎么结合理解

先说结论:

  • isas 是==类型判断 / 类型转换工具==
  • 它们能帮你在运行时判断“这个对象现在到底是什么类型”
  • 但它们==不能证明一个继承设计就合理==

is

作用:

  • 判断对象是否兼容某个类型
  • 也常用于模式匹配

例子:

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

例子:

Bird bird = new Sparrow();

IFlyable flyable = bird as IFlyable;
if (flyable != null)
{
	flyable.Fly();
}

补充:

  • as 常用于引用类型
  • 如果转换失败,结果是 null
  • 对普通非可空值类型不能直接用 as

它和里氏替换原则的关系

is / as 本身没有错,但如果你发现代码里总是这样:

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

#核心 #设计原则