在软件工程的世界里,原则与最佳实践是构建健壮、可维护且高效代码库的支柱。上一章我们介绍了每位开发者都应遵循的基础原则。
本章将继续探讨设计原则,聚焦 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")
引入 FlyingBird
与 FlightlessBird
,它们都继承自 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 的收益。示例表明,所有子类(FlyingBird
与 FlightlessBird
)都可以替代其父类(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_document
与 fax_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.
如你所见,在更新后的设计中,Notification
与 Email
都基于 MessageSender
这一抽象,因此该设计符合 DIP。
总结
本章在第 1 章"基础设计原则"的基础上,进一步探讨了 SOLID 几项原则。理解并应用 SOLID 对于编写可维护、健壮、可扩展的 Python 代码至关重要。这些原则为良好的软件设计奠定坚实基础,使你更易于管理复杂性、减少错误并提升整体代码质量。
下一章,我们将开始探索 Python 中的设计模式------这同样是追求卓越的 Python 开发者必须掌握的重要主题。