C# 中的里氏替换原则

2024年8月29日 | 阅读 10 分钟

里氏替换原则 (LSP) 是面向对象编程和设计中五大 SOLID 原则之一。它由 Barbara Liskov1987 年提出,专门旨在指导面向对象编程的继承和多态性方面。在 C# 和其他面向对象语言中,里氏替换原则是编写和使用类及继承层次结构的基本指导方针。

里氏替换原则可表述如下:

“子类型必须可以替换其基类型,而不会改变程序的正确性。”

这意味着,如果你有一个基类和一个派生类,派生类的对象应该能够替换基类的对象,而不会导致任何意外行为或违反程序的不变性。在 C# 中,你经常会看到这个原则应用于继承和多态性,其中派生类被期望遵循与其基类相同的契约(接口)。

以下是理解 C# 中里氏替换原则的一些关键点:

继承层次结构: 在 C# 中,LSP 主要关注 基类 与其 派生类 之间的关系。派生类应该扩展和专门化基类的行为,同时保持相同的接口(方法和属性)。

方法签名: 派生类中的方法应与基类中的方法具有相同的方法签名。这意味着派生类可以重写或扩展基类方法的行为,但不能更改方法的名称、参数和返回类型。

后置条件: 派生类应满足或放宽基类方法的后置条件(预期行为)。它不应削弱或违反这些条件。换句话说,派生类可以使其方法更宽松,但不能更严格。

前置条件: 派生类应加强或维护基类方法的前置条件(所需条件)。它不应放宽这些前置条件。这确保了任何依赖基类接口的代码仍然可以正确地与派生类对象一起工作。

异常: 如果基类方法在某些条件下抛出异常,派生类可以抛出相同的异常或更具体的异常。它不应抛出更广泛或意外的异常。

如何在 C# 中使用里氏替换原则

如果你想在 C# 中有效地使用里氏替换原则 (LSP),请遵循以下准则和最佳实践:

设计一个公共基类或接口

首先设计一个 基类 或接口,它定义了一组所有派生类共享的方法和属性。这个公共基类或接口代表了所有派生类必须遵守的契约。

适当地重写或实现方法

在派生类中,根据需要重写或实现基类或接口中定义的方法和属性。确保这些方法的行为符合基类或接口设定的期望。

保持相同的方法签名

在派生类中,方法签名(方法名称、参数和返回类型)应与基类或接口中的方法签名相同。这确保了派生类对象可以与基类对象互换使用。

遵循后置条件和前置条件

确保派生类中重写方法的后置条件 (预期行为) 至少与基类的后置条件一样强。换句话说,派生类应满足或放宽相同的条件。

维护或加强派生类中基类方法的前置条件(所需条件)。派生类不应放宽这些前置条件。

避免不必要地使用“new”关键字

在 C# 中,“new” 关键字可用于在派生类中隐藏或遮蔽方法或属性。谨慎使用此关键字,因为它如果使用不当,可能会导致混淆和意外行为。在大多数情况下,最好重写方法而不是遮蔽它们。

测试多态性

通过创建基类和派生类的实例并互换使用它们来测试代码的多态性。确保派生类的行为与基类契约保持一致。

文档化你的设计

清晰地文档化 基类接口,并提供关于派生类应如何实现其方法的指导方针。此文档对于维护开发人员之间的共同理解很重要。

定期审查和重构

定期审查代码库,以确保派生类仍然遵守基类契约。如果需要更改或更新,请重构代码以保持一致性。

示例

让我们举一个例子来演示 C# 中 里氏替换原则 的使用

输出

Ar?a of th? circl?: 78.5398163397448
Ar?a of th? r?ctangl?: 24

说明

  • 在这个例子中,我们定义了一个 基类 Shape,其中有一个 虚方法 Area(),它计算几何图形的面积。默认情况下,它返回 0,但派生类可以重写此方法以提供特定的实现。
  • 我们创建了一个从 Shape 继承的 派生类 Circle。它引入了一个附加属性 Radius,并重写了 Area() 方法,以使用公式 πr² 计算圆的面积。
  • 同样,我们创建了另一个从 Shape 继承的派生类 Rectangle。它引入了属性 Width 和 Height,并重写了 Area() 方法,以使用公式 width * height 计算矩形的面积。
  • Main 方法中,我们演示了里氏替换原则:
  • 我们创建了一个 Circle 类的对象,并将其 Radius 属性设置为 5。
  • 我们创建了一个 Rectangle 类的对象,并将其 Width 设置为 4,Height 设置为 6。
  • 之后,我们对这两个对象调用 Area() 方法,由于多态性和对里氏替换原则的遵守,它们可以互换工作。
  • 最后,我们使用 Console.WriteLine() 打印计算出的圆形和矩形的面积。

以下是更结构化的解释:

  • Shape 类作为各种几何形状的基类。它定义了一个默认返回 0 的虚方法 Area()。
  • Circle 类是一个特定的形状(圆形),它派生自 Shape。它重写了 Area() 方法,以根据提供的半径计算面积。
  • Rectangle 类是另一个特定的形状(矩形),它也派生自 Shape。它重写了 Area() 方法,以根据宽度和高度计算面积。
  • 在 Main 方法中,我们创建了 Circle 和 Rectangle 的实例,并设置了它们的特定属性(Radius 和 Width/Height)。
  • 之后,我们对这些对象调用 Area() 方法,由于多态性和对里氏替换原则的遵守,每个对象都会执行适当的重写方法,正确计算并返回面积。
  • 计算出的面积打印到控制台,通过用派生对象(Circle 和 Rectangle)替换基类(Shape)而不会改变程序的正确性来演示里氏替换原则。

复杂性分析

时间复杂度

  • 创建对象 circlerectangle 的时间复杂度很小,通常被认为是 O(1),因为它不依赖于数据结构的大小或任何循环。
  • 调用这些对象的 Area() 方法的复杂度也很小。在这种特定情况下,它取决于每个类的 Area() 方法的实现,但通常涉及基本的算术运算。因此,Circle 和 Rectangle 类的时间复杂度均为 O(1)
  • 使用 Console.WriteLine() 打印结果通常也是 O(1),因为它不随输入大小而变化。
  • 总的来说,这段代码的时间复杂度是 O(1) 或常数时间。执行时间不取决于任何数据结构的大小或输入大小。

空间复杂度

  • 创建对象 circle 和 rectangle 消耗内存来存储它们的属性(Radius、Width 和 Height)和引用。所需的空间与对象属性的数量成正比,但不取决于输入大小。因此,它的空间复杂度为 O(1)
  • 调用 Area() 方法对空间复杂度没有显著影响,因为它主要涉及计算,而没有额外的内存分配。
  • Console.WriteLine() 语句不消耗随输入扩展的额外空间。打印所需的空间通常很小且恒定,使其为 O(1)
  • 总之,这段代码的空间复杂度也是 O(1) 或常数空间。它不依赖于输入数据或任何数据结构的大小。

示例

让我们举一个不使用 C# 中的里氏替换原则来计算圆形和矩形面积的例子

输出

Ar?a of th? circl?: 0
Ar?a of th? r?ctangl?: 0

说明

  • 在这个例子中,我们有一个基类 Shape,它定义了一个返回 0 的 Area() 方法。Area 方法没有声明为 virtual 或 abstract,因此派生类不能重写它。
  • 之后,两个派生类 CircleRectangle 继承自 Shape。但是,它们使用 new 关键字引入了自己的 Area() 方法。这有效地隐藏了基类的 Area() 方法,而不是重写它。这意味着 Circle 和 Rectangle 有自己独立的 Area 方法,与基类分开。
  • Main 方法 中,我们创建了 Circle 和 Rectangle 的实例,并将它们分配给 Shape 引用(circle 和 rectangle)。这是允许的,因为派生类可以分配给基类引用。
  • 当我们对 circle 和 rectangle 调用 Area() 方法时,行为由引用变量的类型决定,而不是对象的实际类型。在这种情况下,new 关键字导致调用基类的方法,而不是 Circle 和 Rectangle 中重写的方法。这导致不正确的行为,因为基类 Area() 方法始终返回 0。
  • 我们将圆形和矩形的面积打印到控制台,但由于调用了基类的 Area() 方法,这两个值都将为 0。
  • 总之,这段代码演示了在派生类(Circle 和 Rectangle)中使用 new 关键字如何导致基类方法被隐藏而不是重写。当通过基类引用使用派生类对象时,这会导致意外和不正确的行为。这是一个不遵守里氏替换原则的例子,该原则建议派生类应该扩展而不是隐藏基类的行为。

复杂性分析

时间复杂度

这段代码的时间复杂度是 O(1) 或常数时间。执行时间不依赖于任何数据结构的大小或输入大小。

空间复杂度

这段代码的空间复杂度也是 O(1) 或常数空间,就像遵循里氏替换原则的代码一样。

不遵守里氏替换原则的代码的时间和空间复杂度都是 O(1),这意味着它们是常数,不取决于输入大小或数据结构。这是因为代码的基本结构和操作,例如对象创建、方法调用和控制台输出,不随输入大小而扩展,并且涉及基本的算术和内存管理。

实时应用

以下是里氏替换原则 (LSP) 在软件设计中的一些实时应用或场景。

几何图形

在图形设计和 计算机辅助设计 (CAD) 软件中,可以操作和渲染各种几何图形,如圆形、矩形、三角形和多边形。这些形状遵循一个共同的几何形状接口或基类。

LSP 允许软件统一处理这些不同的形状,从而轻松执行缩放、旋转和渲染等操作。

银行系统

银行软件包含各种类型的账户,例如储蓄账户、支票账户和信用卡账户,它们作为通用基账户类的派生类。

LSP 确保交易、账户管理和利息计算可以统一处理,从而更容易在未来添加新的账户类型。

车辆管理

运输和物流软件通常处理不同类型的车辆,包括汽车、卡车、自行车和摩托车。

这些车辆类型遵循一个通用的车辆接口或基类,允许软件以一致的方式管理跟踪、燃料消耗和维护等方面。

机器人技术

在机器人技术中,有不同类型的机器人,如无人机、人形机器人和工业机器人。

这些机器人遵守统一的机器人控制接口,从而能够在不同类型的机器人上一致地应用运动规划、路径跟踪和传感器数据处理等任务。