找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

4775

积分

0

好友

644

主题
发表于 3 小时前 | 查看: 3| 回复: 0

核心本质

子类必须可以替换父类,并且程序行为不出错。

通俗理解

你编写的子类,不能“破坏”父类原本定义好的逻辑契约。

经典反例(需要警惕)

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() 方法,对象就会执行“飞行”这一行为。

而子类 OstrichFly() 方法却抛出了异常,这相当于单方面撕毁了契约,导致所有信赖此契约的代码(如 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 对其属性的语义约定是:WidthHeight两个可以独立修改的维度。

而子类 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 对象由于不实现该接口,根本无法被传入,从而在编译期就杜绝了错误。

希望这篇对里氏替换原则的剖析能帮助你写出更健壮、更易维护的 面向对象设计 代码。如果你有更多想法或案例,欢迎在 云栈社区 与大家交流探讨。




上一篇:依赖倒置原则实战:以C#为例避免代码直接New实现高内聚低耦合
下一篇:AI Agent供应链投毒:五个实验揭示MCP工具层的隐蔽攻击
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-4-10 10:06 , Processed in 0.652789 second(s), 41 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表