这是一本关于用 Python 构建软件的书。好的软件是从好的设计中构建出来的。通过说诸如干净代码之类的话,您可能会认为我们将探索只与软件的实现细节相关的良好实践,而不是其设计。然而,这种假设是错误的,因为代码与设计没有什么不同,代码就是设计。
代码可能是设计的最详细表示。在前两章中,我们讨论了为什么以一致的方式构建代码很重要,并且我们已经看到了编写更紧凑、更习惯的代码的习惯用法。现在是时候理解干净的代码了,更重要的是,最终目标是使代码尽可能健壮,并以一种最小化缺陷或使缺陷完全明显的方式编写代码(如果出现)。
本章,以及接下来的一章,重点是更高抽象层次上的设计原则。我将介绍在 Python 中应用的软件工程的一般原则。
特别是在本章中,我们将回顾有助于良好软件设计的不同原则。高质量的软件应该围绕这些想法来构建,它们将作为设计工具。这并不意味着所有这些原则都应该始终适用;事实上,它们中的一些代表了不同的观点(例如,与防御性编程相反的合同设计(DbC)方法)。其中一些取决于上下文,并不总是适用的。
高质量代码是一个具有多个维度的概念。我们可以将其与我们对软件体系结构的质量属性的思考类似。例如,我们希望我们的软件是安全的,并且具有良好的性能、可靠性和可维护性,仅举几个属性。
本章的目标如下:
- 理解健壮软件背后的概念
- 了解如何在应用程序工作流期间处理错误数据
- 设计易于扩展和适应新需求的可维护软件
- 设计可重用软件
- 编写有效的代码以保持开发团队的高生产率
我们正在开发的软件的某些部分不是由用户直接调用的,而是由代码的其他部分调用的。当我们将应用程序的责任划分为不同的组件或层时,我们必须考虑它们之间的交互。
我们必须在每个组件后面封装一些功能,并向将要使用该功能的客户端公开一个接口,即应用程序编程接口(API)。我们为该组件编写的函数、类或方法在某些考虑因素下具有特定的工作方式,如果不满足这些考虑因素,将导致代码崩溃。相反,调用该代码的客户机需要一个特定的响应,如果我们的函数未能提供该响应,则表示存在缺陷。
也就是说,例如,如果我们有一个函数,该函数预期使用一系列整数类型的参数,而另一个函数通过传递字符串来调用我们的函数,那么很明显,它不应按预期工作,但实际上,该函数根本不应运行,因为它被错误地调用(客户端出错)。此错误不应以静默方式传递。
当然,在设计 API 时,应该记录预期的输入、输出和副作用。但是文档不能在运行时强制执行软件的行为。这些规则,代码的每一部分为了正常工作所期望的,以及调用者期望从中得到的,应该是设计的一部分,这里是合同的概念产生的地方。
DbC 方法背后的理念是,双方不是将各方的期望隐含在代码中,而是就一项合同达成一致,如果违反该合同,将产生一个例外,明确说明其无法继续的原因。
在我们的上下文中,合同是一种执行某些规则的构造,这些规则在软件组件的通信过程中必须遵守。合同主要包含先决条件和后决条件,但在某些情况下,还描述了不变量和副作用:
- 先决条件:我们可以说这些都是代码在运行之前要执行的检查。它将检查在功能继续之前必须达到的所有条件。一般来说,它是通过验证在传递的参数中提供的数据集来实现的,但是如果我们认为它们的副作用被这种验证的重要性遮蔽了,任何东西都不应该阻止我们运行各种验证(例如,验证数据库中的集合、文件或以前调用的另一个方法)。请注意,这对调用者施加了约束。
- 后条件:与先决条件相反,这里是在返回函数调用后进行验证。运行后置条件验证以验证调用方对此组件的期望。
- 不变量:或者,最好在函数的 docstring 中记录不变量,即在函数代码运行时保持不变的内容,作为函数逻辑的正确表达式。
- 副作用:或者,我们可以在文档字符串中提及我们的代码的任何副作用。
虽然从概念上讲,所有这些项目构成软件组件合同的一部分,这是此类项目的文档中应该包含的内容,但只有前两项(先决条件和后决条件)在较低的级别(代码)强制执行。
我们之所以会按合同设计,是因为如果出现错误,它们必须很容易发现(通过注意是先决条件还是后决条件失败,我们将更容易找到罪魁祸首),以便能够快速纠正错误。更重要的是,我们希望代码的关键部分避免在错误的假设下执行。这应该有助于清楚地标记责任和错误(如果发生)的限制,而不是说应用程序的这一部分失败了。但是调用方代码提供了错误的参数,那么我们应该在哪里应用修复程序呢?
其思想是先决条件约束客户机(如果他们想运行代码的某个部分,他们有义务满足这些先决条件),而后决条件约束组件,使其与客户机可以验证和执行的某些保证相关。
这样,我们可以快速确定责任。如果前提条件失败,我们知道这是由于客户端的缺陷造成的。另一方面,如果后条件检查失败,我们知道问题出在例程或类(供应商)本身。
具体地说,关于先决条件,重要的是要强调它们可以在运行时检查,如果它们发生,则根本不应该运行正在调用的代码(运行它没有意义,因为它的条件不成立,这样做可能会使事情变得更糟)。
先决条件是函数或方法为了正确工作而期望得到的所有保证。在一般编程术语中,这通常意味着提供正确格式的数据,例如,初始化的对象、非空值等。特别是对于动态类型的 Python,这也意味着有时我们需要检查所提供的数据的确切类型。这与类型检查不完全相同,mypy
类将执行此操作,而是验证所需的确切值。
这些检查的一部分可以在早期通过使用静态分析工具进行检测,例如mypy
,我们已经在第 1 章、简介、代码格式和工具中介绍了这些工具,但这些检查还不够。函数应该对要处理的信息进行适当的验证。
现在,这就提出了将验证逻辑放在何处的问题,这取决于我们是让客户端在调用函数之前验证所有数据,还是让客户端在运行自己的逻辑之前验证它接收到的所有数据。前者相当于一种宽容的方法(因为函数本身仍然允许任何数据,也可能是格式不正确的数据),而后者相当于一种苛刻的方法。
出于本分析的目的,当涉及到 DbC 时,我们更喜欢一种苛刻的方法,因为它通常是健壮性方面最安全的选择,并且通常是行业中最常见的做法。
无论我们决定采取何种方式,我们都应始终牢记非冗余原则,该原则规定,履行某项职能的每个先决条件只能由合同的两个部分中的一个部分执行,而不能同时由两个部分执行。这意味着我们将验证逻辑放在客户机上,或者将其留给函数本身,但在任何情况下都不应复制它(这也与 DRY 原则有关,我们将在本章后面讨论)。
后置条件是合同的一部分,负责在方法或功能返回后强制执行状态。
假设调用函数或方法时具有正确的属性(即满足其先决条件),则后置条件将保证保留某些属性。
其想法是使用后置条件来检查和验证客户可能需要的一切。如果方法正确执行,并且后条件验证通过,那么任何调用该代码的客户机都应该能够处理返回的对象而不会出现问题,因为契约已经履行。
在写这本书的时刻,一个名为的 Python 合同编程的 PEP-316 被推迟。这并不意味着我们不能用 Python 实现它,因为正如本章开头介绍的,这是一个通用的设计原则。
实施这一点的最佳方式可能是向方法、函数和类添加控制机制,如果它们失败,则引发RuntimeError
异常或ValueError
。很难为正确的异常类型设计一个通用规则,因为这在很大程度上取决于具体的应用程序。前面提到的这些异常是最常见的异常类型,但是如果它们不能准确地适应问题,那么创建自定义异常将是最佳选择。
我们还希望尽可能地隔离代码。也就是说,一部分中的前置条件代码,另一部分中的后置条件代码,以及分离的函数核心。我们可以通过创建更小的函数来实现这种分离,但在某些情况下,实现装饰器将是一种有趣的选择。
该设计原则的主要价值在于有效识别问题所在。通过定义契约,当某个东西在运行时失败时,可以清楚地知道代码的哪些部分被破坏了,哪些部分破坏了契约。
由于遵循这一原则,代码将更加健壮。每个组件都在执行自己的约束并维护一些不变量,只要保留这些不变量,程序就可以被证明是正确的。
这也有助于更好地澄清计划的结构。这些契约没有试图运行特别的验证,也没有试图克服所有可能的失败场景,而是明确指定每个函数或方法期望正常工作的内容,以及期望从中得到的内容。
当然,遵循这些原则也会增加额外的工作,因为我们不仅要编写主应用程序的核心逻辑,还要编写契约。此外,我们还需要考虑为这些合同添加单元测试。然而,从长远来看,这种方法所获得的质量是值得的;因此,为应用程序的关键组件实现这一原则是一个好主意。
尽管如此,为了使这种方法有效,我们应该仔细考虑我们愿意验证的内容,这必须是一个有意义的值。例如,定义只检查提供给函数的参数的正确数据类型的契约没有多大意义。许多程序员会争辩说,这就像试图使 Python 成为一种静态类型的语言。尽管如此,像mypy
这样的工具,结合注释的使用,将更好地达到这一目的,而且花费更少的精力。考虑到这一点,设计契约以使其具有实际价值,例如检查传递和返回的对象的属性、它们必须遵守的条件等等。
防御性编程采用了与 DbC 稍有不同的方法。与其说明合同中必须包含的所有条件(如果未满足这些条件,将引发异常并导致程序失败),不如让代码的所有部分(对象、函数或方法)能够保护自己免受无效输入的影响。
防御性编程是一种具有多个方面的技术,如果它与其他设计原则相结合,则特别有用(这意味着它遵循与 DbC 不同的理念,这并不意味着它是一种或另一种情况,也可能意味着它们可以相互补充)。
防御性编程主题的主要思想是如何处理我们可能会发生的场景中的错误,以及如何处理不应该发生的错误(当不可能的情况发生时)。前者属于错误处理过程,而后者属于断言。以下各节将探讨这两个主题。
在我们的程序中,对于我们预计可能导致错误的情况,我们采用错误处理程序。这通常是数据输入的情况。
错误处理背后的思想是优雅地响应这些预期的错误,试图继续我们的程序执行,或者在错误无法克服时决定失败。
我们可以用不同的方法来处理程序中的错误,但并非所有方法都适用。其中一些方法如下:
- 价值替代
- 错误记录
- 异常处理
在接下来的两部分中,我们将重点讨论值替换和异常处理,因为这些形式的错误处理提供了更有趣的分析。错误日志记录是一种补充做法(也是一种很好的做法;我们应该始终记录错误),但大多数时候我们只在没有其他事情要做时才记录,因此其他方法提供了更有趣的替代方法。
在某些情况下,当出现错误并且存在软件产生错误值或完全失败的风险时,我们可能能够用另一个更安全的值替换结果。我们称之为值替换,因为事实上,我们正在将实际错误结果替换为被视为无中断的值(它可以是默认值、众所周知的常量、哨兵值,或者只是一些根本不影响结果的东西,例如在结果要应用于求和的情况下返回零)。
然而,价值替代并不总是可能的。对于替代值是安全选项的情况,必须仔细选择此策略。做出这个决定是稳健性和正确性之间的权衡。软件程序即使在出现错误的情况下也不会失败,因此是健壮的。但这也不正确。
对于某些类型的软件来说,这可能是不可接受的。如果应用程序是关键的,或者正在处理的数据过于敏感,这不是一个选项,因为我们无法向用户(或应用程序的其他部分)提供错误的结果。在这些情况下,我们选择正确性,而不是让程序在产生错误结果时爆炸。
这个决定的一个稍有不同且更安全的版本是对未提供的数据使用默认值。对于可以使用默认行为的部分代码,例如,未设置的环境变量的默认值、配置文件中缺少的条目或函数的参数,都可能出现这种情况。
我们可以在 Python 的 API 的不同方法中找到支持这一点的 Python 示例,例如,字典有一个get
方法,其(可选)第二个参数允许您指示默认值:
>>> configuration = {"dbport": 5432}
>>> configuration.get("dbhost", "localhost")
'localhost'
>>> configuration.get("dbport")
5432
环境变量具有类似的 API:
>>> import os
>>> os.getenv("DBHOST")
'localhost'
>>> os.getenv("DPORT", 5432)
5432
在前面的两个示例中,如果没有提供第二个参数,将返回None
,因为它是这些函数定义时使用的默认值。我们还可以为自己函数的参数定义默认值:
>>> def connect_database(host="localhost", port=5432):
... logger.info("connecting to database server at %s:%i", host, port)
一般来说,用默认值替换缺少的参数是可以接受的,但用合法的关闭值替换错误数据更危险,并且可能掩盖某些错误。在决定此方法时,请考虑此标准。
在存在不正确或缺失输入数据的情况下,有时可以通过一些示例来纠正这种情况,如前一节中提到的示例。然而,在其他情况下,与其让程序在错误的假设下运行,不如阻止程序继续使用错误的数据运行。在这些情况下,失败并通知调用者出了问题是一种很好的方法,这是违反先决条件的情况,正如我们在 DbC 中看到的那样。
尽管如此,错误的输入数据并不是函数出错的唯一可能方式。毕竟,函数不仅仅是传递数据;它们也有副作用并连接到外部组件。
函数调用中的故障可能是由于这些外部组件中的一个出现问题,而不是函数本身。如果是这样的话,我们的职能部门应该对此进行适当沟通。这将使调试更容易。函数应该清楚、明确地通知应用程序的其余部分有关不能忽略的错误,以便可以相应地解决这些错误。
实现这一点的机制是一个例外。重要的是要强调,这是异常应该用来清楚地宣布异常情况,而不是根据业务逻辑改变程序流程。
如果代码试图使用异常来处理预期的场景或业务逻辑,那么程序流将变得更难读取。这将导致一种情况,即异常被用作一种go-to
语句,这种语句(更糟糕的是)可能跨越调用堆栈上的多个级别(直到调用方函数),违反了将逻辑封装到正确抽象级别的要求。如果这些except
块将业务逻辑与代码试图防御的真正异常情况混合在一起,情况可能会变得更糟;在这种情况下,将很难区分我们必须维护的核心逻辑和要处理的错误。
不要将异常用作业务逻辑的go-to
机制。当调用方需要注意的代码有问题时引发异常。
最后一个概念很重要;例外情况通常是通知打电话的人有什么问题。这意味着应该谨慎使用异常,因为它们会削弱封装。一个函数的异常越多,调用方函数就必须预测的越多,从而知道它调用的函数。如果一个函数引发了太多的异常,这意味着它不是上下文无关的,因为每次我们想要调用它时,我们都必须记住它所有可能的副作用。
这可以作为一种启发式方法,用于判断函数的内聚性是否充分以及职责是否过多。如果它引发了太多的异常,这可能是一个信号,它必须被分解成多个更小的异常。
下面是一些与 Python 中的异常相关的建议。
例外情况是也是主要功能的一部分,只做一件事。函数正在处理(或引发)的异常必须与封装在其上的逻辑一致。
在下面的示例中,我们可以看到混合不同层次的抽象意味着什么。设想一个对象在我们的应用程序中充当某些数据的传输。它连接到一个外部组件,解码后数据将被发送到该组件。在下面的清单中,我们将重点介绍deliver_event
方法:
class DataTransport:
"""An example of an object handling exceptions of different levels."""
_RETRY_BACKOFF: int = 5
_RETRY_TIMES: int = 3
def __init__(self, connector: Connector) -> None:
self._connector = connector
self.connection = None
def deliver_event(self, event: Event):
try:
self.connect()
data = event.decode()
self.send(data)
except ConnectionError as e:
logger.info("connection error detected: %s", e)
raise
except ValueError as e:
logger.error("%r contains incorrect data: %s", event, e)
raise
def connect(self):
for _ in range(self._RETRY_TIMES):
try:
self.connection = self._connector.connect()
except ConnectionError as e:
logger.info(
"%s: attempting new connection in %is", e, self._RETRY_BACKOFF,
)
time.sleep(self._RETRY_BACKOFF)
else:
return self.connection
raise ConnectionError(f"Couldn't connect after {self._RETRY_TIMES} times")
def send(self, data: bytes):
return self.connection.send(data)
对于我们的分析,让我们放大并关注deliver_event()
方法如何处理异常。
与ConnectionError
有什么关系?不多通过观察这两种截然不同的错误类型,我们可以了解责任应该如何划分。
ConnectionError
应在connect
方法内处理。这允许行为的明确分离。例如,如果此方法需要支持重试,则处理所述异常将是一种方法。
相反,ValueError
属于事件的decode
方法。对于这个新的实现(在下一个示例中显示),这个方法不需要捕获任何异常——我们之前担心的异常要么由内部方法处理,要么故意留下来引发。
我们应该将这些片段分成不同的方法或函数。对于连接管理,一个小功能就足够了。此功能将负责尝试建立连接,捕获异常(如果发生),并相应地记录它们:
def connect_with_retry(connector: Connector, retry_n_times: int, retry_backoff: int = 5):
"""Tries to establish the connection of <connector> retrying
<retry_n_times>, and waiting <retry_backoff> seconds between attempts.
If it can connect, returns the connection object.
If it's not possible to connect after the retries have been exhausted, raises ``ConnectionError``.
:param connector: An object with a ``.connect()`` method.
:param retry_n_times int: The number of times to try to call
``connector.connect()``.
:param retry_backoff int: The time lapse between retry calls.
"""
for _ in range(retry_n_times):
try:
return connector.connect()
except ConnectionError as e:
logger.info("%s: attempting new connection in %is", e, retry_backoff)
time.sleep(retry_backoff)
exc = ConnectionError(f"Couldn't connect after {retry_n_times} times")
logger.exception(exc)
raise exc
然后,我们将在我们的方法中调用此函数。对于事件上的ValueError
异常,我们可以用一个新对象将其分离并进行合成,但对于这种有限的情况,这将是过分的,因此仅将逻辑移动到一个单独的方法就足够了。考虑到这两个因素,新版本的方法看起来更紧凑,更易于阅读:
class DataTransport:
"""An example of an object that separates the exception handling by
abstraction levels.
"""
_RETRY_BACKOFF: int = 5
_RETRY_TIMES: int = 3
def __init__(self, connector: Connector) -> None:
self._connector = connector
self.connection = None
def deliver_event(self, event: Event):
self.connection = connect_with_retry(self._connector, self._RETRY_TIMES, self._RETRY_BACKOFF)
self.send(event)
def send(self, event: Event):
try:
return self.connection.send(event.decode())
except ValueError as e:
logger.error("%r contains incorrect data: %s", event, e)
raise
现在看看异常类的分离是如何界定职责的分离的。在显示的第一个示例中,所有内容都是混合的,并且没有明确的关注点分离。然后我们决定连接本身是一个关注点,因此在下一个示例中,创建了connect_with_retry
函数,并且ConnectionError
作为该函数的一部分进行处理,如果我们需要修改该函数(如我们所做的)。另一方面,ValueError
不是同一逻辑的一部分,因此它被留在了它所属的send
方法中。
例外情况具有某种意义。因此,在适当的抽象级别处理每种类型的异常非常重要(这意味着,取决于它们所属的应用程序层)。但它们有时也会携带重要信息。由于这些信息可能很敏感,我们不希望它落入坏人之手,因此在下一节中,我们将讨论异常的安全含义。
这是出于安全考虑。在处理异常时,如果错误太重要,允许它们传播是可以接受的,如果这是特定场景的决定,并且正确性优于健壮性,甚至可能让程序失败。
当出现表示问题的异常时,务必尽可能详细地登录(包括回溯信息、消息和我们可以收集到的所有信息),以便有效地纠正问题。同时,我们希望为自己提供尽可能多的细节,我们不希望任何细节对用户可见。
在 Python 中,异常的回溯包含非常丰富和有用的调试信息。不幸的是,这些信息对于想要尝试破坏应用程序的攻击者或恶意用户也非常有用,更不用说泄漏将代表一次重要的信息泄露,危及组织的知识产权(因为部分代码将被暴露)。
如果您选择让异常传播,请确保不要泄露任何敏感信息。此外,如果您必须通知用户某个问题,请选择通用消息(如Something went wrong
或Page not found
)。这是在发生 HTTP 错误时显示一般信息消息的 web 应用程序中使用的常用技术。
这甚至被称为最邪恶的 Python 反模式(REAL 01)。虽然预测并保护我们的程序不受某些错误的影响是好的,但过于保守可能会导致更糟糕的问题。特别是,过于防守的唯一问题是,有一个空的except
块,它什么也不做就默默地通过。
Python 非常灵活,它允许我们编写可能出错但不会引发错误的代码,如下所示:
try:
process_data()
except:
pass
问题是它永远不会失败,即使它应该失败。如果您从 Python 的禅宗中记住错误永远不应该悄无声息地传递,那么它也是非 Python 的。
配置您的持续集成环境(通过使用第 1 章、简介、代码格式和工具中介绍的工具,自动报告空异常块。
在发生异常的情况下,这段代码不会失败,这可能是我们首先想要的。但是如果有缺陷呢?当process_data()
函数运行时,可能会发生实际故障,我们想知道我们的逻辑中是否有错误,以便能够纠正它。编写这样的代码块会掩盖问题,使事情更难维护。
有两种选择:
- 捕获一个更具体的异常(不太广泛,例如
Exception
)。事实上,一些 linting 工具和 ide 在某些情况下会在代码处理太广泛的异常时向您发出警告。 - 在
except
块上执行一些实际的错误处理。
最好的做法是应用这两项建议。处理一个更具体的异常(例如,AttributeError
或KeyError
将使程序更易于维护,因为读者将知道会发生什么,并且可以了解它的原因。它还将允许引发其他异常,如果发生这种情况,这可能意味着一个 bug,只有这一次可以发现它。
处理异常本身可能意味着很多事情。在其最简单的形式中,它可能只是记录异常(确保使用logger.exception
或logger.error
提供所发生事件的完整上下文)。其他替代方法可以是返回默认值(替换,仅在本例中是在检测到错误之后,而不是在导致错误之前),或者引发不同的异常。
如果选择引发其他异常,请包括导致问题的原始异常(请参阅下一节)。
避免拥有空except
块(使用pass
的另一个原因是它的隐含性:它不会告诉代码读者我们实际上希望忽略该异常。一种更明确的方法是使用contextlib.suppress
函数,它可以接受所有异常作为要忽略的参数,并且可以用作上下文管理器。
在我们的示例中,它可能如下所示:
import contextlib
with contextlib.suppress(KeyError):
process_data()
同样,与前一种情况一样,尽量避免将常规Exception
传递给此上下文管理器,因为效果是相同的。
作为错误处理逻辑的一部分,我们可能决定提出一个不同的错误处理逻辑,甚至可能更改它的消息。如果是这种情况,建议包括导致这种情况的原始异常。
我们可以使用raise <e> from <original_exception>
语法(PEP-3134)。使用此构造时,原始回溯将嵌入到新异常中,原始异常将被设置在结果异常的__cause__
属性中。
例如,如果我们希望在项目内部用自定义异常包装默认异常,我们仍然可以这样做,同时包含有关根异常的信息:
class InternalDataError(Exception):
"""An exception with the data of our domain problem."""
def process(data_dictionary, record_id):
try:
return data_dictionary[record_id]
except KeyError as e:
raise InternalDataError("Record not present") from e
更改异常类型时始终使用raise <e> from <o>
语法。
使用此语法将使回溯包含有关异常或刚刚发生的错误的更多信息,这将在调试时有很大帮助。
断言用于不应该发生的情况,因此assert
语句中的表达式必须表示不可能发生的情况。如果出现这种情况,则表示软件存在缺陷。
与错误处理方法不同,在某些情况下,如果发生特定错误,我们不希望程序继续执行。这是因为,在某些情况下,错误无法克服,并且我们的程序无法纠正其执行过程(或自愈),因此最好快速失败,并让错误被注意到,以便在下一次版本升级时纠正错误。
使用断言的想法是防止程序在出现这种无效场景时造成进一步的损害。有时,与其让程序在错误的假设下继续处理,不如停止并让程序崩溃。
根据定义,断言是代码中的一个布尔条件,必须为 true 才能使程序正确。如果程序因为AssertionError
而失败,则表示缺陷刚刚被发现。
因此,断言不应与业务逻辑混合使用,或用作软件的控制流机制。以下示例是一个坏主意:
try:
assert condition.holds(), "Condition is not satisfied"
except AssertionError:
alternative_procedure()
不要捕获AssertionError
异常,因为它可能会让代码的读者感到困惑。如果您希望代码的某些部分引发异常,请尝试使用更具体的异常。
之前关于捕捉AssertionError
的建议是,不要让程序默默地失败。但它可能会优雅地失败。因此,您可以捕获AssertionError
并显示一般错误消息,同时将所有内部错误详细信息记录到公司的日志平台,而不是让应用程序发生硬崩溃。关键不在于是否捕获此异常,而是断言错误是有价值的信息源,可以帮助您提高软件质量。
确保程序在断言失败时终止。这意味着断言通常放在代码中以识别程序的错误部分。许多编程语言倾向于认为,当程序在生产环境中运行时,断言可以被抑制,但这违背了它的目的,因为它们的目的是让我们准确地知道程序中需要修复的部分。
特别是在 Python 中,使用–O
标志运行将抑制assert
语句,但由于上述原因,不鼓励这样做。
不要使用python –O
运行生产程序,因为您希望利用代码中的断言来纠正缺陷。
在断言语句中包含描述性错误消息,并记录错误,以确保以后可以正确调试和更正问题。
前面的代码不是好主意的另一个重要原因是,除了捕获AssertionError
之外,断言中的语句是一个函数调用。函数调用可能有副作用,而且它们并不总是可重复的(我们不知道再次调用condition.holds()
是否会产生相同的结果)。此外,如果我们在该行停止调试器,我们可能无法方便地看到导致错误的结果,而且,同样,即使我们再次调用该函数,我们也不知道这是否是有问题的值。
更好的替代方案需要更多几行代码,但提供了更有用的信息:
result = condition.holds()
assert result > 0, f"Error with {result}"
使用断言时,尽量避免直接使用函数调用,并使用局部变量编写表达式。
断言和异常处理之间的关系是什么?有些人可能会问,根据异常处理,断言是否没有意义。如果我们可以用if
语句检查并引发异常,为什么要断言条件?不过,有一个微妙的区别。一般来说,异常是为了处理与我们的程序将要考虑的业务逻辑相关的意想不到的情况,而断言类似于代码中的自检机制,以验证(断言)其正确性。
因此,异常引发将比有assert
语句更常见。assert
的典型用法是,算法维护一个必须始终保持的不变逻辑:在这种情况下,您可能希望为该不变逻辑断言。如果这一点在某一点上被打破,则意味着要么算法错误,要么执行不力。
我们探讨了 Python 中的防御编程,以及一些有关异常处理的相关主题。现在,我们进入下一个大主题,下一节将讨论关注点的分离。
这是一个应用于多个层面的设计原则。它不仅仅是关于低层次的设计(代码),它还与更高层次的抽象相关,所以稍后我们讨论架构时会提到它。
应用程序的不同组件、层或模块应承担不同的责任。程序的每个部分应该只负责一部分功能(我们称之为它的关注点),而不应该知道其余部分。
分离软件中的关注点的目标是通过最小化连锁反应来增强可维护性。涟漪效应表示软件中的变化从一个起点开始传播。这可能是一个错误或异常触发了一系列其他异常,导致故障,从而导致应用程序的远程部分出现缺陷。也可能是由于函数定义中的一个简单更改,我们不得不更改大量分散在代码库多个部分的代码。
显然,我们不希望这些情况发生。软件必须易于更改。如果我们必须修改或重构部分代码,这必须对应用程序的其余部分产生最小的影响,实现这一点的方法是通过适当的封装。
以类似的方式,我们希望任何潜在的错误都得到控制,这样它们就不会造成重大损害。
这一概念与 DbC 原则相关,即每个关注点都可以通过合同强制执行。当合同被违反,并且由于这种违反而引发异常时,我们知道程序的哪个部分出现了故障,以及哪些责任没有得到满足。
尽管存在这种相似性,但关注点的分离更进一步。我们通常会想到函数、方法或类之间的契约,虽然这也适用于必须分离的责任,但分离关注点的思想也适用于 Python 模块、包,以及基本上任何软件组件。
这些是优秀软件设计的重要概念。
一方面,cohesion
意味着对象应该有一个小而明确的目的,并且应该尽可能少地做。它遵循与 Unix 命令类似的原理,Unix 命令只做一件事,而且做得很好。我们的对象越具有内聚性,它们就越有用和可重用,从而使我们的设计更好。
另一方面,coupling
指两个或多个对象如何相互依赖的概念。这种依赖性带来了一个限制。如果代码的两个部分(对象或方法)彼此过于依赖,它们会带来一些不希望出现的后果:
- 无代码重用:如果一个函数太依赖于某个特定对象,或者需要太多参数,那么它与该对象耦合,这意味着在不同的上下文中使用该函数将非常困难(为此,我们必须找到一个符合非常严格的接口的合适参数)。
- 涟漪效应:两部分中的一部分的变化肯定会影响到另一部分,因为它们太接近了。
- 低抽象层次:当两个功能如此密切相关时,很难将它们视为不同的关注点,在不同的抽象层次上解决问题。
经验法则:定义良好的软件将实现高内聚和低耦合。
在本节中,我们将回顾一些产生一些好的设计思想的原则。重点是通过易于记忆的首字母缩略词快速与良好的软件实践联系起来,作为一种记忆规则。如果您记住这些话,您将能够更容易地将它们与良好实践联系起来,并且在您正在查看的特定代码行后面找到正确的想法将更快。
这些并不是正式的或学术性的定义,更像是在软件行业工作多年后产生的经验性想法。其中一些确实出现在书中,因为它们是由重要的作者创造的(请参阅参考资料以更详细地调查它们),而其他的可能来源于博客文章、论文或会议演讲。
不要重复自己(干燥和一次且仅一次的(OAOO的想法是密切相关的,所以它们一起包含在这里。它们是不言自明的,你应该不惜一切代价避免重复。
代码中的东西,知识,只需要在一个地方定义一次。当您必须更改代码时,应该只有一个合法的位置可以修改。如果不这样做,则表明系统设计不当。
代码重复是一个直接影响可维护性的问题。代码重复是非常不可取的,因为它会带来许多负面后果:
- 它容易出错:当某些逻辑在整个代码中重复多次,并且需要更改时,这意味着我们依赖于使用该逻辑有效地更正所有实例,而不会忘记其中任何一个,因为在这种情况下,会出现错误。
- 代价高昂:与上一点相关,在多个地方进行更改比只定义一次要花费更多的时间(开发和测试工作)。这会减慢团队的速度。
- 不可靠:也与第一点相关,当上下文中的一次更改需要更改多个位置时,您需要依赖编写代码的人记住所有需要进行修改的实例。真理没有单一的来源。
重复通常是由于忽略(或忘记)代码代表知识而导致的。通过赋予代码的某些部分意义,我们可以识别和标记这些知识。
让我们通过一个例子来了解这意味着什么。想象一下,在一个学习中心,学生的排名标准如下:通过一次考试得 11 分,不通过一次考试得 5 分,在学校里每年得 2 分。以下不是实际的代码,只是表示这些代码如何分散在实际的代码库中:
def process_students_list(students):
# do some processing...
students_ranking = sorted(
students, key=lambda s: s.passed * 11 - s.failed * 5 - s.years * 2
)
# more processing
for student in students_ranking:
print(
"Name: {0}, Score: {1}".format(
student.name,
(student.passed * 11 - student.failed * 5 - student.years * 2),
)
)
请注意,排序函数的键中的 lambda 如何表示域问题中的一些有效知识,但它并没有反映它(它没有名称、正确的位置,没有为该代码指定任何含义,什么都没有)。代码中的这种缺乏意义导致在列出耙时打印出分数时出现重复。
我们应该在代码中反映我们对领域问题的了解,这样我们的代码就不太可能出现重复,也更容易理解:
def score_for_student(student):
return student.passed * 11 - student.failed * 5 - student.years * 2
def process_students_list(students):
# do some processing...
students_ranking = sorted(students, key=score_for_student)
# more processing
for student in students_ranking:
print(
"Name: {0}, Score: {1}".format(
student.name, score_for_student(student)
)
)
一个公平的免责声明:这只是对代码复制特性之一的分析。实际上,代码重复的情况、类型和分类法更多。整个章节都可以专门讨论这个主题,但这里我们将重点放在一个特定的方面,以明确首字母缩略词背后的概念。
在本例中,我们采用了消除重复的最简单方法:创建函数。根据具体情况,最佳解决方案可能会有所不同。在某些情况下,可能需要创建一个全新的对象(可能缺少整个抽象)。在其他情况下,我们可以使用上下文管理器消除重复。迭代器或生成器(在第 7 章中描述,生成器、迭代器和异步编程也可以帮助避免代码中的重复,而装饰器(在第 5 章中解释,使用装饰器改进我们的代码也会有所帮助。
不幸的是,在解决代码重复问题时,没有通用的规则或模式可以告诉您 Python 的哪些特性最适合,但希望在看到本书中的示例以及 Python 元素的使用方式后,读者能够发展自己的直觉。
雅格尼(简称您不需要它)是一个想法,如果您不想过度设计,在编写解决方案时,您可能需要经常记住。
我们希望能够轻松地修改我们的程序,所以我们想让它们成为未来的证明。与此一致,许多开发人员认为他们必须预测所有未来的需求并创建非常复杂的解决方案,因此创建难以阅读、维护和理解的抽象。过了一段时间,事实证明,这些预期的需求并没有出现,或者它们以不同的方式出现了(令人惊讶!),而原本应该精确处理的原始代码也不起作用。
问题是,现在重构和扩展我们的程序更加困难了。发生的事情是,最初的解决方案没有正确地处理最初的需求,当前的需求也没有正确地处理,这仅仅是因为它是错误的抽象。
拥有可维护的软件并不是为了预测未来的需求(不要做未来学!)。它是关于编写只满足当前需求的软件,以便以后可以(并且很容易)进行更改。换句话说,在设计时,要确保你的决定不会束缚你,你可以继续建设,但不要超出需要。
在某些情况下,如果我们知道一些我们认为可能适用或为我们节省时间的原则,通常不遵循这个想法是很有诱惑力的。例如,在本书的后面,我们将回顾设计模式,它们是面向对象设计的典型情况的常见解决方案。虽然研究设计模式很重要,但我们必须拒绝过早应用它们的诱惑,因为这可能会违反雅格尼原则。
例如,假设您正在创建一个类来封装组件的行为。您知道这是需要的,但是您认为将来会有更多(和类似的)需求,因此创建基类(比如用必须实现的方法定义一个接口)并使类成为您刚刚创建的实现该接口的子类是很有诱惑力的。这是错误的,原因有几个。首先,您现在所需要的只是最初创建的类(投入更多时间过度概括我们不知道需要的解决方案不是管理资源的好方法)。然后,该基类会受到当前需求的影响,因此它可能不是正确的抽象。
最好的方法是只写现在需要的内容,而不妨碍进一步的改进。如果以后出现更多的需求,我们可以考虑创建一个基类,抽象一些方法,也许我们会发现为我们的解决方案出现的设计模式。这也是面向对象设计的工作方式:自下而上。
最后,我想强调的是,YAGNI 是一个同样适用于软件架构(而不仅仅是详细代码)的想法。
KIS(代表保持简单)与非常相关。在设计软件组件时,避免过度设计。问问自己,你的解决方案是否是适合问题的最小解决方案。
实现最低限度的功能,正确解决问题,不会使解决方案变得过于复杂。记住,设计越简单,就越容易维护。
这个设计原则是我们在所有抽象层次上都要牢记的一个理念,无论我们是在考虑高级设计,还是在处理特定的代码行。
在较高的层次上,考虑我们正在创建的组件。我们真的需要所有这些吗?这个模块现在真的需要完全可扩展吗?强调最后一部分也许我们想要使该组件可扩展,但现在不是适当的时机,或者这样做是不合适的,因为我们仍然没有足够的信息来创建适当的抽象,并且在这一点上尝试提出通用接口只会导致更糟糕的问题。
就代码而言,保持简单通常意味着使用适合问题的最小数据结构。您很可能会在标准库中找到它。
有时,我们可能会使代码过于复杂,创建的函数或方法比需要的多。下面的类从提供的一组关键字参数创建名称空间,但它有一个相当复杂的代码接口:
class ComplicatedNamespace:
"""A convoluted example of initializing an object with some properties."""
ACCEPTED_VALUES = ("id_", "user", "location")
@classmethod
def init_with_data(cls, **data):
instance = cls()
for key, value in data.items():
if key in cls.ACCEPTED_VALUES:
setattr(instance, key, value)
return instance
使用额外的类方法初始化对象似乎没有必要。然后,迭代和内部对setattr
的调用让事情变得更加奇怪,呈现给用户的界面也不是很清晰:
>>> cn = ComplicatedNamespace.init_with_data(
... id_=42, user="root", location="127.0.0.1", extra="excluded"
... )
>>> cn.id_, cn.user, cn.location
(42, 'root', '127.0.0.1')
>>> hasattr(cn, "extra")
False
用户必须知道另一种方法的存在,这并不方便。最好保持简单,在初始化 Python 中的任何其他对象时使用__init__
方法初始化该对象(毕竟,有一个方法用于此):
class Namespace:
"""Create an object from keyword arguments."""
ACCEPTED_VALUES = ("id_", "user", "location")
def __init__(self, **data):
for attr_name, attr_value in data.items():
if attr_name in self.ACCEPTED_VALUES:
setattr(self, attr_name, attr_value)
记住 Python 的禅:简单比复杂好。
Python 中有许多场景,我们希望保持代码简单。其中一个与我们以前研究过的内容有关:代码复制。Python 中抽象代码的一种常见方法是使用 decorator(我们将在后面的第 5 章中看到,使用 decorator 改进代码。但是,如果我们试图避免一小部分的重复,比如说三行代码呢?在这种情况下,编写 decorator 可能需要更多的行,并且对于我们试图解决的简单重复行来说会更麻烦。在这种情况下,应用常识并保持务实。接受少量的重复可能比复杂的函数更好(当然,除非您找到一种更简单的方法来消除重复并保持代码简单!)。
作为保持代码简单的一部分,我建议避免使用 Python 的高级功能,如元类(或一般与元编程相关的任何功能),因为这些功能不仅几乎不需要(使用它们有非常特殊的理由!),而且它们使代码读起来更加复杂,而且更难维护。
EAFP代表比更容易请求原谅,而LBYL代表三思而后行。
EAFP 的思想是,我们编写代码,让它直接执行一个动作,然后我们在以后万一它不起作用时处理后果。通常,这意味着试着运行一些代码,期望它能工作,但如果不能,则捕获异常,然后在except
块上处理纠正代码。
这与 LBYL 相反。正如它的名字所说,在三思而后行方法中,我们首先检查我们将要使用什么。例如,我们可能希望在尝试操作某个文件之前检查该文件是否可用:
if os.path.exists(filename):
with open(filename) as f:
...
前面代码的 EAFP 版本如下所示:
try:
with open(filename) as f:
...
except FileNotFoundError as e:
logger.error(e)
如果您来自其他语言,比如 C 语言,它没有例外,那么逻辑上会发现 LBYL 方法更有用。在其他语言如 C++中,由于性能考虑,对使用异常有一些挫折,但在 Python 中通常不成立。
当然,特定的情况可能适用,但大多数情况下,您会发现 EAFP 版本更能揭示意图。以这种方式编写的代码更易于阅读,因为它直接进入所需的任务,而不是预防性地检查条件。换句话说,在上一个示例中,您将看到代码的一部分试图打开一个文件,然后对其进行处理。如果该文件不存在,那么我们将处理该情况。在第一个示例中,我们将看到一个函数检查文件是否存在,然后尝试执行某些操作。你可能会说这也很清楚,但我们不能确定。可能被询问的文件是另一个文件,或者是属于程序不同层的函数,或者是一个遗留文件,诸如此类。当您第一眼看到代码时,第二种方法不太容易出错。
您可以在特定的代码中应用这两种思想,因为它们在特定的代码中是有意义的,但一般来说,以 EAFP 方式编写的代码乍看起来更容易选择,因此,如果有疑问,我建议您选择此变体。
在面向对象软件设计中,经常会讨论如何使用范例的主要思想(多态、继承和封装)来解决一些问题。
这些想法中最常用的可能是继承,开发人员通常从创建一个包含他们需要的类的类层次结构开始,并决定每个人应该实现的方法。
虽然继承是一个强有力的概念,但它确实伴随着危险。主要的一点是,每次扩展基类时,我们都会创建一个与父类紧密耦合的新基类。正如我们已经讨论过的,在设计软件时,耦合是我们希望减少到最小的事情之一。
开发人员将继承与代码重用联系起来的主要场景之一是代码重用。虽然我们应该始终接受代码重用,但仅仅因为我们免费从父类获得方法,就强迫我们的设计使用继承来重用代码并不是一个好主意。重用代码的正确方法是拥有高度内聚的对象,这些对象可以轻松组合,并且可以在多个上下文中工作。
当创建派生类时,我们必须小心,因为这是一把双刃剑,一方面,它的优点是我们可以从父类免费获得所有方法的代码,但另一方面,我们将所有方法都带到一个新类中,这意味着我们可能在新定义中放置了太多的功能。
当创建一个新的子类时,我们必须考虑它是否真的要使用它刚刚继承的所有方法,作为一种启发式,以查看类是否被正确定义。相反,如果我们发现我们不需要大多数方法,并且必须重写或替换它们,则这是一个设计错误,可能是由多种原因造成的:
- 超类定义模糊,包含太多的责任,而不是定义良好的接口
- 子类不是它试图扩展的超类的适当专门化
使用继承的一个很好的例子是,当您有一个类,该类定义了某些组件,其行为由该类的接口(其public
方法和属性)定义,然后您需要专门化该类,以便创建执行相同操作但添加了其他内容的对象,或者改变了它行为的某些特定部分。
您可以在 Python 标准库本身中找到良好使用继承的示例。例如,在中的http.server
包(https://docs.python.org/3/library/http.server.html#http.server.BaseHTTPRequestHandler ),我们可以找到一个基类,如BaseHTTPRequestHandler
和子类,如SimpleHTTPRequestHandler
,它们通过添加或更改其基本接口的一部分来扩展这个基类。
说到接口定义,这是继承的另一个很好的用途。当我们想要强制某些对象的接口时,我们可以创建一个抽象基类,该基类不实现行为本身,而只是定义接口。扩展该基类的每个类都必须实现这些接口才能成为适当的子类型。
最后,继承的另一个好例子是异常。我们可以看到 Python 中的标准异常源于Exception
。这就是允许您有一个泛型子句,例如except Exception
,它将捕获所有可能的错误。重点是概念上的问题;它们是从Exception
派生的类,因为它们是更具体的异常。这也适用于著名的库,例如requests
,其中HTTPError
是RequestException
,而IOError
又是IOError
。
如果前面的章节用一个词来概括,它将是专门化。继承的正确用法是专门化对象,并从基本对象开始创建更详细的抽象。
父类(或基类)是新派生类的public
定义的一部分。这是因为继承的方法将是这个新类接口的一部分。因此,当我们阅读类的public
方法时,它们必须与父类定义的一致。
例如,如果我们看到一个从BaseHTTPRequestHandler
派生的类实现了一个名为handle()
的方法,这是有意义的,因为它重写了一个父类。如果它有任何其他方法,其名称与一个与 HTTP 请求有关的操作相关,那么我们也可以认为它的位置正确(但如果我们在该类上找到一个名为process_purchase()
的东西,我们不会这么认为)。
前面的说明似乎很明显,但这是经常发生的事情,特别是当开发人员试图以重用代码为唯一目标使用继承时。在下一个示例中,我们将看到一个典型的情况,它表示 Python 中的一个常见反模式。必须表示一个域问题,并且为该问题设计了一个合适的数据结构,但不是创建一个使用这种数据结构的对象,而是对象本身成为数据结构。
让我们通过一个例子来更具体地了解这些问题。假设我们有一个管理保险的系统,其中有一个模块负责将保单应用于不同的客户。我们需要在内存中保存一组当时正在处理的客户,以便在进一步处理或持久化之前应用这些更改。我们需要的基本操作是将新客户的记录存储为卫星数据,对策略应用更改,或者编辑一些数据,仅举几例。我们还需要支持批处理操作。也就是说,当策略本身发生变化时(此模块当前正在处理的策略),我们必须将这些变化整体应用于当前交易的客户。
考虑到我们需要的数据结构,我们意识到在固定时间内访问特定客户的记录是一种很好的特性。因此,类似于policy_transaction[customer_id]
的东西看起来像一个很好的界面。由此,我们可能会认为subscriptable
对象是一个好主意,并且进一步,我们可能会认为我们需要的对象是一本词典:
class TransactionalPolicy(collections.UserDict):
"""Example of an incorrect use of inheritance."""
def change_in_policy(self, customer_id, **new_policy_data):
self[customer_id].update(**new_policy_data)
使用此代码,我们可以通过其标识符获取有关客户策略的信息:
>>> policy = TransactionalPolicy({
... "client001": {
... "fee": 1000.0,
... "expiration_date": datetime(2020, 1, 3),
... }
... })
>>> policy["client001"]
{'fee': 1000.0, 'expiration_date': datetime.datetime(2020, 1, 3, 0, 0)}
>>> policy.change_in_policy("client001", expiration_date=datetime(2020, 1, 4))
>>> policy["client001"]
{'fee': 1000.0, 'expiration_date': datetime.datetime(2020, 1, 4, 0, 0)}
当然,我们一开始就实现了我们想要的界面,但代价是什么?现在,这个类由于执行了一些不必要的方法而有很多额外的行为:
>>> dir(policy)
[ # all magic and special method have been omitted for brevity...
'change_in_policy', 'clear', 'copy', 'data', 'fromkeys', 'get', 'items', 'keys', 'pop', 'popitem', 'setdefault', 'update', 'values']
这种设计(至少)有两个主要问题。一方面,层次结构是错误的。从概念上讲,从基类创建一个新类意味着它是所扩展类的一个更具体的版本(因此得名)。TransactionalPolicy
怎么会是字典?这有意义吗?记住,这是对象的公共接口的一部分,因此用户将看到这个类及其层次结构,并会注意到这样一个奇怪的专门化以及它的公共方法。
这就引出了第二个耦合问题。事务策略的接口现在包括字典中的所有方法。事务策略真的需要像pop()
或items()
这样的方法吗?然而,它们在那里。它们也是public
,因此此界面的任何用户都有权调用它们,无论它们可能带来什么不希望的副作用。在这一点上,我们并没有通过扩展字典获得太多好处。它实际上需要为受当前策略(change_in_policy()
)更改影响的所有客户更新的唯一方法不在基类上,因此我们必须自己定义它。
这是一个混合实现对象和域对象的问题。字典是一种实现对象,一种数据结构,适用于某些类型的操作,与所有数据结构一样具有折衷性。事务性策略应该表示域问题中的某个内容,该实体是我们试图解决的问题的一部分。
不要将实现数据结构与同一层次结构中的业务域类混合使用。
像这样的层次结构是不正确的,仅仅因为我们从基类获得了一些神奇的方法(通过扩展字典使对象可订阅),就不足以创建这样的扩展。在创建其他更具体的实现类时,应该单独扩展实现类。换句话说,如果您想创建另一个(更具体或稍加修改的)词典,请扩展词典。同样的规则也适用于域问题的类。
正确的解决方法是使用成分。TransactionalPolicy
不是字典,它使用字典。它应该将一个字典存储在一个private
属性中,并通过代理该字典来实现__getitem__()
,然后只实现它所需的public
方法的其余部分:
class TransactionalPolicy:
"""Example refactored to use composition."""
def __init__(self, policy_data, **extra_data):
self._data = {**policy_data, **extra_data}
def change_in_policy(self, customer_id, **new_policy_data):
self._data[customer_id].update(**new_policy_data)
def __getitem__(self, customer_id):
return self._data[customer_id]
def __len__(self):
return len(self._data)
这种方法不仅在概念上是正确的,而且更具可扩展性。如果基础数据结构(目前是一个字典)在将来发生更改,则只要维护接口,该对象的调用者就不会受到影响。这减少了耦合,最小化了涟漪效应,允许更好的重构(不应该更改单元测试),并使代码更易于维护。
Python 支持多重继承。由于继承在使用不当时会导致设计问题,您也可以预期多重继承在未正确实现时也会产生更大的问题。
因此,多重继承是一把双刃剑。在某些情况下,这也是非常有益的。需要明确的是,多重继承没有什么问题。它唯一的问题是,当它没有正确实现时,问题会成倍增加。
如果正确使用,多重继承是一个非常有效的解决方案,这将打开新的模式(例如我们在第 9 章、常见设计模式中讨论的适配器模式)和混合模式。
多重继承的最强大的应用之一可能是,它可以创建 mixin。在探索 mixin 之前,我们需要了解多重继承是如何工作的,以及如何在复杂的层次结构中解析方法。
有些人不喜欢多重继承,因为它在其他编程语言中有限制,例如所谓的钻石问题。当一个类从两个或多个类扩展,并且所有这些类也从其他基类扩展时,底层类将有多种方法来解析来自顶层类的方法。问题是:使用了这些实现中的哪一个?
考虑下面的图表,它具有多重继承的结构。顶级类有一个 class 属性,实现了__str__
方法。想想任何一个具体的类,例如,ConcreteModuleA12
——它从BaseModule1
和BaseModule2
扩展而来,它们中的每一个都将从BaseModule
中获得__str__
的实现。这两种方法中哪一种是ConcreteModuleA12
的方法?
图 3.1:方法解析顺序
使用 class 属性的值,这将变得很明显:
class BaseModule:
module_name = "top"
def __init__(self, module_name):
self.name = module_name
def __str__(self):
return f"{self.module_name}:{self.name}"
class BaseModule1(BaseModule):
module_name = "module-1"
class BaseModule2(BaseModule):
module_name = "module-2"
class BaseModule3(BaseModule):
module_name = "module-3"
class ConcreteModuleA12(BaseModule1, BaseModule2):
"""Extend 1 & 2"""
class ConcreteModuleB23(BaseModule2, BaseModule3):
"""Extend 2 & 3"""
现在,让我们测试一下,看看调用了什么方法:
>>> str(ConcreteModuleA12("test"))
'module-1:test'
没有碰撞。Python 通过使用一种称为 C3 线性化或 MRO 的算法来解决这个问题,该算法定义了调用方法的确定方式。
事实上,我们可以特别要求类的解析顺序:
>>> [cls.__name__ for cls in ConcreteModuleA12.mro()]
['ConcreteModuleA', 'BaseModule1', 'BaseModule2', 'BaseModule', 'object']
了解如何在层次结构中解析该方法可以在设计类时发挥我们的优势,因为我们可以使用 mixin。
mixin 是一个基类,它封装了一些常见的行为,目的是重用代码。通常,mixin 类本身并不有用,单独扩展该类肯定不起作用,因为它大部分时间依赖于在其他类中定义的方法和属性。其思想是通过多重继承将 mixin 类与其他类一起使用,以便 mixin 上使用的方法或属性可用。
假设我们有一个简单的解析器,它接受一个string
并通过其值(由连字符(-
分隔)对其进行迭代:
class BaseTokenizer:
def __init__(self, str_token):
self.str_token = str_token
def __iter__(self):
yield from self.str_token.split("-")
这很简单:
>>> tk = BaseTokenizer("28a2320b-fd3f-4627-9792-a2b38e3c46b0")
>>> list(tk)
['28a2320b', 'fd3f', '4627', '9792', 'a2b38e3c46b0']
但是现在我们希望值以大写形式发送,而不改变基类。对于这个简单的例子,我们可以创建一个新的类,但是假设很多类已经从BaseTokenizer
扩展,我们不想替换所有的类。我们可以将一个新类混合到处理此转换的层次结构中:
class UpperIterableMixin:
def __iter__(self):
return map(str.upper, super().__iter__())
class Tokenizer(UpperIterableMixin, BaseTokenizer):
pass
新的Tokenizer
类非常简单。它不需要任何代码,因为它利用了 mixin。这种类型的 mixin 充当一种装饰器。根据我们刚才看到的,Tokenizer
将从 mixin 中获取__iter__
,而这一个,反过来,通过调用super()
(即BaseTokenizer
,将其值转换为大写,从而产生所需的效果。
正如我们在 Python 中讨论的继承一样,我们已经看到了对软件设计非常重要的主题,如内聚和耦合。这些概念在软件设计中反复出现,也可以从函数及其参数的角度对其进行分析,我们将在下一节进行探讨。
在 Python 中,函数可以定义为以几种不同的方式接收参数,调用方也可以以多种方式提供这些参数。
在软件工程中定义接口也有一套行业范围的实践,这些实践与函数中参数的定义密切相关。
在本节中,我们将首先探讨 Python 函数中的参数机制,然后回顾与此主题相关的良好实践相关的软件工程的一般原则,最后将这两个概念联系起来。
首先,让我们回顾一下 Python 中参数如何传递给函数的特殊性。
通过首先理解 Python 提供的处理参数的可能性,我们将能够更容易地吸收一般规则,其思想是这样做之后,我们可以很容易地得出结论,在处理参数时什么是好的模式或习惯用法。然后,我们可以确定在哪些情况下,Pythonic 方法是正确的,在哪些情况下,我们可能滥用语言的特性。
Python 中的第一条规则是所有参数都由一个值传递。总是这意味着当向函数传递值时,它们被分配给函数签名定义上的变量,以便以后在函数上使用。
您会注意到,函数可能会也可能不会改变它接收到的参数,这取决于它们的类型。如果我们正在传递mutable
对象,并且函数体对此进行了修改,那么当然,我们有一个副作用,即当函数返回时,这些对象将被更改。
在下面,我们可以看到不同之处:
>>> def function(argument):
... argument += " in function"
... print(argument)
...
>>> immutable = "hello"
>>> function(immutable)
hello in function
>>> mutable = list("hello")
>>> immutable
'hello'
>>> function(mutable)
['h', 'e', 'l', 'l', 'o', ' ', 'i', 'n', ' ', 'f', 'u', 'n', 'c', 't', 'i', 'o', 'n']
>>> mutable
['h', 'e', 'l', 'l', 'o', ' ', 'i', 'n', ' ', 'f', 'u', 'n', 'c', 't', 'i', 'o', 'n']
>>>
这看起来可能不一致,但事实并非如此。当我们传递第一个参数 astring
时,它被分配给function
上的参数。由于string
对象是不可变的,像argument += <expression>
这样的语句实际上将创建新对象argument + <expression>
,并将其分配回参数。此时,参数只是函数范围内的局部变量,与调用方中的原始变量无关。
另一方面,当经过list
时,它是mutable
对象,那么语句有不同的含义(它相当于list
上的calling .extend()
。此运算符通过在保存原始list
对象引用的变量上就地修改list
来进行操作,从而对其进行修改。在第二种情况下,list
的引用通过一个值传递给函数。但由于它是一个引用,它正在对原始list
对象进行变异,因此我们在函数完成后看到变异。这大致相当于:
>>> a = list(range(5))
>>> b = a # the function call is doing something like this
>>> b.append(99)
>>> b
[0, 1, 2, 3, 4, 99]
>>> a
[0, 1, 2, 3, 4, 99]
我们在处理mutable
对象时必须小心,因为它可能导致意外的副作用。除非你绝对确信以这种方式操纵mutable
参数是正确的,否则我建议你避免使用它,并选择没有这些问题的替代方案。
不要改变函数参数。一般来说,尽量避免功能中不必要的副作用。
Python 中的参数可以按位置传递,就像在许多其他编程语言中一样,但也可以按关键字传递。这意味着我们可以显式地告诉函数我们想要它的哪些参数的哪些值。唯一需要注意的是,在通过关键字传递参数后,后面的其余参数也必须以这种方式传递,否则将引发SyntaxError
。
Python 和其他语言一样,都有内置函数和构造,可以接受数量可变的参数。例如,考虑 Tyt T0-内插函数(无论是通过使用 OrthT1 算子或字符串的 PosiT2Ay 方法),其遵循与 C 中的 ToeT3Ay 函数相似的结构,第一个位置参数具有 PoT T4 进制格式,后跟将放置在该格式化字符串的标记上的任意数量的参数。
除了利用 Python 中可用的这些函数外,我们还可以创建自己的函数,它将以类似的方式工作。在本节中,我们将介绍具有可变数量参数的函数的基本原则,以及一些建议,以便在下一节中,我们可以探讨如何在处理常见问题、问题和约束时利用这些功能,如果函数具有过多参数,则这些功能可能具有这些优势。
对于数量可变的positional
参数,使用星号(*
),位于打包这些参数的变量名称之前。这是通过 Python 的打包机制实现的。
假设有一个函数接受三个位置参数。在代码的一部分中,我们碰巧在list
中有我们想要传递给函数的参数,它们的顺序与函数期望的顺序相同。
我们可以使用打包机制,在一条指令中一个接一个地传递它们(即,list[0]
传递给第一个元素,list[1]
传递给第二个元素,等等),这将是真正的非 Pythonic:
>>> def f(first, second, third):
... print(first)
... print(second)
... print(third)
...
>>> l = [1, 2, 3]
>>> f(*l)
1
2
3
包装机制的优点在于,它也可以反过来工作。如果我们想根据变量各自的位置提取 alist
的值,我们可以这样分配它们:
>>> a, b, c = [1, 2, 3]
>>> a
1
>>> b
2
>>> c
3
部分拆包也是可能的。假设我们只对序列的第一个值感兴趣(可以是list
、tuple
或其他值),在某个点之后,我们只希望其余值保持在一起。我们可以分配所需的变量,并将其余变量保留在打包的list
下。我们开箱的顺序不受限制。如果在其中一个未打包的小节中没有放置任何内容,则结果将是空的list
。在 Python 终端上尝试以下示例,并探索如何使用生成器进行解包:
>>> def show(e, rest):
... print("Element: {0} - Rest: {1}".format(e, rest))
...
>>> first, *rest = [1, 2, 3, 4, 5]
>>> show(first, rest)
Element: 1 - Rest: [2, 3, 4, 5]
>>> *rest, last = range(6)
>>> show(last, rest)
Element: 5 - Rest: [0, 1, 2, 3, 4]
>>> first, *middle, last = range(6)
>>> first
0
>>> middle
[1, 2, 3, 4]
>>> last
5
>>> first, last, *empty = 1, 2
>>> first
1
>>> last
2
>>> empty
[]
解包变量的最佳用途之一可以在迭代中找到。当我们必须迭代一个元素序列,而每个元素又是一个序列时,最好在迭代每个元素的同时解包。为了看一个实际的例子,我们将假设我们有一个函数,它接收list
个数据库行,并负责用这些数据创建用户。第一个实现从行中每个列的位置获取用于构造用户的值,这一点都不惯用。第二个实现在迭代时使用解包:
from dataclasses import dataclass
USERS = [
(i, f"first_name_{i}", f"last_name_{i}")
for i in range(1_000)
]
@dataclass
class User:
user_id: int
first_name: str
last_name: str
def bad_users_from_rows(dbrows) -> list:
"""A bad case (non-pythonic) of creating ``User``s from DB rows."""
return [User(row[0], row[1], row[2]) for row in dbrows]
def users_from_rows(dbrows) -> list:
"""Create ``User``s from DB rows."""
return [
User(user_id, first_name, last_name)
for (user_id, first_name, last_name) in dbrows
]
请注意,第二个版本更容易阅读。在函数的第一个版本(bad_users_from_rows
中,我们有以row[0]
、row[1]
和row[2]
形式表示的数据,这些数据没有告诉我们它们是什么。另一方面,像user_id
、first_name
和last_name
这样的变量本身就说明了问题。
在构造User
对象时,我们还可以使用 star 操作符传递tuple
中的所有positional
参数:
[User(*row) for row in dbrows]
在设计我们自己的功能时,我们可以利用这种功能来发挥我们的优势。
我们可以在标准库中找到的一个例子是max
函数,其定义如下:
max(...)
max(iterable, *[, default=obj, key=func]) -> value
max(arg1, arg2, *args, *[, key=func]) -> value
With a single iterable argument, return its biggest item. The
default keyword-only argument specifies an object to return if
the provided iterable is empty.
With two or more arguments, return the largest argument.
还有一种类似的表示法,关键字参数有两颗星(**
。如果我们有一个字典,我们用一个双星将它传递给一个函数,它将做的是选择键作为参数的名称,并将该函数中的value
作为该函数中的parameter
的value
传递。
例如,请查看以下内容:
function(**{"key": "value"})
与以下内容相同:
function(key="value")
相反,如果我们定义一个以两个星形符号开头的参数的函数,如果将参数打包到字典中,则会出现相反的情况:
>>> def function(**kwargs):
... print(kwargs)
...
>>> function(key="value")
{'key': 'value'}
Python 的这个特性非常强大,因为它允许我们动态地选择要传递给函数的值。但是,滥用此功能并过度使用它会使代码更难理解。
当我们像前面的例子一样定义一个函数时,它的一个参数有一个双星,这意味着允许使用任意的关键字参数,Python 将把它们放在我们可以自行访问的字典中。根据前面定义的函数,kwargs
参数是一个字典。一个好的建议是不要使用此字典从中提取特定值。
也就是说,不要查找字典的特定键。相反,直接在函数定义上提取这些参数。
例如,不要这样做:
def function(**kwargs): # wrong
timeout = kwargs.get("timeout", DEFAULT_TIMEOUT)
...
让 Python 进行解包并在签名处设置默认参数:
def function(timeout=DEFAULT_TIMEOUT, **kwargs): # better
...
在这个例子中,timeout 并不是严格意义上的关键字。我们将在几节中看到如何生成只包含关键字的参数,但最重要的是不要操纵kwargs
字典,而是在签名级别执行适当的解包。
在深入研究只包含关键字的参数之前,让我们先从那些只包含位置的参数开始。
正如我们已经看到的,位置参数(变量或非变量)是 Python 中首先提供给函数的参数。这些参数的值是根据它们提供给函数的位置来解释的,这意味着它们分别被分配给函数定义中的参数。
如果我们在定义函数参数时不使用任何特殊语法,默认情况下,它们可以通过位置或关键字传递。例如,在以下函数中,对该函数的所有调用都是等效的:
>>> def my_function(x, y):
... print(f"{x=}, {y=}")
...
>>> my_function(1, 2)
x=1, y=2
>>> my_function(x=1, y=2)
x=1, y=2
>>> my_function(y=2, x=1)
x=1, y=2
>>> my_function(1, y=2)
x=1, y=2
这意味着,在第一种情况下,我们传递值1
和2
,并根据它们的位置,将它们分别分配给参数x
和y
。使用这种语法,如果需要的话(例如,为了更明确),没有什么可以阻止我们将相同的参数与其关键字一起传递(即使是以相反的顺序)。这里唯一的限制是,如果我们将一个参数作为关键字传递,那么下面的所有参数也必须作为关键字提供(上一个示例在参数反转时不起作用)。
然而,从 Python 3.8(PEP-570)开始,引入了新的语法,允许定义严格定位的参数(这意味着我们在传递值时不能提供它们的名称)。要使用此选项,必须在最后一个位置参数(仅限参数)的末尾添加一个/
。例如:
>>> def my_function(x, y, /):
... print(f"{x=}, {y=}")
...
>>> my_function(1, 2)
x=1, y=2
>>> my_function(x=1, y=2)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: my_function() got some positional-only arguments passed as keyword arguments: 'x, y'
请注意函数的第一次调用是如何工作的(与以前一样),但从现在起,任何传递关键字参数的尝试都将失败。引发的异常将在其消息中告诉我们试图仅作为关键字传递的仅位置参数。通常,使用关键字参数会使代码更具可读性,因为您随时都知道为哪些参数提供了哪些值,但在某些情况下,这种语法可能很有用,例如,在参数名称没有意义的情况下(因为它们没有意义,而不是因为我们在命名它们时做得不好!),试图使用他们的名字会适得其反。
举一个非常简单的例子,想象一个函数来检查两个单词是否是字谜。该函数接受两个字符串并进行一些处理。我们如何命名这两个字符串并不重要(坦率地说,它们的顺序并不重要,它只是第一个单词和第二个单词)。尝试为这些参数提供好的名称没有多大意义,在调用函数时分配它们的关键字值也没有多大意义。
对于其他情况,应该避免这种情况。
不要强制有意义的参数仅为位置参数。
在非常特殊的情况下,仅定位参数可能是一个好主意,但大多数情况下不需要这样做。但一般来说,这不是一个您希望多次使用的功能,因为我们可以利用将参数作为关键字传递的优势,因为这样可以更容易地理解哪些值传递给哪些参数。出于这个原因,相反的情况是您希望更经常地做的事情,只使用 arguments 关键字,我们将在下一节中讨论。
与前面的功能类似,可以只使用一些关键字参数。这可能更有意义,因为我们可以在函数调用中指定关键字参数时找到意义,现在我们可以加强这种明确性。
在这种情况下(与前一种情况相反),我们使用*
符号在仅关键字参数开始时发出信号。在函数签名中,位置参数(*args
数量可变)后面的所有内容都将仅为关键字。
例如,以下定义接受两个位置参数,然后是任意数量的位置参数,然后是两个最终参数,这些参数仅作为关键字传递。最后一个具有默认值(尽管这不是强制性的,如第三种情况):
>>> def my_function(x, y, *args, kw1, kw2=0):
... print(f"{x=}, {y=}, {kw1=}, {kw2=}")
...
>>> my_function(1, 2, kw1=3, kw2=4)
x=1, y=2, kw1=3, kw2=4
>>> my_function(1, 2, kw1=3)
x=1, y=2, kw1=3, kw2=0
函数调用清楚地说明了它的行为。如果我们不希望在前两个参数之后有任何数量的位置参数,我们可以简单地将*
替换为*args
。
此功能对于以向后兼容的方式扩展已定义(并正在使用)的函数或类非常有用。例如,如果您有一个包含两个参数的函数,并且在整个代码中多次调用它(有时按位置使用参数,有时按关键字使用参数),并且您希望添加第三个参数,那么如果您希望当前调用继续工作,您必须为它设置默认值。但更好的做法是只使用最后一个参数关键字,因此新调用必须明确表示打算使用新定义。
同样,在重构和保持兼容性时,此功能也很有用。假设您有一个要用新实现替换的函数,但为了保持兼容性,您将原始函数保留为包装器。让我们分析如下函数调用之间的差异:
result = my_function(1, 2, True)
另一个电话如下:
result = my_function(1, 2, use_new_implementation=True)
很明显,第二个示例更为明确,只要看一眼函数调用,您就会清楚地知道发生了什么。因此,只使用新参数(决定使用哪个实现)关键字是有意义的。
在这种情况下,如果有一个参数确实需要上下文才能理解,那么只使用 parameter 关键字是一个好主意。
这些是 Python 函数中参数和参数如何工作的基础。现在我们可以用这些知识来讨论好的设计思想。
在本节中,我们同意的观点,即函数或方法包含太多参数是设计糟糕的标志(代码气味)。然后,我们提出了处理这个问题的方法。
第一种选择是软件设计具体化的更一般的原则(为我们正在传递的所有参数创建一个新对象,这可能是我们缺少的抽象)。将多个参数压缩到一个新对象中并不是 Python 特有的解决方案,而是我们可以在任何编程语言中应用的解决方案。
另一种选择是使用我们在上一节中看到的特定于 Python 的特性,利用变量位置参数和关键字参数来创建具有动态签名的函数。虽然这可能是一种类似 python 的方式,但我们必须小心不要滥用该功能,因为我们可能正在创建一些动态的东西,很难维护。在这种情况下,我们应该看看函数的主体。不管签名是什么,参数是否正确,如果函数响应参数值做了太多不同的事情,那么它就是一个信号,它必须被分解成多个更小的函数(记住,函数应该做一件事,而且只能做一件事!)。
一个函数签名的参数越多,它就越有可能与调用方函数紧密耦合。
假设我们有两个函数,f1
和f2
,后者有五个参数。参数f2
越多,任何试图调用该函数的人都越难收集所有信息并传递,以便它能够正常工作。
现在,f1
似乎拥有所有这些信息,因为它可以正确地调用它。由此,我们可以得出两个结论。首先,f2
可能是一个漏洞百出的抽象,这意味着f1
知道f2
所需要的一切,它几乎可以弄清楚自己在内部做什么,并且能够自己完成。
所以,总而言之,f2
并没有抽象那么多。第二,看起来f2
只对f1
有用,很难想象在不同的上下文中使用此函数,从而使其更难重用。
当函数具有更通用的接口并且能够处理更高级别的抽象时,它们就变得更具可重用性。
这适用于各种函数和对象方法,包括类的__init__
方法。这种方法的存在通常(但并非总是)意味着应该传递一个新的更高级别的抽象,或者缺少一个对象。
如果一个函数需要太多的参数来正常工作,请考虑它是代码气味。
事实上,这是一个设计问题,静态分析工具如pylint
(在第 1 章、简介、代码格式和工具中讨论)在遇到此类情况时,默认情况下会发出警告。发生这种情况时,不要取消显示“重构”警告。
假设我们找到一个需要太多参数的函数。我们知道我们不能这样离开代码库,重构过程是必不可少的。但是有什么选择呢?
根据具体情况,以下一些规则可能适用。这一点也不广泛,但它确实提供了如何解决一些经常发生的场景的想法。
有时,如果我们可以看到大多数参数都属于一个公共对象,那么可以很容易地更改参数。例如,考虑像这样的函数调用:
track_request(request.headers, request.ip_addr, request.request_id)
现在,函数可能需要也可能不需要额外的参数,但这里有一点非常明显:所有参数都依赖于request
,那么为什么不传递request
对象呢?这是一个简单的更改,但它显著改进了代码。正确的函数调用应该是track_request(request)
——更不用说,在语义上,它也更有意义。
虽然这样传递参数是受鼓励的,但在我们将mutable
对象传递给函数的所有情况下,我们必须非常小心副作用。我们正在调用的函数不应该对我们正在传递的对象进行任何修改,因为这将改变对象,从而产生不希望的副作用。除非这实际上是期望的效果(在这种情况下,必须明确),否则这种行为是不被鼓励的。即使当我们真的想要改变我们正在处理的对象上的某些东西时,更好的选择是复制它并返回它的(新的)修改版本。
使用不可变对象并尽可能避免副作用。
这就给我们带来了一个类似的主题:分组参数。在上一个示例中,参数已经分组,但没有使用该组(在本例中为request
对象)。但其他情况并不像那一种那么明显,我们可能希望将参数中的所有数据分组到一个充当container
的对象中。不用说,这一分组必须有意义。这里的想法是具体化:创建我们设计中缺失的抽象。
如果前面的策略不起作用,作为最后手段,我们可以更改函数的签名以接受可变数量的参数。如果参数的数量太多,使用*args
或**kwargs
会使事情更难理解,因此我们必须确保正确记录和正确使用接口,但在某些情况下,这是值得的。
诚然,用*args
和**kwargs
定义的函数确实是灵活的、适应性强的,但缺点是它失去了它的签名,并且失去了它的部分含义和几乎所有的易读性。我们已经看到了一些例子,说明变量名(包括函数参数)如何使代码更易于阅读。如果一个函数将接受任意数量的参数(位置或关键字),我们可能会发现,当我们将来想要查看该函数时,我们可能不知道它应该如何处理其参数,除非它有一个非常好的 docstring。
当您希望在另一个函数(例如,将调用super()
的方法或装饰器)上有一个完美的包装器时,请尝试仅定义具有最通用参数的函数(*args
、**kwargs
)。
良好的软件设计包括遵循软件工程的良好实践,并充分利用语言的大部分特性。使用 Python 所提供的一切都有很大的价值,但是滥用它并试图将复杂的特性融入简单的设计中也有很大的风险。
除了这一一般性原则之外,最好增加一些最后建议。
这个词非常笼统,可以有多种含义或解释。在数学中,正交意味着两个元素是独立的。如果两个向量正交,则它们的标量积为零。这也意味着他们根本没有关系。其中一个的改变根本不会影响另一个。这就是我们应该思考软件的方式。
更改模块、类或函数不应影响正在修改的组件的外部世界。当然,这是非常可取的,但并非总是可能的。但即使是在不可能的情况下,一个好的设计也会尽可能地减少影响。我们已经看到了诸如关注点分离、内聚和组件隔离之类的想法。
就软件的运行时结构而言,正交性可以解释为使更改(或副作用)局部化的过程。例如,这意味着对对象调用方法不应改变其他(不相关)对象的内部状态。在本书中,我们已经(并将继续)强调了最小化代码中副作用的重要性。
在 mixin 类的示例中,我们创建了一个标记器对象,该对象返回一个iterable
。__iter__
方法返回一个新生成器的事实增加了所有三个类(基础类、混合类和混凝土类)都是正交的可能性。如果这返回了一些具体的内容(比如说,list
,那么这将创建对其余类的依赖,因为当我们将list
更改为其他内容时,我们可能需要更新代码的其他部分,这表明这些类并不像应该的那样独立。
让我们给你看一个简单的例子。Python 允许通过参数传递函数,因为它们只是常规对象。我们可以使用此功能实现某种正交性。我们有一个计算价格的函数,包括税和折扣,但之后我们要格式化获得的最终价格:
def calculate_price(base_price: float, tax: float, discount: float) -> float:
return (base_price * (1 + tax)) * (1 - discount)
def show_price(price: float) -> str:
return "$ {0:,.2f}".format(price)
def str_final_price(
base_price: float, tax: float, discount: float, fmt_function=str
) -> str:
return fmt_function(calculate_price(base_price, tax, discount))
请注意,顶级函数由两个正交函数组成。需要注意的一点是我们如何计算price
,这就是另一个的表示方式。改变一个不会改变另一个。如果我们没有传递任何特定的内容,它将使用string
转换作为默认表示函数,如果我们选择传递自定义函数,则生成的string
将发生变化。但show_price
的变化不影响calculate_price
。我们可以对其中一个功能进行更改,因为我们知道另一个功能将保持原样:
>>> str_final_price(10, 0.2, 0.5)
'6.0'
>>> str_final_price(1000, 0.2, 0)
'1200.0'
>>> str_final_price(1000, 0.2, 0.1, fmt_function=show_price)
'$ 1,080.00'
有一个有趣的质量方面与正交性有关。如果代码的两部分是正交的,这意味着其中一部分可以在不影响另一部分的情况下进行更改。这意味着更改的部分具有与应用程序其余部分的单元测试正交的单元测试。在这个假设下,如果这些测试通过,我们可以假设(在一定程度上)应用程序是正确的,而不需要完全的回归测试。
更广泛地说,正交性可以从特征的角度来考虑。应用程序的两个功能可以完全独立,这样就可以测试和发布它们,而不必担心其中一个可能会破坏另一个(或者其他代码)。假设项目需要一个新的身份验证机制(比如说oauth2
,但仅仅是为了这个示例),同时另一个团队也在编写一个新的报告。
除非该系统中存在根本性的错误,否则这两个功能都不会影响其他功能。无论哪一个先被合并,另一个都不应该受到影响。
代码的组织方式也会影响团队的绩效及其可维护性。
特别是,拥有包含大量定义(类、函数、常量等)的大型文件是一种不好的做法,应该加以劝阻。这并不意味着在每个文件中放置一个定义,而是一个好的代码库将根据相似性来构造和排列组件。
幸运的是,在大多数情况下,在 Python 中将大文件更改为小文件并不是一项困难的任务。即使代码的其他多个部分依赖于该文件上的定义,也可以将其分解为一个包,并保持完全的兼容性。想法是创建一个新目录,其中包含一个__init__.py
文件(这将使其成为一个 Python 包)。除了这个文件之外,我们还有多个文件,其中包含每个文件所需的所有特定定义(根据特定标准分组的函数和类更少)。然后,__init__.py
文件将从所有其他文件导入它以前的定义(这是保证其兼容性的原因)。此外,这些定义可以在模块的__all__
变量中提及,以使其可导出。
这有很多好处。除了每个文件都更易于导航和查找之外,我们还可以认为它将更高效,原因如下:
- 导入模块时,它包含更少的要解析和加载到内存中的对象。
- 模块本身可能会导入更少的模块,因为它需要更少的依赖项,就像以前一样。
这也有助于为项目制定约定。例如,我们可以创建一个特定于要在项目中使用的常量值的文件,而不是将constants
放置在所有文件中,并从中导入该文件:
from myproject.constants import CONNECTION_TIMEOUT
以这种方式集中信息可以更容易地重用代码,并有助于避免无意中的重复。
关于分离模块和创建 Python 包的更多细节将在第 10 章、清洁体系结构中讨论,我们将在软件体系结构的上下文中对此进行探讨。
在本章中,我们探讨了实现清洁设计的几个原则。理解代码是设计的一部分是实现高质量软件的关键。本章和下一章正是围绕这一点展开的。
有了这些想法,我们现在可以构造更健壮的代码。例如,通过应用 DbC,我们可以创建保证在其约束范围内工作的组件。更重要的是,如果发生错误,这不会突然发生,但相反,我们将清楚地知道谁是罪犯,以及代码的哪一部分违反了合同。这种划分是有效调试的关键。
按照类似的思路,如果每个组件都能保护自己不受恶意意图或错误输入的影响,那么它们就可以变得更加健壮。尽管这个想法与 DbC 的方向不同,但它可能会很好地补充它。防御性编程是一个好主意,尤其是对于应用程序的关键部分。
对于这两种方法(DbC 和防御性编程),正确处理断言非常重要。记住它们在 Python 中应该如何使用,不要将断言作为程序控制流逻辑的一部分使用。也不要捕获此异常。
说到异常,知道如何以及何时使用它们很重要,这里最重要的概念是避免将异常作为一种控制流(go-to
类型的构造使用。
我们探讨了面向对象设计中经常出现的一个话题,即使用继承还是组合。这里的主要教训不是使用一个而不是另一个,而是使用更好的选项;我们还应该避免一些常见的反模式,这可能在 Python 中经常看到(特别是考虑到它的高度动态性)。
最后,我们讨论了函数中参数的数量,以及干净设计的启发式方法,始终牢记 Python 的特殊性。
这些概念是基本的设计思想,为下一章的内容奠定了基础。我们需要首先理解这些想法,这样我们才能进入更高级的话题,比如 SOLID 原则。
以下是您可以参考的信息列表:
- PEP-570:Python 位置参数(https://www.python.org/dev/peps/pep-0570/
- PEP-3102:仅关键字参数(https://www.python.org/dev/peps/pep-3102/
- 面向对象软件构建,第二版,作者Bertrand Meyer
- 实用程序员:从熟练工到大师,作者安德鲁·亨特和大卫·托马斯,由艾迪生·韦斯利出版,2000 年。
- PEP-316:Python 合同编程(https://www.python.org/dev/peps/pep-0316/
- REAL 01:最邪恶的 Python 反模式(https://realpython.com/blog/python/the-most-diabolical-python-antipattern/
- PEP-3134:异常链接和嵌入式回溯:(https://www.python.org/dev/peps/pep-3134/
- 惯用 Python:EAFP 对 LBYL:https://blogs.msdn.microsoft.com/pythonengineering/2016/06/29/idiomatic-python-eafp-versus-lbyl/
- 组合与继承:如何选择?https://www.thoughtworks.com/insights/blog/composition-vs-inheritance-how-choose
- Python HTTP:https://docs.python.org/3/library/http.server.html#http.server.BaseHTTPRequestHandler
- 请求库中异常的源参考:http://docs.python-requests.org/en/master/_modules/requests/exceptions/
- 代码完成:软件构建实用手册,第二版,由Steve McConnell撰写