核心本质
子类必须可以替换父类,并且程序行为不出错。
通俗理解
你编写的子类,不能“破坏”父类原本定义好的逻辑契约。
经典反例(需要警惕)
public class Bird
{
public virtual void Fly()
{
Console.WriteLine("飞");
}
}
public class Ostrich : Bird
{
public override void Fly()
{
throw new Exception("鸵鸟不会飞"); // ❌ 违反了行为契约
}
}
问题出在哪里?
假设有这样一个调用方:
void MakeBirdFly(Bird bird)
{
bird.Fly();
}
当我们传入一个 Ostrich 对象时:
MakeBirdFly(new Ostrich());
程序会直接抛出异常,导致功能崩溃。
为什么这违反了LSP?
子类 (Ostrich) 破坏了父类 (Bird) 所承诺的 Fly() 方法的行为契约。父类保证调用 Fly() 会执行“飞行”动作,而子类却让其抛出异常。
关键洞察
LSP的本质约束的不是“语法上的继承”,而是 “行为上的契约 (Behavior Contract)”。
什么是“行为契约”?
父类 Bird 对其使用者做出了一个明确的承诺:调用 Fly() 方法,对象就会执行“飞行”这一行为。
而子类 Ostrich 的 Fly() 方法却抛出了异常,这相当于单方面撕毁了契约,导致所有信赖此契约的代码(如 MakeBirdFly 函数)都可能面临意外失败。
正确的设计思路(组合优于继承)
解决上述问题的关键,是重新审视对象的关系。鸵鸟是一种鸟,但从“能力”角度来看,并非所有的鸟都拥有“飞行”这项能力。因此,更好的设计是将“能力”抽象为接口。
public interface IFlyable
{
void Fly();
}
public class Sparrow : IFlyable // 麻雀会飞,实现该接口
{
public void Fly()
{
Console.WriteLine("飞");
}
}
public class Ostrich
{
// 鸵鸟不会飞,因此不实现 IFlyable 接口
}
这样一来,MakeBirdFly 函数的参数就应该改为 IFlyable,只有真正能飞的对象才能传入,从类型系统层面就杜绝了错误的发生。这种方式遵循了良好的 设计原则。
设计思路的转变
从“基于分类的继承”(是一种鸟)转变为“基于能力的组合”(拥有飞行能力)。这更符合现实世界的逻辑,也让代码更健壮。
第二个常见误区(更为隐蔽)
前置条件、后置条件或不变式的改变
考虑经典的“正方形不是长方形”的例子:
public class Rectangle
{
public virtual int Width { get; set; }
public virtual int Height { get; set; }
public int Area() => Width * Height;
}
public class Square : Rectangle
{
public override int Width
{
set
{
base.Width = value;
base.Height = value; // ❌ 强制将高设为与宽相等
}
}
public override int Height
{
set
{
base.Height = value;
base.Width = value; // ❌ 强制将宽设为与高相等
}
}
}
问题浮现
var rect = new Square();
rect.Width = 5;
rect.Height = 10; // 这行代码实际上也隐式地 `rect.Width = 10`
Console.WriteLine(rect.Area()); // 输出 100,而不是预期的 50
违反LSP的原因
父类 Rectangle 对其属性的语义约定是:Width 和 Height 是两个可以独立修改的维度。
而子类 Square 在重写 setter 时,强行绑定了这两个属性,改变了它们“独立”这一核心行为契约。导致所有依赖于“长宽可独立设置”这一假设的代码(例如一个调整矩形长宽比例的函数)在传入 Square 对象时都会产生非预期的结果。
LSP 的实践检验标准
在设计中,你可以用一个简单的问题来检验:
“如果我在任何使用父类对象的地方,将其替换为它的某个子类对象,程序的行为是否依然正确且安全?”
- 答案是肯定的 → 设计符合 LSP。
- 答案是否定的,或需要修改客户端代码 → 设计违反了 LSP。
LSP 在SOLID原则中的定位
| 原则 |
核心作用 |
| OOP (继承机制) |
提供了代码复用的基础工具(“油门”) |
| LSP (里氏替换) |
限制继承的滥用,确保替换安全(“刹车”) |
| OCP (开闭原则) |
指导如何利用继承进行扩展(“地图”) |
| DIP (依赖倒置) |
通过抽象解耦具体依赖(“导航”) |
一句话总结:如果说继承机制给了你一辆车,那么LSP就是确保你不会把车开下悬崖的刹车系统。 深入理解这些 计算机科学 的基础原理,对构建健壮的软件架构至关重要。
思考与练习
练习 1:判断题
public class Payment
{
public virtual void Pay()
{
Console.WriteLine("支付");
}
}
public class FreePayment : Payment
{
public override void Pay()
{
// 空实现,什么都不做
}
}
问:FreePayment 是否违反 LSP?
结论:这取决于业务语义,不能仅从代码形式判断。
分析:
关键在于父类 Payment.Pay() 方法所承诺的“行为契约”到底是什么?
- 如果契约是 “执行一次支付动作”,那么
FreePayment 的空实现显然破坏了契约(没有执行动作),违反LSP。
- 如果契约是 “完成一次支付流程处理”,并且业务上“免费”本身就是一种合法的支付状态(如0元订单),那么空实现就是完成该流程的一种正确方式,不违反LSP。
核心要点:LSP的判断高度依赖于上下文和业务含义,而非单纯的语法。
练习 2:判断题
public class Bird
{
public virtual void Move()
{
Console.WriteLine("移动");
}
}
public class Penguin : Bird
{
public override void Move()
{
Console.WriteLine("游泳"); // 企鹅移动的方式是游泳
}
}
问:是否违反 LSP?
结论:不违反。
分析:
父类契约:Move() 表示“移动”。
子类行为:Move() 实现为“游泳”。
“游泳”是“移动”的一种具体形式,子类在遵循父类“移动”这一宽泛契约的前提下,提供了更具体的行为实现。这是合理的细化,而非破坏。
| 行为变化 |
是否符LSP |
原因 |
| 飞 → 抛异常 |
❌ 违反 |
从“有行为”变为“无行为/失败” |
| 移动 → 游泳 |
✅ 符合 |
从“宽泛行为”变为“具体行为” |
练习 3:改造题
以下是一个违反LSP的设计,请将其改造为符合LSP的设计。
原始问题代码:
public class Employee
{
public virtual int GetSalary()
{
return 1000;
}
}
public class Intern : Employee
{
public override int GetSalary()
{
throw new Exception("实习生没有工资"); // ❌
}
}
改造后的符合LSP的设计:
// 将有薪资的能力抽象为接口
public interface IPayable
{
double GetSalary();
}
// 正式员工有薪资,实现该接口
public class Employee : IPayable
{
public double GetSalary()
{
return 1000;
}
}
// 实习生没有薪资能力,因此不实现 IPayable 接口
public class Intern
{
// 可能拥有其他属性和方法,但不包括 GetSalary
}
通过接口隔离,我们从“是否是一种员工”的继承思维,转变为“是否拥有获取薪资能力”的组合思维。这样,要求薪资的代码会依赖 IPayable 接口,而 Intern 对象由于不实现该接口,根本无法被传入,从而在编译期就杜绝了错误。
希望这篇对里氏替换原则的剖析能帮助你写出更健壮、更易维护的 面向对象设计 代码。如果你有更多想法或案例,欢迎在 云栈社区 与大家交流探讨。