精通 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 开发者必须掌握的重要主题。

相关推荐
liuyunshengsir7 小时前
PyTorch 动态量化(Dynamic Quantization)
人工智能·pytorch·python
电子云与长程纠缠7 小时前
UE5制作六边形包裹球体效果
开发语言·python·ue5
DFT计算杂谈7 小时前
KPROJ编译教程
java·前端·python·算法·conda
念恒123068 小时前
Python(循环中断)
开发语言·python
tsfy20038 小时前
Python 处理中文文件名的3个坑(附 Flask 上传解决函数)
开发语言·python·flask·文件上传·中文编码
AI技术控8 小时前
KV Cache 缓存机制的原理和应用:从 Transformer 推理到大模型服务优化
人工智能·python·深度学习·缓存·自然语言处理·transformer
魔法阵维护师8 小时前
从零开发游戏需要学习的c#模块,第十章(设计模式入门)
学习·游戏·设计模式·c#
用户356302904878 小时前
【设计模式】组合模式——树形结构的统一处理
设计模式
vx-程序开发9 小时前
基于机器学习的动漫可视化系统的设计与实现-计算机毕业设计源码08339
java·c++·spring boot·python·spring·django·php
爱睡懒觉的焦糖玛奇朵9 小时前
【从视频到数据集:焦糖玛奇朵的魔法工具Video To YOLO Dataset】
人工智能·python·学习·yolo·音视频