SOLID 设计原则的 Python 指南
认识我的人都会告诉你,我非常推崇罗伯特·C·马丁(鲍勃大叔)倡导的 SOLID 设计原则。多年来,我在 C#、PHP、Node.js 和 Python 中都运用过这些原则。无论我在哪里运用它们,它们通常都广受欢迎……除了我开始使用 Python 的时候。在代码审查期间,我不断收到诸如“这不是一种非常 Pythonic 的做事方式”之类的评论。当时我刚接触 Python,所以真的不知道该如何回应。我不知道 Pythonic 代码的含义和样子,而且所有给出的解释都不太令人满意。说实话,这让我很恼火。我觉得人们用 Pythonic 的方式逃避编写更规范的代码。从那时起,我就一直致力于证明 SOLID 代码就是 Pythonic 的。这就是最终目标,现在开始我的阐述。
什么是 SOLID 设计
Michael Feathers 因创建助记符 SOLID 而备受赞誉,该助记符基于 Robert C. Martin 的论文《设计原则和设计模式》中的原则。
这些原则包括:
- 单一职责原则
- 开放封闭原则
- 里氏可替代性原则
- 接口隔离原则
- 依赖倒置原则
我们稍后会更详细地介绍这些原则。关于 SOLID 设计原则,最重要的一点是它们应该被整体运用。只选择其中一种并不会对你有多大帮助。只有将这些原则结合起来运用,你才能真正体会到它们的价值。
什么是 Pythonic 方式
尽管对于 Pythonic 方式没有官方定义,但稍微用 Google 搜索一下就能找到几个与此相关的答案。
“上述正确语法的 Pythonic 代码遵循 Python 社区通常接受的约定,并以遵循创始哲学的方式使用该语言。” - Derek D.
我认为最准确的描述来自 Python 之禅。
Python 之禅,作者 Tim Peters
美丽胜过丑陋。
明确胜过隐含。
简单胜过复杂。
复杂胜过繁琐。扁平胜过嵌套。
稀疏胜过密集。 可读性
很重要。 特殊情况不足以特殊到打破规则。 尽管实用性胜过纯粹性。 错误永远不应该默默地过去。 除非明确地被压制。 面对歧义,拒绝猜测的诱惑。 应该有一种——最好只有一种——显而易见的方法。 尽管除非你是荷兰人,否则这种方法一开始可能并不明显。 现在总比没有好。 尽管没有通常比现在好。如果 实现很难解释,那它就是一个坏主意。 如果实现很容易解释,那它可能是一个好主意。 命名空间是一个非常棒的主意——让我们多做些这样的事!
最后一件事
在深入探讨这些原则以及它们与 Python 之禅的关联之前,我想做一件其他 SOLID 教程没有做过的事情。我们不会为每个原则使用不同的代码片段,而是使用同一个代码库,并在讲解每个原则时使其更加符合 SOLID 规范。以下是我们将要开始的代码。
class FTPClient:
def __init__(self, **kwargs):
self._ftp_client = FTPDriver(kwargs['host'], kwargs['port'])
self._sftp_client = SFTPDriver(kwargs['sftp_host'], kwargs['user'], kwargs['pw'])
def upload(self, file:bytes, **kwargs):
is_sftp = kwargs['sftp']
if is_sftp:
with self._sftp_client.Connection() as sftp:
sftp.put(file)
else:
self._ftp_client.upload(file)
def download(self, target:str, **kwargs) -> bytes:
is_sftp = kwargs['sftp']
if is_sftp:
with self._sftp_client.Connection() as sftp:
return sftp.get(target)
else:
return self._ftp_client.download(target)
单一职责原则(SRP)
定义:每个模块/类应该只有一个职责,因此也只有一个改变的原因。
相关禅:做事应该有一个——最好只有一个——显而易见的方法
单一职责原则 (SRP) 的核心在于通过围绕职责组织代码来提升内聚力、降低耦合度。不难理解其背后的原理。如果任何特定职责的所有代码都集中在一个具有内聚力的位置,那么即使职责可能相似,它们通常也不会重叠。不妨考虑一下这个非代码示例。如果你的职责是扫地,我的职责是拖地,那么我没有理由去追踪地板是否已经扫过。我可以直接问你:“地板扫了吗?”,并根据你的回答来采取行动。
我发现将职责视为用例很有用,这就是我们的“禅”发挥作用的方式。每个用例应该只在一个地方处理,从而创建一种显而易见的处理方式。这也满足了 SRP 定义中“一个更改原因”的部分。此类应该更改的唯一原因是用例发生了变化。
检查原始代码,我们发现该类并非单一职责,因为它必须管理 FTP 和 SFTP 服务器的连接详细信息。此外,这些方法甚至并非单一职责,因为它们都必须选择要使用的协议。可以通过将类拆分FTPClient
为两个类,每个类分别承担其中一项职责来解决这个问题。
class FTPClient:
def __init__(self, host, port):
self._client = FTPDriver(host, port)
def upload(self, file:bytes):
self._client.upload(file)
def download(self, target:str) -> bytes:
return self._client.download(target)
class SFTPClient(FTPClient):
def __init__(self, host, user, password):
self._client = SFTPDriver(host, username=user, password=password)
def upload(self, file:bytes):
with self._client.Connection() as sftp:
sftp.put(file)
def download(self, target:str) -> bytes:
with self._client.Connection() as sftp:
return sftp.get(target)
只需简单修改一下,我们的代码就更具 Python 风格了。代码稀疏而不密集,简洁而不复杂,扁平而不嵌套。如果您还没完全理解,不妨想象一下,原始代码加上错误处理后的样子,与遵循 SRP 的代码相比会是什么样子。
开放封闭原则(OCP)
定义:软件实体(类、函数、模块)应该对扩展开放但对更改关闭。
相关禅:简单胜过复杂。复杂胜过繁琐。
由于变更和扩展的定义如此相似,因此很容易被开放封闭原则所淹没。我发现决定是否进行变更或扩展的最直观方法是考虑函数签名。变更是指强制更新调用代码的任何操作。这可能是更改函数名称、交换参数顺序或添加非默认参数。任何调用该函数的代码都将被强制根据新签名进行更改。另一方面,扩展允许添加新功能,而无需更改调用代码。这可以是重命名参数、添加具有默认值的新参数或添加*arg
、 或**kwargs
参数。任何调用该函数的代码仍将按最初编写的方式工作。同样的规则也适用于类。
下面是添加批量操作支持的示例。
你的直觉可能是在类中添加一个upload_bulk
和download_bulk
函数FTPClient
。幸运的是,这也是处理此用例的可靠方法。
class FTPClient:
def __init__(self, host, port):
... # For this example the __init__ implementation is not significant
def upload(self, file:bytes):
... # For this example the upload implementation is not significant
def download(self, target:str) -> bytes:
... # For this example the download implementation is not significant
def upload_bulk(self, files:List[str]):
for file in files:
self.upload(file)
def download_bulk(self, targets:List[str]) -> List[bytes]:
files = []
for target in targets:
files.append(self.download(target))
return files
在这种情况下,最好使用函数扩展类,而不是通过继承进行扩展,因为 BulkFTPClient 子类必须更改下载的函数签名,以反映它返回字节列表而不仅仅是字节,这违反了开放封闭原则以及里氏替代原则。
里氏替代原则(LSP)
定义:如果 S 是 T 的子类型,则类型 T 的对象可以用类型 S 的对象替换。
相关禅:特殊情况还不足以打破规则。
里氏可替换性原则是我在大学里学到的第一个 SOLID 设计原则,也是我唯一学到的。也许这就是为什么它对我来说如此直观。用通俗的英语来说,就是“任何子类都可以在不破坏功能的情况下替换其父类”。
您可能已经注意到,到目前为止,所有 FTP 客户端类都具有相同的函数签名。这样做是有意为之,以便它们遵循里氏可替代性原则。SFTPClient 对象可以替换 FTPClient 对象,而调用upload
或 的任何代码download
都对此毫不知情。
FTP 文件传输的另一个特殊情况是支持 FTPS(没错,FTPS 和 SFTP 是不同的)。解决这个问题可能很棘手,因为我们有很多选择。它们是:
1. 添加upload_secure
、 和download_secure
函数。2
. 通过 添加安全标志**kwargs
。3
. 创建一个FTPSClient
扩展 的新类FTPClient
。
由于我们将在接口隔离和依赖倒置原则中讨论的原因,新的 FTPSClient 类是可行的方法。
class FTPClient:
def __init__(self, host, port):
...
def upload(self, file:bytes):
...
def download(self, target:str) -> bytes:
...
class FTPSClient(FTPClient):
def __init__(self, host, port, username, password):
self._client = FTPSDriver(host, port, user=username, password=password)
这正是继承所针对的边缘情况,遵循里氏多态性原则可以实现有效的多态性。你会注意到,现在FTPClient
可以用FTPSClient
或代替SFTPClient
。事实上,这三个都是可以互换的,这就引出了接口隔离。
接口隔离原则(ISP)
定义:客户端不应该依赖于它不使用的方法。
相关禅:可读性很重要 && 复杂比复杂更好。
与里氏原则不同,接口隔离原则对我来说是最后一个也是最难理解的原则。我总是把它等同于 interface 关键字,而大多数关于 SOLID 设计的解释都无法消除这种困惑。此外,我发现的大多数指南都试图将所有内容分解成微小的接口,通常每个接口只有一个函数,因为“接口太多总比太少好”。
这里有两个问题,首先 Python 没有接口,其次 C# 和 Java 等语言有接口,将它们分解得太开总是会导致接口实现接口,这会变得很复杂,而复杂不是 Pythonic。
首先,我想通过一些 C# 代码来探讨接口过小的问题,然后我们将介绍一种 Python 式的 ISP 方法。如果您同意或只是选择相信我的观点,认为超小接口并非隔离接口的最佳方法,请直接跳到下面的 Python 式解决方案。
# Warning here be C# code
public interface ICanUpload {
void upload(Byte[] file);
}
public interface ICanDownload {
Byte[] download();
}
class FTPClient : ICanUpload, ICanDownload {
public void upload(Byte[] file) {
...
}
public Byte[] download(string target) {
...
}
}
当你需要指定同时实现 ICanDownload 和 ICanUpload 接口的参数类型时,问题就开始了。下面的代码片段演示了这个问题。
class ReportGenerator {
public Byte[] doStuff(Byte[] raw) {
...
}
public void generateReport(/*What type should go here?*/ client) {
raw_data = client.download('client_rundown.csv');
report = this.doStuff(raw_data);
client.upload(report);
}
}
在generateReport
函数签名中,你要么必须指定具体FTPClient
类作为参数类型(这违反了依赖倒置原则),要么创建一个同时实现 ICanUpload 和 ICanDownload 接口的接口。否则,ICanUpload
传入一个只实现了接口的对象可能会导致下载调用失败,反之,传入一个只实现了ICanDownload
接口的对象也会导致下载调用失败。通常的做法是创建一个IFTPClient
接口,并让generateReport
函数依赖于该接口。
public interface IFTPClient: ICanUpload, ICanDownload {
void upload(Byte[] file);
Byte[] download(string target);
}
这样做是可行的,只是我们仍然依赖 FTP 客户端。如果我们想将报告存储在 S3 中怎么办?
Pythonic 解决方案
对我来说,ISP 指的是合理地选择其他开发者如何与你的代码交互。没错,它与 API 和 CLI 中的“I”更相关,而不是 interface 关键字。这也是 Python 之禅中“可读性至上”的驱动力所在。一个好的接口会遵循抽象的语义,并与术语相匹配,从而使代码更具可读性。
让我们看看如何添加一个 S3Client,因为它与 具有相同的上传/下载语义FTPClients
。我们希望保持和 的S3Clients
签名一致,但 new继承就显得有些荒谬了。毕竟,S3 并非 FTP 的特例。FTP 和 S3 的共同点在于它们都是文件传输协议,而且这些协议通常共享类似的接口,如本例所示。因此,与其继承 ,不如将这些类与一个抽象基类绑定在一起,抽象基类是 Python 中最接近接口的东西。upload
download
S3Client
FTPClient
FTPClient
我们创建一个FileTransferClient
作为接口的类,所有现有的客户端现在都继承自该类,而不是继承自FTPClient
。这强制使用通用接口,并允许我们将批量操作移至它们自己的接口中,因为并非所有文件传输协议都支持批量操作。
from abc import ABC
class FileTransferClient(ABC):
def upload(self, file:bytes):
pass
def download(self, target:str) -> bytes:
pass
def cd(self, target_dir):
pass
class BulkFileTransferClient(ABC):
def upload_bulk(self, files:List[bytes]):
pass
def download_bulk(self, targets:List[str]):
pass
但这给我们带来了什么呢……好吧。
class FTPClient(FileTransferClient, BulkFileTransferClient):
...
class FTPSClient(FileTransferClient, BulkFileTransferClient):
...
class SFTPClient(FileTransferClient, BulkFileTransferClient):
...
class S3Client(FileTransferClient):
...
class SCPClient(FileTransferClient):
...
哦天哪!这代码写得真好!我们甚至设法塞进了一个,SCPClient
并将批量操作保留为它们自己的混合宏。所有这些都与依赖注入(依赖倒置原则的一种技术)完美地结合在一起。
依赖倒置原则(DIP)
定义:高级模块不应该依赖于低级模块。它们应该依赖于抽象,抽象不应该依赖于细节,相反,细节应该依赖于抽象。
相关禅:明确优于隐含
这就是将一切联系在一起的关键。我们根据其他 SOLID 原则所做的一切,都是为了达到一个目标:我们不再依赖于某个细节,即用于移动文件的底层文件传输协议。现在,我们可以围绕业务规则编写代码,而无需将它们绑定到特定的实现。我们的代码同时满足依赖倒置的两个要求。
我们的高级模块不再需要依赖像 FTPClient、SFTPClient 或 S3Client 这样的低级模块,而是依赖于 FileTransferClient 这个抽象类。我们依赖的是文件移动的抽象,而不是文件移动的具体细节。
我们的抽象 FileTransferClient 不依赖于协议特定的细节,相反,这些细节取决于如何通过抽象使用它们(即可以上传或下载文件)。
这是依赖倒置工作的一个示例。
def exchange(client:FileTransferClient, to_upload:bytes, to_download:str) -> bytes:
client.upload(to_upload)
return client.download(to_download)
if __name__ == '__main__':
ftp = FTPClient('ftp.host.com')
sftp = FTPSClient('sftp.host.com', 22)
ftps = SFTPClient('ftps.host.com', 990, 'ftps_user', 'P@ssw0rd1!')
s3 = S3Client('ftp.host.com')
scp = SCPClient('ftp.host.com')
for client in [ftp, sftp, ftps, s3, scp]:
exchange(client, b'Hello', 'greeting.txt')
结论
你已经拥有了一个 SOLID 实现,而且它非常 Pythonic。如果你之前没有接触过 SOLID,我希望你至少已经熟悉了它。对于那些正在学习 Python 并且不确定如何继续编写 SOLID 代码的人来说,这篇文章很有帮助。当然,这是一个精心挑选的例子,我知道它能够支持我的论点,但在撰写本文时,我仍然对它的变化之大感到惊讶。并非所有问题都符合这种精确的细分,但我已尽力在
我的决策背后包含足够的理由,以便你将来可以选择最 SOLID 和 Pythonic 的实现。
如果您不同意或需要任何澄清,请发表评论或在 Twitter 上@d3r3kdrumm0nd。
文章来源:https://dev.to/ezzy1337/a-pythonic-guide-to-solid-design-principles-4c8i