改进 Python 中的面向对象设计

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

在本教程中,我们将学习如何改进 Python 中的面向对象设计。当我们编写类并在 Python 中设计交互时,我们会遵循一组有助于构建更好的面向对象代码的指令。面向对象设计是流行且被广泛接受的标准集之一。它也被称为 SOLID 原则。在 C++ 和 Java 中,这些原则被广泛使用。然而,你可能不知道 Python 也遵循 SOLID 原则。你可以考虑在我们的 OOD 中应用这些原则。

SOLID 是一个缩写,它包含了适用于面向对象设计的五个核心原则。它们如下所示:

  • 单一职责原则 (SRP)
  • 开闭原则 (OCP)
  • 里氏替换原则 (LSP)
  • 接口隔离原则 (ISP)
  • 依赖反转原则 (DIP)

我们将通过适当的示例详细解释每个原则。我们还将学习如何在 Python 中应用它们。这些原则将有助于深入理解如何编写更简洁、更有组织、可扩展且可重用的面向对象代码。让我们从第一个原则开始。

单一职责原则 (SRP)

单一职责原则指出——一个类应该只有一个改变的理由。这句话的含义是一个类应该只有一个职责,通过其方法来表达。如果一个类负责多个任务,最好将这些任务分离到单独的类中。这个原则遵循关注点分离的概念,它建议您将程序分成不同的部分。每个部分必须解决一个单独的关注点。

为了解释单一职责原则,我们使用以下示例。

示例 -

在上面的示例中,我们创建了一个 FileHandler 类,它负责通过 .read().write() 方法管理文件。它还通过提供 .compress() 和 .decompress() 方法处理 ZIP 归档。

这个类不遵循单一职责原则,因为它有两个改变其内部实现的理由。我们可以通过将类拆分成两个更小、更专用的类来使其更健壮,每个类都有自己特定的关注点。

示例 -

在修改后的代码示例中,FileHandler 类负责管理文件,包括从文件中读取和写入。另一方面,ZipFileHandler 类负责使用 ZIP 格式压缩和解压缩文件。通过将功能拆分到单独的类中,我们使它们更小、更易于管理和理解。

应用 SRP 并不意味着一个类只能有一个方法,而是它应该有一个单一的总体职责或目的。重要的是要运用您的判断力,并确定在您的特定代码库中,什么构成了一个类的连贯职责。

开闭原则 (OCP)

开闭原则是指——一个类应该对扩展开放,对修改关闭。

让我们理解下面的例子。

示例 -

Shape 类接受 shape_type 作为参数,可以是“rectangle”或“circle”。我们还定义了 **kwargs 来获取特定的属性集。如果我们传递 rectangle,那么我们需要传递 height 和 width 关键字参数,以便我们可以构造一个正确的矩形。如果我们将 self type 设置为 circle,我们必须传递一个 radius 参数来构造一个圆。Shape 还有一个 .calculate_area() 方法,用于根据其 .shape_type 计算当前形状的面积。

Shape 类的当前实现违反了开闭原则,因为它需要修改现有代码才能添加新的形状(例如,正方形)。

Shape 类的当前实现引起了担忧,特别是关于开闭原则。乍一看,很明显需要修改类才能添加新的形状,例如正方形。

开闭原则指出,软件实体应该对扩展开放,但对修改关闭。换句话说,我们应该能够在不修改现有代码的情况下引入新功能。

为了遵循开闭原则,需要一个更灵活、可扩展的设计。一种可能的方法是利用继承和多态。我们可以为每个形状创建单独的类,所有类都实现一个共同的接口或基类,而不是一个单独的 `Shape` 类来处理不同的形状类型。

这种设计允许通过添加继承自公共接口或基类的新形状类来轻松扩展。每个形状类都可以实现自己的初始化和面积计算逻辑,封装该特定形状的独特属性和行为。

通过采用继承和多态,我们可以实现更模块化、可维护和可扩展的代码库,该代码库与面向对象设计的原则相符。

我们可以使用以下示例来解决此问题 -

示例 -

上面的代码看起来更合适,并且遵循 OCP。这个类为我们想要定义的任何形状提供了所需的接口(API)。该接口包含一个 .shape_type 属性和一个 .calculate_area() 方法,您必须在所有子类中重写它们。

里氏替换原则 (LSP)

这个原则指出——子类型必须能够替代基类型。在提供的代码中,Shape 类作为基类,而 Circle、Rectangle 和 Square 是其子类。通过遵循这种结构,任何使用 Shape 类的代码都可以无缝地使用其子类。

例如,如果您有一段代码作用于 Shape 对象,例如调用 calculate_area() 方法,您可以毫无问题地将该 Shape 对象替换为 Circle、Rectangle 或 Square 的实例。代码将继续正常运行,因为每个子类都根据其特定形状实现了自己的 calculate_area() 方法版本。

在实践中,面向对象设计中应用的替代性原则侧重于确保子类与其基类行为一致。该原则旨在避免破坏调用不同对象上相同代码的期望。

通过遵守此原则,当您创建子类时,例如“Circle”、“Rectangle”或“Square”,它们的行为方式应该与基类“Shape”一致。这意味着在这些类的不同对象上调用相同的方法(例如 calculate_area())应该产生预期结果,并且不会导致任何意外行为。

通过使子类遵守基类定义的行为和接口,您可以确保现有代码(与基类一起工作)可以无缝地与子类一起工作,而不会出现任何问题或意外。这促进了代码的可靠性、可维护性,并有助于维护一致且可预测的代码库。

示例 -

由于正方形是具有相等边长的矩形的特例,因此您可以从 Rectangle 类派生一个 Square 类以重用代码。通过这样做,您可以继承 Rectangle 类的属性和方法,同时还可以自定义正方形特有的行为。

为了确保正方形的边长始终相等,您可以重写宽度和高度属性的设置方法。这允许您同步两个边长的值,以便当一个边长改变时,另一个边长自动调整以匹配。

示例 -

Square 类被定义为 Rectangle 类的子类,这意味着它继承了 Rectangle 类的属性和方法。

在 __init__ 方法中,调用 super().__init__(side, side) 以使用两次传递的 side 参数来调用 Rectangle 超类的初始化。通过这样做,Square 对象以相等的宽度和高度值进行初始化,从而创建一个正方形。

__setattr__ 方法被重写以自定义在 Square 对象上设置属性时的行为。它允许同步宽度和高度属性,确保它们始终具有相等的值。

当使用 self.attribute = value 语法设置属性时,会调用 super().__setattr__(key, value) 来调用基类的 __setattr__ 方法。这确保了属性以适合父类的方式设置。

之后,执行检查以确定修改后的属性是“width”还是“height”。如果是,则对象 __dict__ 中的 width 和 height 属性被显式设置为相同的值 (value)。这确保了对正方形一侧的任何修改也会更新另一侧以保持相等的值。

通过重写 __setattr__ 方法,Square 类强制执行宽度和高度必须始终相等的约束,从而保留了正方形的形状及其固有特性。

然而,这违反了里氏替换原则,因为我们不能用正方形实例替换矩形实例。我们可以通过为矩形和正方形创建一个基类来解决这个问题。

示例 -

经过修改后,Shape 成为一种可以使用多态性替换为 Rectangle 或 Square 的类型。它们现在是兄弟类,而不是父子关系。需要注意的是,Rectangle 和 Square 具有不同的属性集、不同的初始化方法,并且有可能实现额外的独立行为。

尽管存在这些差异,Rectangle 和 Square 都通过 Shape 基类共享一个公共接口。这个公共接口确保这两种形状都能够计算各自的面积。

接口隔离原则 (ISP)

接口隔离原则的主要思想是客户端不应该被迫依赖它们不使用的方法。接口属于客户端,而不属于层次结构。

在这种上下文中,客户端指的是与其它类交互的类和子类。接口由这些类赖以执行其功能的方法和属性组成。如果一个类不使用某些方法或属性,则最好将它们隔离到更具体的类中。

让我们理解以下示例 -

示例 -

上述类结构允许我们创建具有不同功能集的不同机器,并使设计更加灵活和可扩展。

依赖倒置原则 (DIP)

这个原则指出——抽象不应该依赖于细节。细节应该依赖于抽象。

这听起来可能有点复杂。以下示例将使其更清晰。我们有一个 Frontend 类,以友好的方式向用户显示数据。该应用程序目前从数据库中获取数据。代码如下:

示例 -

在上面的示例中,FrontEnd 类依赖于 BackEnd 类。这两个类是紧密耦合的。这种耦合可能是可扩展性问题的原因。如果出现修改,例如我们想从 RestAPI 而不是数据库中读取数据,我们该怎么做呢?

可能的解决方案是向 BackEnd 添加一个新方法以从 RESTAPI 检索数据。但是,这也需要修改 FrontEnd。

我们可以使用依赖反转原则 (DIP) 来解决这个问题,使类依赖于抽象而不是像 BackEnd 这样的具体实现。下面是简化示例。

示例 -

在此版本中,我们引入了一个 DataSource 接口,它是一个抽象,表示我们可以从中获取数据的来源。Database 类实现了此 DataSource 接口。

Frontend 类现在依赖于 DataSource 接口,而不是具体的 Database 类。这遵循了依赖反转原则,因为高级 Frontend 类依赖于抽象(接口)而不是特定实现。

这种方法允许您轻松引入符合 DataSource 接口的其他数据源(例如,API、文件),而无需修改 Frontend 类,从而提高了灵活性和可维护性。

结论

本教程简要介绍了 SOLID 原则(单一职责原则、开闭原则、里氏替换原则、接口隔离原则和依赖反转原则),它们是编写可维护、灵活和可扩展代码的基本指导方针。通过遵循这些原则,您可以创建更易于理解、扩展和长期维护的代码。

在前面提供的示例中,我们展示了如何在 Python 中应用每个原则来重构和改进代码。我们识别了设计问题,重构了代码以符合 SOLID 原则,并展示了这样做的好处。


下一主题最长回文子串