原文链接:https://realpython.com/solid-principles-python/
by Leodanis Pozo Ramos May 01, 2023
目录
- Python中的面向对象设计:SOLID原则
- 单一职责原则(SRP)
- 开放封闭原则(OCP)
- 里氏替换原则(LSP)
- 接口隔离原则(ISP)
- 依赖倒置原则(DIP)
- 结语
当你使用面向对象编程(OOP)建立起来一个Python项目,规划好不同的类、对象之间的交互以解决特定问题是工作的重要部分。这种规划就被称作 object-oriented design (OOD) (面向对象设计),把它做好可是个不小的挑战。如果你在设计Python类时卡住了,那么SOLID原则会帮你走出困境。
SOLID是一套共计5个的面向对象的设计原则,能帮助你设计出优秀的、结构清晰的类,进而写出更容易维护、更灵活、伸缩性强的代码。这些教程是面向对象设计最佳实践的根本部分。
在本篇教程中,你将
- 理解每一条SOLID原则的意义和目的
- 识别违反了部分SOLID原则的Python代码
- 运用SOLID原则来重塑你的Python代码并提升它的设计
在这次学习之旅中,你会编写实际的案例,来探索如何在SOLID原则的指导下写出条理性强、灵活、维护性强以及伸缩性强的代码。
为了在最大程度上受益,你必须对Python的 object-oriented programming (面向对象编程)概念有较好的了解,例如 classes , interfaces ,和 inheritance 。
Python中的面向对象设计:SOLID原则
当在Python里编写类和设计它们的交互时,你可以遵循一系列帮你编写更好的面向对象代码的原则。这其中最受欢迎、最广泛接受的 object-oriented design (OOD) (面向对象设计)标准之一就是 SOLID 原则。
如果你是从 C++ 或者 Java 转过来的,你可能已经对这些原则很熟悉了。也许你在想SOLID原则是否对Python代码也适用。这个问题的答案,当然是一个响亮的 yes 。如果你在编写面向对象代码,那就应该把这些原则考虑进去。
但SOLID原则有哪些呢?SOLID是五个应用在面向对象设计的核心原则的缩写。这些原则如下:
- Single-responsibility principle (SRP)(单一职责原则)
- Open–closed principle (OCP)(开放封闭原则)
- Liskov substitution principle (LSP)(里氏替换原则)
- Interface segregation principle (ISP)(接口隔离原则)
- Dependency inversion principle (DIP)(依赖倒置原则)
你将详细探索这些原则,并编写一些在Python中应用它们的真实代码。在这个过程中,通过应用SOLID原则,你将对如何编写更直观、有条理、伸缩性强、复用性强的面向对象代码有深入的理解。闲话少说,你要开始学清单上的第一条原则啦。
单一职责原则 (SRP)
单一职责原则 (SRP) 由 Robert C. Martin 提出,他的昵称 Uncle Bob 更广为人知。他在软件工程界广受尊敬,还是 Agile Manifesto (敏捷宣言)的签署者之一。事实上,他创造了SOLID这个术语。
单一职责原则规定了这点:
一个类应该只有一个发生变化的原因
这就是说一个类应该只有一个职责,它的方法也应该表现成这样。如果一个类关注了多个任务,那么你应该把这些任务拆成单个单个的类。
注意: 你会发现SOLID原则的表述形式会有差别。本篇教程谈的是 Uncle Bob 在他的书 Agile Software Development: Principles, Patterns, and Practices 里用的表述。所以,所有的直接引用都来自他的书。
如果你想看看这些,以及相关原则的快速概述的其他表述,可以看看 Uncle Bob’s The Principles of OOD。
这个原则和 separation of concerns (责任分离)很相近,责任分离建议把程序拆成不同部分。每个部分得强调单独的责任。
为了说明单一职责原则以及它如何提升你的面向对象设计,就比如说有一个这样的 FileManager
类:
# file_manager_srp.py
from pathlib import Path
from zipfile import ZipFile
class FileManager:
def __init__(self, filename):
self.path = Path(filename)
def read(self, encoding="utf-8"):
return self.path.read_text(encoding)
def write(self, data, encoding="utf-8"):
self.path.write_text(data, encoding)
def compress(self):
with ZipFile(self.path.with_suffix(".zip"), mode="w") as archive:
archive.write(self.path)
def decompress(self):
with ZipFile(self.path.with_suffix(".zip"), mode="r") as archive:
archive.extractall()
在这个例子中,你的 FileManager
类有两个不同的职责。它用 .read()
和 .write()
方法来管理文件。也通过提供 .compress()
和 .decompress()
方法处理 ZIP archives (ZIP文档)。
这个类违反了单一职责原则因为他有两个改变内部实现的原因。为了修正这个问题,让你的设计鲁棒性更好,你可以把这个类拆成两个更小、更专一的类,每个只有它自己的特定职责:
# file_manager_srp.py
from pathlib import Path
from zipfile import ZipFile
class FileManager:
def __init__(self, filename):
self.path = Path(filename)
def read(self, encoding="utf-8"):
return self.path.read_text(encoding)
def write(self, data, encoding="utf-8"):
self.path.write_text(data, encoding)
class ZipFileManager:
def __init__(self, filename):
self.path = Path(filename)
def compress(self):
with ZipFile(self.path.with_suffix(".zip"), mode="w") as archive:
archive.write(self.path)
def decompress(self):
with ZipFile(self.path.with_suffix(".zip"), mode="r") as archive:
archive.extractall()
现在你有了两个更小的类,每个都仅有一个职责。 FileManager
关心的是管理文件,而 ZipFileManager
处理ZIF格式文件的 compression (压缩)和 decompression (解压)。 这两个类更小,所以更容易管理。它们也更容易分析、测试以及debug。
此处的职责可能是相当主观的。具有单一职责并不一定意味着具有单个方法。职责不是直接跟方法数绑定的,而是跟你的类负责的核心任务绑定,取决于这个类在你的代码里代表什么。然而,主观性并不应该阻止你力求SRP。
开放封闭原则(OCP)
面向对象设计里的**开放封闭原则(OCP)**最初由 Bertrand Meyer 在1988年提出,意思是:
软件实体(类、模块、函数等等)应该对拓展开放,对修改封闭
要理解什么是开放封闭原则,考虑下面的 Shape
类:
# shapes_ocp.py
from math import pi
class Shape:
def __init__(self, shape_type, **kwargs):
self.shape_type = shape_type
if self.shape_type == "rectangle":
self.width = kwargs["width"]
self.height = kwargs["height"]
elif self.shape_type == "circle":
self.radius = kwargs["radius"]
def calculate_area(self):
if self.shape_type == "rectangle":
return self.width * self.height
elif self.shape_type == "circle":
return pi * self.radius**2
Shape
的构造器接收一个在 "rectangle"
和 "circle"
二选一的 shape_type
参数。还用 **kwargs
语法接收特定的一套关键字参数。如果你把形状设成 "rectangle"
,那么你应该传入 width
和 height
关键字参数以便构造一个合适的矩形。
相反,如果你把形状设成 "circle"
,那么你必须也传入 radius
参数来构造一个圆形。
注意: 这个例子可能看起来有点极端。这是为了清晰地展示开放封闭原则的核心观点。
Shape
还有一个 .calculate_area()
方法,能根据当前形状的 .shape_type
计算面积:
>>> from shapes_ocp import Shape
>>> rectangle = Shape("rectangle", width=10, height=5)
>>> rectangle.calculate_area()
50
>>> circle = Shape("circle", radius=5)
>>> circle.calculate_area()
78.53981633974483
这个类能运行。你可以创建圆和矩形,计算面积,等等。然而,这个类看起来很糟糕,有些事似乎第一眼就不对。
想象你需要加入一个新的形状,也许是正方形。你会怎么做?好吧,其中一个选择是再加个elif
子句到 .__init__()
和 .calculate_area()
里,这样就能解决正方形的需求了。
必须做这些改变来创建新的形状就说明你的类对修改是开放的。这违反了开放封闭原则。你该如何修改类使得它对拓展开放但对修改封闭呢?这有一个可能的解决:
# shapes_ocp.py
from abc import ABC, abstractmethod
from math import pi
class Shape(ABC):
def __init__(self, shape_type):
self.shape_type = shape_type
@abstractmethod
def calculate_area(self):
pass
class Circle(Shape):
def __init__(self, radius):
super().__init__("circle")
self.radius = radius
def calculate_area(self):
return pi * self.radius**2
class Rectangle(Shape):
def __init__(self, width, height):
super().__init__("rectangle")
self.width = width
self.height = height
def calculate_area(self):
return self.width * self.height
class Square(Shape):
def __init__(self, side):
super().__init__("square")
self.side = side
def calculate_area(self):
return self.side**2
在这段代码里,你完全 refactored (重塑)了 Shape
类,把它变成了一个 abstract base class (ABC)(抽象基类)。这个类提供的 interface (API) (接口)让你能定义任何想要的形状。这个 interface (接口)由 .shape_type
属性和必须在子类重写的 .calculate_area()
方法组成。
注意:上面的这个例子以及后续章节的一些例子使用了Python的ABC来提供接口继承。在这种继承里,子类继承了接口而不是功能。反之,如果类继承了功能,就是实现继承。
这种更新让类对修改封闭。现在你无需修改 Shape
就能往类里加入新的形状啦。在每种情况下,你都必须实现所需接口,这也使得你的类具有 polymorphic (多态性)。
里氏替换原则(LSP)
**里氏替换原则(LSP)**由 Barbara Liskov 在1987年的 OOPSLA conference (OOPSLA会议)提出。从此,这个原则成了面向对象编程的基本部分。这条原则规定了:
子类必须能替代它们的基类
举个例子,如果你有一段用 Shape
类运行的代码,那么也应该能把这个类替换成它的任意一个子类,例如 Circle
或 Rectangle
,而不会中途报错。
**注意:**你可以阅读 conference proceedings (会议议程),了解 Barbara Liskov 首次分享这一原则的主题演讲内容;或者还可以观看她的一段简短 interview (访谈)片段,以获取更多背景信息。
实际上,这个原则是说当任何人调用子类和基类的同一个方法时,它们的行为应该如预期般一致。继续形状的例子,比如说你有一个下面这样的 Rectangle
类:
# shapes_lsp.py
class Rectangle:
def __init__(self, width, height):
self.width = width
self.height = height
def calculate_area(self):
return self.width * self.height
在 Rectangle
里,你提供了 .calculate_area()
方法,该方法使用了 .width
和 .height
instance attributes(实例属性)。
由于一个正方形是等边的特殊矩形,为了复用相同代码,你考虑从 Rectangle
派生出 Square
。然后,你为 .width
和 .height
属性重写了 setter ,这样当一条边改变,另一条边也会改变:
# shapes_lsp.py
# ...
class Square(Rectangle):
def __init__(self, side):
super().__init__(side, side)
def __setattr__(self, key, value):
super().__setattr__(key, value)
if key in ("width", "height"):
self.__dict__["width"] = value
self.__dict__["height"] = value
在这一小段代码中,你定义 Rectangle
的子类 Square
。作为用户可能会希望,类构造器 constructor 只接收正方形变成作为参数。 在内部, .__init__()
方法使用 side
参数初始化了父类的 .width
和 .height
属性。
你可能也会定义 special method (特殊方法) .__setattr__()
来接入Python的属性设置机制并在给 .width
或 .height
属性 assignment (设定)新值时拦截。 特别地,当设定其中一个属性,另外一个属性也会被设成同样的值:
>>> from shapes_lsp import Square
>>> square = Square(5)
>>> vars(square)
{'width': 5, 'height': 5}
>>> square.width = 7
>>> vars(square)
{'width': 7, 'height': 7}
>>> square.height = 9
>>> vars(square)
{'width': 9, 'height': 9}
现在你确保了 Square
对象永远是一个有效的矩形,虽然浪费了一点内存,但感觉舒服多了。不幸的是,这违反了里氏替换原则因为你不能将 Rectangle
的实例替换成对应的 Square
。
当别人想在代码里用矩形对象,他们可能假定这个对象表现得像一个矩形,即会暴露出两个独立的 .width
和 .height
属性。 与此同时,你的 Square
类更改了由对象接口所保证的行为,从而打断了这种假设。这可能导致意料之外的结果,很难去 debug 。
虽然正方形在数学上是一类特殊的矩形,但如果你想遵守里氏替换原则,它们的类不应该是父子代关系。其中一个解决方案是给 Rectangle
和 Square
创建一个可拓展的基类:
# shapes_lsp.py
from abc import ABC, abstractmethod
class Shape(ABC):
@abstractmethod
def calculate_area(self):
pass
class Rectangle(Shape):
def __init__(self, width, height):
self.width = width
self.height = height
def calculate_area(self):
return self.width * self.height
class Square(Shape):
def __init__(self, side):
self.side = side
def calculate_area(self):
return self.side ** 2
现在你可以通过多态性用 Rectangle
或 Square
替换 Shape
,现在它们是兄弟姐妹而不是父子代关系。注意两个具体的形状都有不同的一套属性、不同的初始化方法,并可能实现更多不相干的行为。他们唯一的共同点就是都能计算面积。
有了这个实现,当你只关心它们的共同行为时,就可以用 Square
和 Rectangle
子类交换 Shape
:
>>> from shapes_lsp import Rectangle, Square
>>> def get_total_area(shapes):
... return sum(shape.calculate_area() for shape in shapes)
>>> get_total_area([Rectangle(10, 5), Square(5)])
75
这里,你给计算总面积的函数传入了一个矩形和正方形。由于这个函数只关心 .calculate_area()
方法,形状不同也没关系。这就是里氏替换原则的精华。
接口隔离原则(ISP)
**接口隔离原则(ISP)**和单一职责原则出发点相同。 是的,这又是 Uncle Bob’s 的另一项成就。这个原则的中心思想是:
clients 不应该被强迫依赖它们用不上的方法。Interfaces 属于clients,而不是 hierarchy (层次结构)。
在这个情景里,clients 指的是类和子类,而 interfaces 包括了方法和属性。换句话说,如果一个类用不上某些方法或属性,那么这些方法和属性就该拆分到更多专门的类里。
考虑下面这个类的 hierarchy (层次结构),用于模仿打印机设备:
# printers_isp.py
from abc import ABC, abstractmethod
class Printer(ABC):
@abstractmethod
def print(self, document):
pass
@abstractmethod
def fax(self, document):
pass
@abstractmethod
def scan(self, document):
pass
class OldPrinter(Printer):
def print(self, document):
print(f"Printing {document} in black and white...")
def fax(self, document):
raise NotImplementedError("Fax functionality not supported")
def scan(self, document):
raise NotImplementedError("Scan functionality not supported")
class ModernPrinter(Printer):
def print(self, document):
print(f"Printing {document} in color...")
def fax(self, document):
print(f"Faxing {document}...")
def scan(self, document):
print(f"Scanning {document}...")
在这个例子中,基类 Printer
,提供了子类必须实现的接口。继承自 Printer
的 OldPrinter
必须实现相同的接口。然而, OldPrinter
不使用 .fax()
和 .scan()
方法因为这种打印机不支持这些功能。
这个实现违反了 ISP 因为它强迫 OldPrinter
暴露没实现也用不上的接口。要修正这点,你应该把接口分成更小、更专门的类。然后你就可以按照需要继承多个接口类来创建具体类:
# printers_isp.py
from abc import ABC, abstractmethod
class Printer(ABC):
@abstractmethod
def print(self, document):
pass
class Fax(ABC):
@abstractmethod
def fax(self, document):
pass
class Scanner(ABC):
@abstractmethod
def scan(self, document):
pass
class OldPrinter(Printer):
def print(self, document):
print(f"Printing {document} in black and white...")
class NewPrinter(Printer, Fax, Scanner):
def print(self, document):
print(f"Printing {document} in color...")
def fax(self, document):
print(f"Faxing {document}...")
def scan(self, document):
print(f"Scanning {document}...")
现在 Printer
, Fax
,和 Scanner
是提供特定接口的基类。要创建 OldPrinter
,你只需要继承 Printer
接口。这样,这个类就不会有用不上的方法了。要创建 ModernPrinter
类,你需要继承所有的接口。简单地说,你隔离了 Printer
接口。
这种类设计允许你创建具有不同功能的各种机器,使得你的设计更灵活、拓展性好。
依赖倒置原则(DIP)
**依赖倒置原则(DIP)**是SOLID里的最后一个。这个原则规定:
抽象不该依赖细节。细节应该依赖抽象。
听起来很复杂。这儿有个例子帮你明白这点。比如说你正在创建一个应用程序,有一个 FrontEnd
类,用来把数据友好地展示给用户。这个应用程序目前从数据库获取数据,所以你最终写出了下面代码:
# app_dip.py
class FrontEnd:
def __init__(self, back_end):
self.back_end = back_end
def display_data(self):
data = self.back_end.get_data_from_database()
print("Display data:", data)
class BackEnd:
def get_data_from_database(self):
return "Data from the database"
在这个例子中, FrontEnd
类依赖于 BackEnd
类和它的具体实现。你可以说这两个类紧密耦合在了一起。这种耦合会导致伸缩性上的问题。比如说你的应用程序发展很快,你希望它能从 REST API 获取数据。你会怎么做呢?
你可能想到给 BackEnd
加个新方法来从 REST API 获取数据。然而,这又使得你去修改 FrontEnd
,而根据 open-closed principle (开放封闭原则),它本应对修改封闭。
要修正这个问题,你可以应用依赖倒置原则,使你的类依赖抽象而不是像 BackEnd
这样的具体实现。在这个特定例子中,你可以引入一个 DataSource
类来提供在具体类中使用的接口:
# app_dip.py
from abc import ABC, abstractmethod
class FrontEnd:
def __init__(self, data_source):
self.data_source = data_source
def display_data(self):
data = self.data_source.get_data()
print("Display data:", data)
class DataSource(ABC):
@abstractmethod
def get_data(self):
pass
class Database(DataSource):
def get_data(self):
return "Data from the database"
class API(DataSource):
def get_data(self):
return "Data from the API"
在这个重新设计的版本里,你加入了 DataSource
类作为提供所需接口或者说 .get_data()
方法的抽象类。注意 FrontEnd
现在依赖于 DataSource
提供的抽象接口。
然后你定义了 Database
类,作为从数据库获取数据的情景的具体实现。这个类通过继承依赖于 DataSource
抽象类。最后,你定义了 API
类以支持从 REST API 获取数据。这个类也依赖于 DataSource
抽象类。
现在你可以在代码里这么用 FrontEnd
类:
>>> from app_dip import API, Database, FrontEnd
>>> db_front_end = FrontEnd(Database())
>>> db_front_end.display_data()
Display data: Data from the database
>>> api_front_end = FrontEnd(API())
>>> api_front_end.display_data()
Display data: Data from the API
这里,你先使用一个Database
对象初始化 FrontEnd
,然后又换了一个 API
对象。每次你调用 .display_data()
,结果都依赖于你使用的具体数据源。注意你可也以通过给 FrontEnd
实例重新赋值 .data_source
属性来动态改变数据源。
结语
关于五条SOLID原则,你已经学到了很多,包括如何分辨违反它们的代码以及如何重塑代码来遵守最佳设计实践。你见识到了有关每条原则的正面和反面案例,也学到了在Python里应用SOLID原则能提升你的面向对象设计。
在本篇教程中,你学到了:
- 理解每一条SOLID原则的意义和目的
- 识别违反了部分SOLID原则的Python代码
- 运用SOLID原则来重塑你的Python代码并提升它的设计
有了这些知识,你就对一些确立已久的最佳实践打下了坚实的基础,当你设计Python中的类及其关系时,应该把它们应用其中。通过应用这些原则,你可以创造更易维护、拓展性强、伸缩性强和更易测试的代码。