精通 Python 设计模式——SOLID 原则

在软件工程的世界里,原则与最佳实践是构建健壮、可维护且高效代码库的支柱。上一章我们介绍了每位开发者都应遵循的基础原则。

本章将继续探讨设计原则,聚焦 SOLID------这是 Robert C. Martin 提出的首字母缩略词,代表五条旨在让软件设计更易理解、更灵活、更易维护的原则。

本章将涵盖以下主题:

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

在本章结束时,你将理解这五条设计原则,并知晓如何在 Python 中应用它们。

技术要求

参见第 1 章所列环境与工具要求。

SRP

**单一职责原则(SRP)**是软件设计中的基础理念。它主张:当你定义一个类来提供某项功能时,该类应当只有一个存在的理由,并只对这项功能的一个方面负责。更直白地说:每个类只做一件事,且将这件事的变化点封装在类内。

遵循 SRP 的实质,是让类更聚焦、内聚、专业。这样能显著提升代码库的可维护性与可理解性:当每个类职责清晰且单一,理解、管理与扩展就更容易。

当然,你并非"必须"遵循 SRP。但了解并以此为镜鉴去思考代码,能让你的代码库随时间持续改进。实践中,SRP 往往促使类更小、更聚焦,并通过组合来构建复杂系统,同时保持清晰有序的结构。

说明(NOTE)

SRP 并非要求"尽量减少类的代码行数",而是要求一个类应当只有一种引发其变化的原因,从而降低修改时引入意外副作用的可能。

遵循 SRP 的设计示例

设想一个在内容/文档管理工具或某类 Web 应用中常见的功能:生成 PDF 并保存到磁盘。为理解 SRP,先看一个 遵循 SRP 的初始版本:开发者定义了一个 Report 类,同时负责生成报告和将其保存到文件。

python 复制代码
class Report:
    def __init__(self, content):
        self.content = content

    def generate(self):
        print(f"Report content: {self.content}")

    def save_to_file(self, filename):
        with open(filename, 'w') as file:
            file.write(self.content)

如上,Report 负担了两项职责:生成保存 。虽然可以工作,但设计原则鼓励我们为未来演进做准备。SRP 提醒我们应分离职责。重构如下:用两个各司其职的类。

首先,生成报告内容的类:

python 复制代码
class Report:
    def __init__(self, content: str):
        self.content: str = content

    def generate(self):
        print(f"Report content: {self.content}")

其次,处理保存到文件的类:

python 复制代码
class ReportSaver:
    def __init__(self, report: Report):
        self.report: Report = report

    def save_to_file(self, filename: str):
        with open(filename, 'w') as file:
            file.write(self.report.content)

加入测试代码:

ini 复制代码
if __name__ == "__main__":
    report_content = "This is the content."
    report = Report(report_content)
    report.generate()
    report_saver = ReportSaver(report)
    report_saver.save_to_file("report.txt")

完整代码(ch02/srp.py):

python 复制代码
class Report:
    def __init__(self, content: str):
        self.content: str = content

    def generate(self):
        print(f"Report content: {self.content}")

class ReportSaver:
    def __init__(self, report: Report):
        self.report: Report = report

    def save_to_file(self, filename: str):
        with open(filename, "w") as file:
            file.write(self.report.content)

if __name__ == "__main__":
    report_content = "This is the content."
    report = Report(report_content)
    report.generate()
    report_saver = ReportSaver(report)
    report_saver.save_to_file("report.txt")

运行:

bash 复制代码
python ch02/srp.py

输出:

csharp 复制代码
Report content: This is the content.

同时会生成 report.txt 文件。一切如预期。可见,遵循 SRP 能带来更整洁、可维护且可适应变化的代码,提升项目整体质量与寿命。

OCP

开闭原则(OCP)同样是基础原则。它强调:软件实体(类、模块等)应当对扩展开放、对修改关闭。也就是:一个实体在定义实现之后,不应通过修改 原有代码来添加新功能;相反,应当通过扩展(如继承或接口/协议)来满足新需求。

有过一定规模开发经验的人会理解:随意修改已有实体,极易破坏依赖它的其他代码。OCP 为构建灵活可维护的系统提供了坚实基础:在不改动既有代码的前提下,引入新特性/行为;从而减少改动带来的缺陷或意外副作用。

遵循 OCP 的设计示例

设想一个表示矩形的 Rectangle 类,同时我们想要一个函数来计算不同形状的面积。一个起始(但不理想)的做法可能是:

arduino 复制代码
class Rectangle:
    def __init__(self, width:float, height: float):
        self.width: float = width
        self.height: float = height

def calculate_area(shape) -> float:
    if isinstance(shape, Rectangle):
        return shape.width * shape.height

说明(NOTE)

这段代码不在示例代码文件中,它只是帮助思考的"草案",并非最终做法。

如果之后要添加其他形状,就必须修改 calculate_area。这不理想:我们会反复改这段函数,也就需要反复测试、承担反复引入缺陷的风险。

为写出可维护代码,我们按 OCP 改造,并扩展支持圆形 Circle(使用 Circle 类)。

首先导入依赖:

javascript 复制代码
import math
from typing import Protocol

定义 Shape 协议,约定形状需提供 area() 方法:

ruby 复制代码
class Shape(Protocol):
    def area(self) -> float:
        ...

说明

关于 Python 的 Protocol 技术,请参见第 1 章"基础设计原则"。

定义满足 Shape 协议的 Rectangle

arduino 复制代码
class Rectangle:
    def __init__(self, width: float, height: float):
        self.width: float = width
        self.height: float = height

    def area(self) -> float:
        return self.width * self.height

定义同样满足 Shape 协议的 Circle

python 复制代码
class Circle:
    def __init__(self, radius: float):
        self.radius: float = radius

    def area(self) -> float:
        return math.pi * (self.radius**2)

实现 calculate_area,使新增形状时无需修改它:

python 复制代码
def calculate_area(shape: Shape) -> float:
    return shape.area()

加入测试代码:

ini 复制代码
if __name__ == "__main__":
    rect = Rectangle(12, 8)
    rect_area = calculate_area(rect)
    print(f"Rectangle area: {rect_area}")

    circ = Circle(6.5)
    circ_area = calculate_area(circ)
    print(f"Circle area: {circ_area:.2f}")

完整代码(ch02/ocp.py):

python 复制代码
import math
from typing import Protocol

class Shape(Protocol):
    def area(self) -> float:
        ...

class Rectangle:
    def __init__(self, width: float, height: float):
        self.width: float = width
        self.height: float = height

    def area(self) -> float:
        return self.width * self.height

class Circle:
    def __init__(self, radius: float):
        self.radius: float = radius

    def area(self) -> float:
        return math.pi * (self.radius**2)

def calculate_area(shape: Shape) -> float:
    return shape.area()

if __name__ == "__main__":
    rect = Rectangle(12, 8)
    rect_area = calculate_area(rect)
    print(f"Rectangle area: {rect_area}")

    circ = Circle(6.5)
    circ_area = calculate_area(circ)
    print(f"Circle area: {circ_area:.2f}")

运行:

bash 复制代码
python ch02/ocp.py

输出:

yaml 复制代码
Rectangle area: 96
Circle area: 132.73

一切正常!最大收获在于:我们新增 了一种形状,却无需修改 calculate_area。设计更优雅、维护更轻松,正是遵循 OCP 的直接收益。如今你又掌握了一条应当"日用而不觉"的原则:在保持既有功能稳定的同时,让设计能从容应对需求演进。

里氏替换原则(LSP)

**里氏替换原则(Liskov Substitution Principle, LSP)**是面向对象编程中的又一项基础概念,它规定了子类应当如何与其父类关联。按照 LSP,如果一个程序使用的是某个父类的对象,那么用其子类对象替换时,不应改变程序的正确性与预期行为。

遵循该原则对于保持软件系统的健壮性至关重要。它确保在使用继承时,子类是在不改变父类对外行为的前提下进行扩展。举例来说:如果一个函数在使用父类对象时是正确的,那么在使用该父类的任意子类对象时,也应当同样正确。

LSP 允许开发者在不破坏既有功能 的情况下引入新的子类类型。这在大型系统中尤为重要,因为某一处的变更可能会影响系统的其它部分。通过遵循 LSP,开发者可以更安全地修改和扩展类,并确信新的子类能够与既有的层次结构与功能无缝集成

遵循 LSP 的设计示例

先看一个 Bird 与其子类 Penguin 的示例:

ruby 复制代码
class Bird:
    def fly(self):
        print("I can fly")

class Penguin(Bird):
    def fly(self):
        print("I can't fly")

为满足一个"让鸟飞起来"的程序需求,我们添加一个函数:

scss 复制代码
def make_bird_fly(bird):
    bird.fly()

在当前代码下,如果传入 Bird 实例,会得到"会飞"的行为;而传入 Penguin 实例,会得到"不会飞"的行为。你可以查看并运行 ch02/lsp_violation.py 中的这份初版设计来验证结果。这恰好揭示了 LSP 想要我们避免的问题。那么,如何按照 LSP 改进设计呢?

为遵循 LSP,我们可以重构并引入新类,确保行为一致:

保留 Bird 类,但用更恰当的方法来表达我们想要的通用行为,命名为 move()

ruby 复制代码
class Bird:
    def move(self):
        print("I'm moving")

引入 FlyingBirdFlightlessBird,它们都继承自 Bird

ruby 复制代码
class FlyingBird(Bird):
    def move(self):
        print("I'm flying")

class FlightlessBird(Bird):
    def move(self):
        print("I'm walking")

相应地,函数改为:

scss 复制代码
def make_bird_move(bird):
    bird.move()

加入测试代码:

ini 复制代码
if __name__ == "__main__":
    generic_bird = Bird()
    eagle = FlyingBird()
    penguin = FlightlessBird()

    make_bird_move(generic_bird)
    make_bird_move(eagle)
    make_bird_move(penguin)

完整代码(ch02/lsp.py)如下:

ruby 复制代码
class Bird:
    def move(self):
        print("I'm moving")

class FlyingBird(Bird):
    def move(self):
        print("I'm flying")

class FlightlessBird(Bird):
    def move(self):
        print("I'm walking")

def make_bird_move(bird):
    bird.move()

if __name__ == "__main__":
    generic_bird = Bird()
    eagle = FlyingBird()
    penguin = FlightlessBird()

    make_bird_move(generic_bird)
    make_bird_move(eagle)
    make_bird_move(penguin)

测试命令:

bash 复制代码
python ch02/lsp.py

预期输出:

css 复制代码
I'm moving
I'm flying
I'm walking

该输出验证了我们想要的设计结果:当用 Penguin(不会飞)或 Eagle(会飞)替换通用的 Bird 时,程序的正确性仍能保持 ------也就是说,无论是 Bird 还是任何其子类实例,"都会移动(move)"。这正是遵循 LSP 的收益。示例表明,所有子类(FlyingBirdFlightlessBird)都可以替代其父类(Bird)而不破坏程序的预期行为,符合 LSP。

接口隔离原则(ISP)

接口隔离原则(Interface Segregation Principle, ISP)主张设计小而专 的接口,而不是大而全的通用接口。该原则指出:一个类不应被迫实现它不需要的接口。在 Python 语境中,这意味着一个类不应被迫继承并实现与其职责无关的方法。

ISP 建议我们在设计软件时,避免创建庞大、单体式的接口;而应聚焦于小而聚焦 的接口。这样,类只需继承或实现所需 的接口,确保每个类只包含相关且必要的方法。

遵循该原则,有助于构建模块化 的软件,提升可读性与可维护性,减少副作用,并让重构与测试更加容易等。

遵循 ISP 的设计示例

设想一个 AllInOnePrinter 类,具备打印、扫描与传真功能:

ruby 复制代码
class AllInOnePrinter:
    def print_document(self):
        print("Printing")

    def scan_document(self):
        print("Scanning")

    def fax_document(self):
        print("Faxing")

如果我们想引入一个只负责打印的精简版 SimplePrinter,它却不得不实现或继承 scan_documentfax_document(尽管并不需要)。这并不理想。

为遵循 ISP,我们把每种功能拆分到独立接口中,使每个类只实现自己需要的接口。

关于接口的说明

请参见第 1 章"基础设计原则"中"面向接口而非实现"的介绍,理解接口的重要性以及在 Python 中定义接口的技术(抽象基类、协议等)。特别是在此场景下,Protocol(协议)是天然的答案:它帮助我们定义只做一件事的小接口。

先定义三个接口:

ruby 复制代码
from typing import Protocol

class Printer(Protocol):
    def print_document(self):
        ...

class Scanner(Protocol):
    def scan_document(self):
        ...

class Fax(Protocol):
    def fax_document(self):
        ...

保留 AllInOnePrinter(它已经实现了这三类功能):

ruby 复制代码
class AllInOnePrinter:
    def print_document(self):
        print("Printing")

    def scan_document(self):
        print("Scanning")

    def fax_document(self):
        print("Faxing")

添加只实现打印功能的 SimplePrinter(实现 Printer 接口):

ruby 复制代码
class SimplePrinter:
    def print_document(self):
        print("Simply Printing")

再添加一个函数:当传入实现了 Printer 接口的对象时,调用其打印方法:

scss 复制代码
def do_the_print(printer: Printer):
    printer.print_document()

加入测试代码:

ini 复制代码
if __name__ == "__main__":
    all_in_one = AllInOnePrinter()
    all_in_one.scan_document()
    all_in_one.fax_document()
    do_the_print(all_in_one)

    simple = SimplePrinter()
    do_the_print(simple)

完整代码(ch02/isp.py)如下:

ruby 复制代码
from typing import Protocol

class Printer(Protocol):
    def print_document(self):
        ...

class Scanner(Protocol):
    def scan_document(self):
        ...

class Fax(Protocol):
    def fax_document(self):
        ...

class AllInOnePrinter:
    def print_document(self):
        print("Printing")

    def scan_document(self):
        print("Scanning")

    def fax_document(self):
        print("Faxing")

class SimplePrinter:
    def print_document(self):
        print("Simply Printing")

def do_the_print(printer: Printer):
    printer.print_document()

if __name__ == "__main__":
    all_in_one = AllInOnePrinter()
    all_in_one.scan_document()
    all_in_one.fax_document()
    do_the_print(all_in_one)

    simple = SimplePrinter()
    do_the_print(simple)

测试命令:

bash 复制代码
python ch02/isp.py

预期输出:

复制代码
Scanning
Faxing
Printing
Simply Printing

借助该设计,每个类只需实现与其行为相关的方法。这正是接口隔离原则(ISP)的体现。

依赖倒置原则(DIP)

**依赖倒置原则(Dependency Inversion Principle, DIP)**主张:高层模块不应直接依赖低层模块;二者都应当依赖于抽象(接口/协议)。这样可以将高层组件与低层组件的细节解耦。

该原则能降低系统各部分之间的耦合度,使系统更易维护与扩展,下面通过示例加以说明。

遵循 DIP 会在系统内部带来松散耦合,因为它鼓励把接口作为系统各部分之间的中介。当高层模块依赖接口时,它们就与低层模块的具体实现隔离。这样的关注点分离增强了可维护性与可扩展性。

本质上,DIP 与第 1 章"基础设计原则"中的松散耦合紧密相关:它提倡组件通过接口而非具体实现进行交互,从而减少模块间的相互依赖,使你可以更容易地修改或扩展系统的一部分而不影响其他部分。

遵循 ISP 的设计示例

【译注:此处原文标题似有笔误,应为"遵循 DIP 的设计示例"。】

考虑一个使用 Email 类经由电子邮件发送通知的 Notification 类。两者代码如下:

ruby 复制代码
class Email:
    def send_email(self, message):
        print(f"Sending email: {message}")

class Notification:
    def __init__(self):
        self.email = Email()

    def send(self, message):
        self.email.send_email(message)

关于代码的说明

这还不是最终版本的示例。

目前,高层的 Notification 类直接依赖于低层的 Email 类,这并不理想。为遵循 DIP,我们可以引入抽象,代码如下。

定义 MessageSender 接口:

python 复制代码
from typing import Protocol

class MessageSender(Protocol):
    def send(self, message: str):
        ...

定义实现 MessageSender 接口的 Email 类:

python 复制代码
class Email:
    def send(self, message: str):
        print(f"Sending email: {message}")

定义 Notification 类:它通过构造函数接收一个实现了 MessageSender 的对象并保存到 sender 属性中,用它来完成实际的发送:

ruby 复制代码
class Notification:
    def __init__(self, sender: MessageSender):
        self.sender: MessageSender = sender

    def send(self, message: str):
        self.sender.send(message)

加入测试代码:

ini 复制代码
if __name__ == "__main__":
    email = Email()
    notif = Notification(sender=email)
    notif.send(message="This is the message.")

完整代码(ch02/dip.py)如下:

python 复制代码
from typing import Protocol

class MessageSender(Protocol):
    def send(self, message: str):
        ...

class Email:
    def send(self, message: str):
        print(f"Sending email: {message}")

class Notification:
    def __init__(self, sender: MessageSender):
        self.sender = sender

    def send(self, message: str):
        self.sender.send(message)

if __name__ == "__main__":
    email = Email()
    notif = Notification(sender=email)
    notif.send(message="This is the message.")

运行:

bash 复制代码
python ch02/dip.py

预期输出:

csharp 复制代码
Sending email: This is the message.

如你所见,在更新后的设计中,NotificationEmail 都基于 MessageSender 这一抽象,因此该设计符合 DIP

总结

本章在第 1 章"基础设计原则"的基础上,进一步探讨了 SOLID 几项原则。理解并应用 SOLID 对于编写可维护、健壮、可扩展的 Python 代码至关重要。这些原则为良好的软件设计奠定坚实基础,使你更易于管理复杂性、减少错误并提升整体代码质量。

下一章,我们将开始探索 Python 中的设计模式------这同样是追求卓越的 Python 开发者必须掌握的重要主题。

相关推荐
c8i3 小时前
django中的FBV 和 CBV
python·django
c8i3 小时前
python中的闭包和装饰器
python
烛阴5 小时前
【TS 设计模式完全指南】懒加载、缓存与权限控制:代理模式在 TypeScript 中的三大妙用
javascript·设计模式·typescript
bobz9655 小时前
k8s svc 实现的技术演化:iptables --> ipvs --> cilium
架构
云舟吖5 小时前
基于 electron-vite 实现一个 RPA 网页自动化工具
前端·架构
李广坤6 小时前
工厂模式
设计模式
这里有鱼汤7 小时前
小白必看:QMT里的miniQMT入门教程
后端·python
brzhang7 小时前
当AI接管80%的执行,你“不可替代”的价值,藏在这20%里
前端·后端·架构
TF男孩17 小时前
ARQ:一款低成本的消息队列,实现每秒万级吞吐
后端·python·消息队列