在本章中,我们将探讨用 Python 表达思想的方式,以及 Python 自身的特点。如果你熟悉在编程中完成某些任务的标准方法(比如获取列表的最后一个元素,迭代和搜索),或者如果你来自其他编程语言(如 C++、C++和 java),那么你会发现,一般来说,Python 为大多数常见任务提供了自己的机制。
在编程中,习惯用法是为了执行特定任务而编写代码的一种特殊方式。每次重复并遵循相同的结构是很常见的。有些人甚至会争论并称之为模式,但要小心,因为它们不是设计模式(我们将在后面探讨)。主要区别在于,设计模式是高级思想,独立于语言(某种程度上),但它们不会立即转化为代码。另一方面,习语实际上是经过编码的。这是当我们想要执行一项特定任务时应该采用的书写方式。
由于习语是代码,它们依赖于语言。每种语言都有自己的习惯用法,这意味着在该特定语言中的操作方式(例如,如何用 C 或 C++打开和编写文件)。当代码遵循这些习惯用法时,它被称为习惯用法,在 Python 中通常被称为 Pythonic。
遵循这些建议并首先编写 Pythonic 代码有多种原因(我们将看到并分析),因为以惯用的方式编写代码通常性能更好。它也更紧凑,更容易理解。这些是我们在代码中一直想要的特性,以便它能够有效地工作。
其次,正如前一章所介绍的,整个开发团队能够习惯相同的模式和代码结构是很重要的,因为这将帮助他们关注问题的真正本质,并帮助他们避免犯错误。
本章的目标如下:
- 理解索引和切片,并正确实现可索引的对象
- 实现序列和其他可重用项
- 了解上下文管理器的良好用例,以及如何编写有效的用例。
- 通过神奇的方法实现更多的惯用代码
- 避免 Python 中导致意外副作用的常见错误
在下一节中,我们首先探索列表上的第一项(索引和切片)。
在 Python 中,就像在其他语言中一样,一些数据结构或类型支持通过索引访问其元素。它与大多数编程语言的另一个共同点是,第一个元素位于索引编号0
中。然而,与这些语言不同的是,当我们希望以与通常不同的顺序访问元素时,Python 提供了额外的特性。
例如,如何访问 C 中数组的最后一个元素?这是我第一次尝试 Python 时做的事情。与 C 中的方法相同,我将得到数组长度减去 1 的位置的元素。在 Python 中,这也会起作用,但我们也可以使用负数,从最后一个元素开始计数,如以下命令所示:
>>> my_numbers = (4, 5, 3, 9)
>>> my_numbers[-1]
9
>>> my_numbers[-3]
5
这是一个首选(Pythonic)做事方式的例子。
除了只获取一个元素外,我们还可以使用slice
获取多个元素,如下命令所示:
>>> my_numbers = (1, 1, 2, 3, 5, 8, 13, 21)
>>> my_numbers[2:5]
(2, 3, 5)
在本例中,方括号上的语法意味着我们获取元组上的所有元素,从第一个数字(包括)的索引开始,一直到第二个数字(不包括)的索引。在 Python 中,切片通过排除选定间隔的结尾来实现。
您可以排除“开始”或“停止”这两个间隔中的任何一个,在这种情况下,它将分别从序列的开始或结束处起作用,如以下命令所示:
>>> my_numbers[:3]
(1, 1, 2)
>>> my_numbers[3:]
(3, 5, 8, 13, 21)
>>> my_numbers[::] # also my_numbers[:], returns a copy
(1, 1, 2, 3, 5, 8, 13, 21)
>>> my_numbers[1:7:2]
(1, 3, 8)
在第一个示例中,它将获取位置编号3
中索引的所有内容。在第二个示例中,它将获取从位置3
(含)到末尾的所有数字。在第二个到最后一个示例中,两端都被排除在外,它实际上是在创建原始元组的副本。
最后一个示例包括第三个参数,即步骤。这表示在迭代间隔时要跳转多少个元素。在这种情况下,它意味着得到位置 1 和位置 7 之间的元素,跳跃 2。
在所有这些情况下,当我们将间隔传递给序列时,实际发生的是我们正在传递slice
。注意,slice
是 Python 中的内置对象,您可以自己构建并直接传递:
>>> interval = slice(1, 7, 2)
>>> my_numbers[interval]
(1, 3, 8)
>>> interval = slice(None, 3)
>>> my_numbers[interval] == my_numbers[:3]
True
请注意,当其中一个元素丢失(开始、停止或步骤)时,它被认为是None
。
对于切片,您应该总是更喜欢使用这种内置语法,而不是手动尝试迭代for
循环中的元组、字符串或列表,手动排除元素。
我们刚才讨论的功能之所以有效,是因为有一个名为__getitem__
的神奇方法(神奇方法是那些被 Python 用来保留特殊行为的双下划线包围的方法)。这是在调用类似于myobject[key]
的内容时调用的方法,将key
(方括号内的值)作为参数传递。一个序列,尤其是一个同时实现了__getitem__
和__len__
的对象,因此,它可以被迭代。列表、元组和字符串是标准库中序列对象的示例。
在本节中,我们更关心的是通过键从对象中获取特定元素,而不是构建序列或可编辑对象,这是第 7 章、生成器、迭代器和异步编程中探讨的主题。
如果要在域中的自定义类中实现__getitem__
,则必须考虑一些因素,以便遵循 Pythonic 方法。
如果您的类是标准库对象的包装器,那么您最好将行为尽可能委托给底层对象。这意味着,如果您的类实际上是列表上的包装器,则调用该列表上的所有相同方法以确保其保持兼容。在下面的列表中,我们可以看到一个对象如何包装列表的示例,对于我们感兴趣的方法,我们只需将其委托给list
对象上的相应版本:
from collections.abc import Sequence
class Items(Sequence):
def __init__(self, *values):
self._values = list(values)
def __len__(self):
return len(self._values)
def __getitem__(self, item):
return self._values.__getitem__(item)
为了声明我们的类是一个序列,它实现了来自collections.abc
模块(的Sequence
接口 https://docs.python.org/3/library/collections.abc.html )。对于您编写的旨在作为标准类型的对象(容器、映射等)的类,最好从这个模块实现接口,因为这揭示了该类的意图,也因为使用接口将迫使您实现所需的方法。
此示例使用组合(因为它包含一个作为列表的内部协作器,而不是从列表类继承)。另一种方法是通过类继承,在这种情况下,我们必须扩展collections.UserList
基类,并考虑本章最后一部分提到的注意事项。
但是,如果您正在实现自己的序列,而该序列不是包装器或不依赖于下面的任何内置对象,那么请记住以下几点:
- 按范围索引时,结果应该是类的同一类型的实例
- 在
slice
提供的范围内,尊重 Python 使用的语义,不包括末尾的元素
第一点是一个微妙的错误。想想看,当你得到一个列表的一部分时,结果就是一个列表;当您在元组中请求一个范围时,结果是一个元组;当您请求一个子字符串时,结果是一个字符串。在每种情况下,结果与原始对象的类型相同是有意义的。比如说,如果您正在创建一个表示日期间隔的对象,并且您请求该间隔的范围,那么返回列表、元组或其他内容将是一个错误。相反,它应该返回具有新间隔集的同一类的新实例。最好的例子是在标准库中,带有range
函数。如果您使用间隔调用range
,它将构造一个 iterable 对象,该对象知道如何生成所选范围内的值。当您为range
指定一个间隔时,您将获得一个新的范围(这是有意义的),而不是列表:
>>> range(1, 100)[25:50]
range(26, 51)
第二条规则也是关于一致性的,如果代码与 Python 本身一致,那么代码的用户会发现它更熟悉,也更易于使用。作为 Python 开发人员,我们已经习惯了切片如何工作,range
函数如何工作,等等。在自定义类上创建异常会造成混乱,这意味着它将更难记住,并可能导致错误。
现在我们已经了解了索引和切片,以及如何创建我们自己的索引和切片,在下一节中,我们将采用相同的方法,但用于上下文管理器。首先,我们将了解标准库中的上下文管理器是如何工作的,然后我们将进入下一个级别并创建自己的上下文管理器。
上下文管理器是 Python 提供的一个非常有用的特性。它们之所以如此有用,是因为它们能够正确响应模式。经常出现这样的情况:我们希望运行一些具有前置条件和后置条件的代码,这意味着我们希望在某个主操作之前和之后分别运行这些代码。在这些情况下,上下文管理器是很好的工具。
大多数时候,我们看到上下文管理器围绕着资源管理。例如,在打开文件的情况下,我们希望确保它们在处理后关闭(这样我们就不会泄漏文件描述符)。或者,如果我们打开与服务(甚至是套接字)的连接,我们还希望确保相应地关闭它,或者在处理临时文件时,等等。
在所有这些情况下,您通常必须记住释放所有分配的资源,这只是考虑最好的情况,但是异常和错误处理又如何呢?考虑到处理程序的所有可能组合和执行路径会使调试变得更加困难,解决此问题的最常用方法是将清理代码放在一个finally
块上,这样我们就可以确保不会错过它。例如,一个非常简单的案例如下所示:
fd = open(filename)
try:
process_file(fd)
finally:
fd.close()
尽管如此,还是有一种更优雅、更通俗的方式来实现同样的目标:
with open(filename) as fd:
process_file(fd)
with
语句(PEP-343)进入上下文管理器。在这种情况下,open
函数实现了上下文管理器协议,这意味着当块完成时,即使发生异常,文件也会自动关闭。
上下文管理器由两种神奇的方法组成:__enter__
和__exit
。在上下文管理器的第一行,with
语句将调用第一个方法__enter__
,该方法返回的任何内容都将分配给标记在as
之后的变量。这是可选的,我们实际上不需要返回任何关于__enter__
方法的特定内容,即使我们返回了,如果不需要,也没有严格的理由将其分配给变量。
执行这一行之后,代码进入一个新的上下文,在这里可以运行任何其他 Python 代码。在该块上的最后一条语句完成后,上下文将退出,这意味着 Python 将调用我们第一次调用的原始上下文管理器对象的__exit__
方法。
如果 context manager 块内出现异常或错误,仍会调用__exit__
方法,这便于安全管理条件清理。事实上,这个方法接收在块上触发的异常,以防我们希望以自定义方式处理它。
尽管在处理资源时经常会发现上下文管理器(如我们提到的关于文件、连接等的示例),但这并不是它们拥有的唯一应用程序。我们可以实现自己的上下文管理器,以处理我们需要的特定逻辑。
上下文管理器是分离关注点和隔离代码中应保持独立的部分的好方法,因为如果我们混合使用它们,那么逻辑将变得更难维护。
举个例子,考虑一个我们想用脚本运行数据库备份的情况。需要注意的是,备份处于脱机状态,这意味着我们只能在数据库未运行时进行备份,为此,我们必须停止备份。运行备份后,我们希望确保重新启动该过程,而不管备份过程本身如何进行。
现在,第一种方法是创建一个巨大的单片函数,尝试在同一个位置执行所有操作,停止服务,执行备份任务,处理异常和所有可能的边缘情况,然后再次尝试重新启动服务。您可以想象这样一个功能,出于这个原因,我将不告诉您详细信息,而是直接提出一种可能的方法,用上下文管理器解决这个问题:
def stop_database():
run("systemctl stop postgresql.service")
def start_database():
run("systemctl start postgresql.service")
class DBHandler:
def __enter__(self):
stop_database()
return self
def __exit__(self, exc_type, ex_value, ex_traceback):
start_database()
def db_backup():
run("pg_dump database")
def main():
with DBHandler():
db_backup()
在这个例子中,我们不需要块内的上下文管理器的结果,这就是为什么我们可以考虑,至少对于这个特定的情况,ORT T0 的返回值是无关的。这是在设计上下文管理器时需要考虑的问题。一旦块启动,我们需要什么?一般来说,在__enter__
上总是返回一些东西应该是一种良好的实践(尽管不是强制性的)。
在这个模块中,我们只运行备份任务,独立于维护任务,正如前面所看到的。我们还提到,即使备份任务有错误,仍然会调用__exit__
。
注意__exit__
方法的签名。它接收在块上引发的异常的值。如果该区块没有异常,那么它们都是无异常的。
值得一提的是返回值。通常,我们希望保持方法的原样,而不返回任何特定的内容。如果此方法返回True
,则意味着可能引发的异常将不会传播到调用方,并将在那里停止。有时,这是期望的效果,甚至可能取决于引发的异常类型,但一般来说,吞下异常不是一个好主意。记住:错误永远不应该悄无声息地过去。
切记不要在__exit__
上意外返回True
。如果你这样做了,确保这正是你想要的,并且有一个很好的理由。
通常,我们可以实现上一个示例中的上下文管理器。我们所需要的只是一个实现__enter__
和__exit__
魔术方法的类,然后该对象将能够支持上下文管理器协议。虽然这是实现上下文管理器最常见的方法,但它不是唯一的方法。
在本节中,我们不仅将看到实现上下文管理器的不同(有时更紧凑)方法,还将看到如何通过使用标准库(尤其是contextlib
模块)充分利用它们。
contextlib
模块包含许多帮助函数和对象,用于实现上下文管理器或使用已经提供的帮助我们编写更紧凑代码的工具。
让我们先看看contextmanager
装饰师。
当contextlib.contextmanager
装饰器应用于函数时,它将该函数上的代码转换为上下文管理器。所讨论的函数必须是一种称为generator
函数的特殊函数,它将把语句分别划分为__enter__
和__exit__
魔术方法中的语句。
如果在这一点上,您不熟悉装饰器和生成器,那么这不是问题,因为我们将要查看的示例是自包含的,并且不管怎样,都可以应用和理解配方或惯用法。这些主题将在第 7 章、生成器、迭代器和异步编程中详细讨论。
前面示例的等效代码可以用contextmanager
装饰符重写,如下所示:
import contextlib
@contextlib.contextmanager
def db_handler():
try:
stop_database()
yield
finally:
start_database()
with db_handler():
db_backup()
在这里,我们定义了generator
函数,并对其应用@contextlib.contextmanager
修饰符。该函数包含一个yield
语句,使其成为一个generator
函数。同样,发电机的详细信息与本案例无关。我们需要知道的是,当应用这个 decorator 时,yield
语句之前的所有内容都将被运行,就好像它是__enter__
方法的一部分一样。然后,产生的值将是上下文管理器评估的结果(返回的是什么?__enter__
将返回什么),如果我们选择像as x
那样分配变量,将分配给变量什么:-在这种情况下,不会产生任何结果(这意味着产生的值将是无的,隐式的),但是如果我们想要,我们可以生成一条语句,该语句将成为我们可能希望在上下文管理器块中使用的内容。
此时,generator
函数被挂起,进入上下文管理器,再次运行数据库的备份代码。完成之后,执行重新开始,因此我们可以考虑,在 OutT1 语句之后出现的每一行都将是 AUT2 T2 逻辑的一部分。
编写这样的上下文管理器有一个优点,即重构现有函数、重用代码更容易,通常,当我们需要一个不属于任何特定对象的上下文管理器时,这是一个好主意(否则,在面向对象的意义上,您将创建一个没有实际用途的“假”类)。
添加额外的魔法方法将使我们领域的另一个对象更加耦合,承担更多的责任,并支持它可能不应该支持的东西。当我们只需要一个上下文管理器函数,而不保留许多状态,并且与其他类完全隔离和独立时,这可能是一个很好的方法。
然而,有更多的方法可以实现上下文管理器,答案再一次出现在标准库的contextlib
包中。
我们可以使用的另一个助手是contextlib.ContextDecorator
。这是一个基类,它提供了将修饰符应用于函数的逻辑,该函数将使其在上下文管理器中运行。上下文管理器本身的逻辑必须通过实现上述神奇方法来提供。结果是一个类可以作为函数的装饰器,或者可以混合到其他类的类层次结构中,使它们作为上下文管理器。
为了使用它,我们必须扩展此类并在所需方法上实现逻辑:
class dbhandler_decorator(contextlib.ContextDecorator):
def __enter__(self):
stop_database()
return self
def __exit__(self, ext_type, ex_value, ex_traceback):
start_database()
@dbhandler_decorator()
def offline_backup():
run("pg_dump database")
您是否注意到与前面的示例不同的地方?没有with
声明。我们只需调用函数,offline_backup()
将自动在上下文管理器中运行。这是基类提供的逻辑,用于将其用作包装原始函数的装饰器,从而使其在上下文管理器中运行。
这种方法唯一的缺点是,通过对象的工作方式,它们是完全独立的(这是一个很好的特性)——装饰者对正在装饰的函数一无所知,反之亦然。无论多么好,这意味着如果需要的话,offline_backup
函数无法访问 decorator 对象。但是,没有什么可以阻止我们仍然在函数中调用这个装饰器来访问对象。
这可以通过以下形式完成:
def offline_backup():
with dbhandler_decorator() as handler: ...
作为一个 decorator,这个还有一个优点,即逻辑只定义一次,我们可以通过简单地将 decorator 应用于需要相同不变逻辑的其他函数,来重复使用它。
让我们探讨一下contextlib
的最后一个功能,看看我们可以从上下文管理器中得到什么,并了解我们可以使用它们来做什么。
在这个库中,我们可以找到contextlib.suppress
,这是一个实用程序,可以在我们知道忽略某些异常是安全的情况下避免它们。这类似于在try/except
块上运行相同的代码并传递异常或只是记录异常,但区别在于调用suppress
方法使这些异常作为逻辑的一部分得到控制变得更加明确。
例如,考虑下面的代码:
import contextlib
with contextlib.suppress(DataConversionException):
parse_data(input_json_or_dict)
这里,异常的存在意味着输入数据已经是预期的格式,因此不需要转换,因此可以安全地忽略它。
上下文管理器是 Python 的一个独特特性。因此,可以认为使用上下文管理器是惯用的。在下一节中,我们将探讨 Python 的另一个有趣特性,它将帮助我们编写更简洁的代码;理解和赋值表达式。
我们将在整本书中多次看到理解表达。这是因为它们通常是一种更简洁的代码编写方式,而且一般来说,以这种方式编写的代码更易于阅读。我一般说来,因为有时如果我们需要对收集的数据进行一些转换,使用理解可能会导致一些更复杂的代码。在这些情况下,最好编写一个简单的for
循环。
然而,我们可以运用最后一种手段来挽救这种局面:赋值表达式。在本节中,我们将讨论这些备选方案。
建议使用理解在一条指令中创建数据结构,而不是在多个操作中创建数据结构。例如,如果我们想创建一个列表,其中包含对某些数字的计算,而不是这样写:
numbers = []
for i in range(10):
numbers.append(run_calculation(i))
我们将直接创建列表:
numbers = [run_calculation(i) for i in range(10)]
以这种形式编写的代码通常性能更好,因为它使用单个 Python 操作,而不是重复调用list.append
。如果您对代码的内部结构或不同版本之间的差异感到好奇,可以查看dis
模块,并用这些示例调用它。
让我们看一个函数示例,该函数将获取一些表示云计算环境(例如 ARN)上资源的字符串,并返回包含在这些字符串上找到的帐户 ID 的集合。这样写函数是最天真的方式:
from typing import Iterable, Set
def collect_account_ids_from_arns(arns: Iterable[str]) -> Set[str]:
"""Given several ARNs in the form
arn:partition:service:region:account-id:resource-id
Collect the unique account IDs found on those strings, and return them.
"""
collected_account_ids = set()
for arn in arns:
matched = re.match(ARN_REGEX, arn)
if matched is not None:
account_id = matched.groupdict()["account_id"]
collected_account_ids.add(account_id)
return collected_account_ids
很明显,代码有很多行,它做的事情相对简单。此代码的读者可能会被这些多个语句弄糊涂,可能在使用该代码时无意中出错。如果我们能简化它,那就更好了。通过以类似于函数式编程的方式使用一些理解表达式,我们可以在更少的行中实现相同的功能:
def collect_account_ids_from_arns(arns):
matched_arns = filter(None, (re.match(ARN_REGEX, arn) for arn in arns))
return {m.groupdict()["account_id"] for m in matched_arns}
函数的第一行类似于应用map
和filter
:首先,我们将尝试匹配正则表达式的结果应用于提供的所有字符串,然后过滤那些不是None
的字符串。结果是一个迭代器,我们稍后将使用它在集合理解表达式中提取帐户 ID。
前面的函数应该比第一个示例更易于维护,但仍然需要两条语句。在 Python3.8 之前,不可能实现更紧凑的版本。但随着 PEP-572(中赋值表达式的引入 https://www.python.org/dev/peps/pep-0572/ ),我们可以在一条语句中重写:
def collect_account_ids_from_arns(arns: Iterable[str]) -> Set[str]:
return {
matched.groupdict()["account_id"]
for arn in arns
if (matched := re.match(ARN_REGEX, arn)) is not None
}
注意理解中第三行的语法。这将在作用域内设置临时标识符,该标识符是将正则表达式应用于字符串的结果,并且可以在同一作用域内的更多部分中重用。
在这个特定的例子中,如果第三个例子比第二个好,这是有争议的(但毫无疑问,他们两个都比第一个好!)。我认为最后一个示例更具表现力,因为它在代码中有较少的间接含义,读者需要知道的关于如何收集值的所有内容都属于相同的范围。
请记住,更紧凑的代码并不总是意味着更好的代码。如果要写一行代码,我们必须创建一个复杂的表达式,那么它就不值得了,我们最好还是用幼稚的方法。这与我们将在下一章讨论的保持简单原则有关。
考虑到理解表达式的可读性,如果这一行代码实际上不容易理解,不要强迫您的代码是一行代码。
一般使用赋值表达式的另一个很好的原因(不仅仅是在理解中)是性能方面的考虑。如果我们必须使用一个函数作为转换逻辑的一部分,我们不想调用太多。将函数的结果分配给临时标识符(就像在新范围中分配表达式所做的那样)将是一种很好的优化技术,同时可以使代码更具可读性。
评估可通过使用赋值表达式进行的性能改进。
在下一节中,我们将回顾 Python 的另一个惯用特性:properties
。此外,我们将讨论在 Python 对象中公开或隐藏数据的不同方式。
一个对象的所有属性和函数在 Python 中都是public
,这与其他语言不同属性可以是public
、private
或protected
。也就是说,阻止调用方对象调用对象具有的任何属性是没有意义的。这是与其他编程语言相比的另一个区别,在其他编程语言中,您可以将某些属性标记为private
或protected
。
没有严格的执行,但有一些公约。以下划线开头的属性应该是该对象的private
,我们希望没有外部代理调用它(但同样,没有什么可以阻止这一点)。
在开始讨论properties
的细节之前,值得一提的是 Python 中下划线的一些特性,了解约定和属性的范围。
Python 中有一些约定和实现细节使用了下划线,这是一个值得分析的有趣主题。
如前所述,默认情况下,对象的所有属性都是public
。考虑下面的例子来说明这一点:
>>> class Connector:
... def __init__(self, source):
... self.source = source
... self._timeout = 60
...
>>> conn = Connector("postgresql://localhost")
>>> conn.source
'postgresql://localhost'
>>> conn._timeout
60
>>> conn.__dict__
{'source': 'postgresql://localhost', '_timeout': 60}
这里,一个Connector
对象是用source
创建的,它从前面提到的两个属性source
和timeout
开始。前者为public
,后者为private
。然而,当我们创建这样一个对象时,从下面几行可以看到,我们实际上可以访问这两个对象。
这段代码的解释是_timeout
只能在connector
内部访问,不能从调用方访问。这意味着您应该以某种方式组织代码,以便您可以在需要超时的任何时候安全地重构超时,这取决于它不是从对象外部(仅在内部)调用的,因此保留了与以前相同的接口。遵守这些规则使代码更易于维护,也更健壮,因为如果我们维护对象的接口,在重构代码时就不必担心连锁反应。同样的原则也适用于方法。
类应该只公开那些与外部调用方对象相关的属性和方法,即包含其接口的属性和方法。严格来说不是对象接口一部分的所有内容都应以一个下划线作为前缀。
以下划线开头的属性必须被视为private
,不能在外部调用。另一方面,作为这条规则的一个例外,我们可以说在单元测试中,如果这使得测试更容易,那么它可能被允许访问内部属性(但是请注意,当您决定重构主类时,坚持这种实用的方法仍然会受到可维护性成本的影响)。但是,请记住以下建议:
使用太多的内部方法和属性可能表明类有太多的任务,并且不符合单一责任原则。这可能表明您需要将它的一些职责提取到更多的协作类中。
使用一个下划线作为前缀是一种明确划分对象接口的 python 方式。然而,有一种常见的误解,即某些属性和方法实际上是可以生成的private
。这又是一种误解。让我们想象一下,timeout
属性现在定义为一个前导双下划线:
>>> class Connector:
... def __init__(self, source):
... self.source = source
... self.__timeout = 60
...
... def connect(self):
... print("connecting with {0}s".format(self.__timeout))
... # ...
...
>>> conn = Connector("postgresql://localhost")
>>> conn.connect()
connecting with 60s
>>> conn.__timeout
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: 'Connector' object has no attribute '__timeout'
一些开发人员使用此方法隐藏某些属性,认为timeout
现在是private
,没有其他对象可以修改它,就像在本例中一样。现在,看看在尝试访问__timeout
时引发的异常。它是AttributeError
,说它不存在。它没有说“这是私人的”或“这不能被访问”,等等。它说它不存在。这应该给我们一个线索,事实上,一些不同的事情正在发生,这种行为只是一种副作用,而不是我们想要的真正效果。
实际情况是,使用双下划线,Python 为属性创建了一个不同的名称(这称为名称混乱)。它所做的是创建具有以下名称的属性:"_<class-name>__<attribute-name>"
。在这种情况下,将创建一个名为'_Connector__timeout'
的属性,该属性可以按如下方式访问(和修改):
>>> vars(conn)
{'source': 'postgresql://localhost', '_Connector__timeout': 60}
>>> conn._Connector__timeout
60
>>> conn._Connector__timeout = 30
>>> conn.connect()
connecting with 30s
请注意我们前面提到的副作用,该属性仍然存在,只是名称不同,因此,在我们第一次尝试访问它时,就引发了AttributeError
。
Python 中双下划线的概念完全不同。创建它是为了覆盖将要扩展多次的类的不同方法,而不存在与方法名发生冲突的风险。即使是这样的用例也太牵强,无法证明使用这种机制是合理的。
双下划线是一种非 Python 方法。如果需要将属性定义为private
,请使用一个下划线,并遵守 Pythonic 约定,即它是private
属性。
不要使用前导双下划线定义属性。
同样,不要定义自己的“dunder”方法(名称被双下划线包围的方法)
现在让我们来探讨另一种情况,即,当我们确实希望访问对象的某些属性时,这些属性将被指定为public
。通常,我们会使用properties
来实现这一点,我们将在下一节中对此进行探讨。
通常,在面向对象的设计中,我们创建对象来表示域问题实体的抽象。从这个意义上讲,对象可以封装行为或数据。通常情况下,数据的准确性决定了是否可以创建对象。也就是说,某些实体只能存在于数据的某些值中,而不允许存在不正确的值。
这就是我们创建验证方法的原因,通常用于setter
操作。然而,在 Python 中,有时我们可以使用properties
更紧凑地封装这些setter
和getter
方法。
考虑一个需要处理坐标的地理系统的例子。纬度和经度只有一定范围的值是有意义的。在这些值之外,坐标不可能存在。我们可以创建一个对象来表示坐标,但在这样做时,我们必须确保纬度值始终在可接受的范围内。为此,我们可以使用properties
:
class Coordinate:
def __init__(self, lat: float, long: float) -> None:
self._latitude = self._longitude = None
self.latitude = lat
self.longitude = long
@property
def latitude(self) -> float:
return self._latitude
@latitude.setter
def latitude(self, lat_value: float) -> None:
if lat_value not in range(-90, 90 + 1):
raise ValueError(f"{lat_value} is an invalid value for latitude")
self._latitude = lat_value
@property
def longitude(self) -> float:
return self._longitude
@longitude.setter
def longitude(self, long_value: float) -> None:
if long_value not in range(-180, 180 + 1):
raise ValueError(f"{long_value} is an invalid value for longitude")
self._longitude = long_value
这里,我们使用属性来定义纬度和经度。在这样做时,我们确定检索这些属性中的任何一个都将返回private
变量中保存的内部值。更重要的是,当任何用户想要以以下形式修改这些properties
中任何一个的值时:
coordinate.latitude = <new-latitude-value> # similar for longitude
使用@latitude.setter
修饰符声明的验证方法将被自动(透明)调用,并将语句(<new-latitude-value>
右侧的值作为参数传递(在前面的代码中命名为lat_value
。
不要为对象上的所有属性编写自定义get_*
和set_*
方法。大多数情况下,将它们作为常规属性就足够了。如果需要修改检索或修改属性时的逻辑,请使用properties
。
我们已经了解了对象何时需要保存值,以及properties
如何帮助我们以一致和透明的方式管理其内部数据,但有时,我们可能还需要根据对象及其内部数据的状态进行一些计算。在大多数情况下,房产是一个很好的选择。
例如,如果您有一个需要以特定格式或数据类型返回值的对象,则可以使用属性来执行此计算。在上一个示例中,如果我们决定返回精度高达四位小数的坐标(无论原始数字提供了多少位小数),我们可以通过读取值的@property
方法对此进行四舍五入计算。
您可能会发现,属性是实现命令和查询分离的好方法(CC08
。命令和查询分离原则规定,对象的方法应该要么回答某个问题,要么执行某个问题,但不能两者兼而有之。如果一个方法正在做某件事,同时它返回一个状态来回答该操作如何进行的问题,那么它正在做不止一件事,这显然违反了函数应该做一件事,而且只能做一件事的原则。
根据方法的名称,这可能会造成更多的混乱,使读者更难理解代码的实际意图。例如,如果一个方法被称为set_email
,我们将其用作if self.set_email("[email protected]"): ...
,那么该代码在做什么?是否将电子邮件设置为[email protected]?
是否检查电子邮件是否已设置为该值?两者(设置,然后检查状态是否正确)?
通过properties
,我们可以避免这种混乱。@property
修饰符是将对某个内容进行回答的查询,@<property_name>.setter
是将执行某个操作的命令。
从这个例子中得到的另一条好建议如下:在一个方法中不要做多于一件的事情。如果要分配某个值,然后检查该值,请将其分解为两个或多个语句。
为了说明这意味着什么,使用前面的示例,我们将使用一个setter
或 getter 方法来设置用户的电子邮件,然后使用另一个属性来简单地请求电子邮件。这是因为,一般来说,任何时候我们询问一个对象的当前状态,它都应该返回它,而不会产生副作用(不会改变其内部表示)。对于这个规则,我能想到的唯一例外可能是惰性属性:我们只想预计算一次,然后使用计算出的值。对于其他情况,请尝试使属性幂等,然后尝试使用允许更改对象内部表示形式的方法,但不要同时使用这两种方法。
方法只能做一件事。如果必须运行一个操作,然后检查状态,请在由不同语句调用的不同方法中执行该操作。
继续我们的想法,有时我们需要对象来保存值,当涉及到对象的初始化时,Python 中有一个通用的样板,即在__init__
方法中声明对象将拥有的所有属性,然后将其设置为内部变量,通常采用以下形式:
def __init__(self, x, y, … ):
self.x = x
self.y = y
自 Python 3.7 以来,我们可以通过使用dataclasses
模块来简化此过程。PEP-557 对此进行了介绍。在上一章中,我们已经在代码上使用注释的上下文中看到了这个模块,在这里,我们将简要回顾一下它如何帮助我们编写更紧凑的代码。
这个模块提供了一个@dataclass
修饰符,当它应用于一个类时,它将获取所有带有注释的类属性,并将它们视为实例属性,就像它们在初始化方法中声明一样。当使用这个装饰器时,它会自动在类上生成__init__
方法,所以我们不必这样做。
此外,该模块还提供了一个field
对象,它将帮助我们定义某些属性的特定特征。例如,如果我们需要的一个属性是可变的(例如list
,我们将在本章后面(在 Python 中避免警告的部分)看到,我们不能在__init__
方法中传递此默认空列表,而是应该传递None
,并将其设置为__init__
中的默认列表,如果None
已提供。
当使用field
对象时,我们要做的是使用default_factory
参数,并为其提供list
类。此参数用于不接受任何参数的可调用对象,并将在未为该属性的值提供任何内容时被调用以构造对象。
因为没有要实现的__init__
方法,如果我们需要运行验证,会发生什么?或者,如果我们想计算一些属性,或者从以前的属性派生出一些属性?要回答后者,我们可以依赖properties
,正如我们在上一节中所探讨的那样。按照前者,数据类允许我们有一个由__init__
自动调用的__post_init__
方法,因此这将是编写初始化后逻辑的好地方。
为了把所有这一切付诸实践,让我们考虑为 R TIE 数据结构建模一个节点的例子(其中 R 代表 Po.T7 根,No.Ty8t,这意味着它是某个基础 R 上的索引树)。此数据结构的详细信息以及与之相关的算法超出了本书的范围,但就本示例而言,我将提到这是一种数据结构,旨在回答对文本或字符串的查询(例如前缀,以及查找类似或相关的词)。在一种非常基本的形式中,该数据结构包含一个值(例如,它包含一个字符,可以是它的整数表示),然后是一个数组或长度 R,并引用下一个节点(这是一个递归数据结构,与linked list
或tree
的含义相同)。其思想是数组的每个位置都隐式定义了对下一个节点的引用。例如,假设值0
被映射到字符'a'
,那么如果下一个节点在其0
位置包含一个不同于None
的值,那么这意味着有一个'a'
的引用,该引用指向另一个 R-Trie 节点。
从图形上看,数据结构可能如下所示:
图 2.1:R-Trie 节点的通用结构
我们可以编写如下代码块来表示它。在下面的代码中,名为next_
的属性包含一个尾随下划线,这是将其与内置next
函数区分开来的一种方式。我们可以说,在这种情况下,没有冲突,但是如果我们需要在RTrieNode
类中使用next()
函数,这可能会有问题(通常很难捕捉到细微的错误):
from typing import List
from dataclasses import dataclass, field
R = 26
@dataclass
class RTrieNode:
size = R
value: int
next_: List["RTrieNode"] = field(
default_factory=lambda: [None] * R)
def __post_init__(self):
if len(self.next_) != self.size:
raise ValueError(f"Invalid length provided for next list")
前面的示例包含几个不同的组合。首先,我们用R=26
定义一个 R-Trie 来表示英语字母表中的字符(这对于理解代码本身并不重要,但它提供了更多上下文)。这个想法是,如果我们想存储一个单词,我们就从第一个字母开始为每个字母创建一个节点。当链接到下一个字符时,我们将其存储在对应于该字符的next_
数组的位置,以及对应于该字符的另一个节点,依此类推。
注意类中的第一个属性:size
。这一个没有注释,因此它是一个常规类属性(所有节点对象共享),而不是专门属于该对象的属性。或者,我们可以通过设置field(init=False)
来定义它,但这种形式更紧凑。但是,如果我们想注释变量,但不把它看作是 OutT2 席的一部分,那么这个语法是唯一可行的选择。
然后遵循另外两个属性,它们都有注释,但考虑因素不同。第一个value
是一个整数,但它没有默认参数,因此当我们创建一个新节点时,我们必须始终提供一个值作为第一个参数。第二个是可变参数(本身是一个list
),它确实有一个默认工厂:在本例中,一个lambda
函数将创建一个大小为 R 的新列表,在所有插槽上用None
初始化。请注意,如果我们使用field(default_factory=list)
进行此操作,我们仍然会在创建时为每个对象构建一个新列表,但这将失去对该列表长度的控制。最后,我们想验证我们没有创建具有错误长度的下一个节点列表的节点,因此这在__post_init__
方法中得到验证。任何创建此类列表的尝试都将在初始化时使用ValueError
来阻止。
数据类提供了一种更简洁的编写类的方法,而不必在__init__
方法中设置所有同名变量。
当您的对象没有对数据进行许多复杂的验证或转换时,请考虑此备选方案。记住最后一点。注释很好,但它们不强制执行数据转换。这意味着,例如,如果您声明的属性需要是float
或integer
,则必须在__init__
方法中进行此转换。将其作为数据类编写是不行的,它可能会隐藏一些细微的错误。这适用于不严格要求验证且可以进行类型转换的情况。例如,定义一个可以从多个其他类型创建的对象是非常好的,比如从数字string
转换为float
(毕竟,这利用了 Python 的动态类型特性),前提是该对象在__init__
方法中正确转换为所需的数据类型。
当我们需要将对象用作数据容器或包装器时,数据类的一个很好的用例可能就是所有这些地方,即使用命名元组或简单名称空间的情况。当您正在评估代码中的选项时,将数据类视为命名元组或命名空间的另一种选择。
在 Python 中,我们有可以在默认情况下迭代的对象。例如,列表、元组、集合和字典不仅可以保存我们想要的结构中的数据,还可以通过for
循环进行迭代以重复获取这些值。
然而,内置的iterable
对象并不是for
循环中的唯一类型。我们还可以创建自己的iterable
,使用我们为迭代定义的逻辑。
为了实现这一点,我们再次依靠神奇的方法。
迭代通过自己的协议(即iterator
协议)在 Python 中工作。当您尝试以for e in myobject:...
形式迭代一个对象时,Python 在非常高的级别上检查的是以下两件事情,顺序如下:
- 如果对象包含迭代器方法之一-
__next__
或__iter__
- 如果对象是序列且具有
__len__
和__getitem__
因此,作为一种回退机制,序列可以迭代,因此有两种方法可以定制对象,使其能够处理for
循环。
当我们尝试迭代一个对象时,Python 将在其上调用iter()
函数。这个函数首先要检查的事情之一是该对象上是否存在__iter__
方法,如果存在,将执行该方法。
以下代码创建了一个对象,该对象允许在一系列日期上进行迭代,在循环的每一轮中每次生成一天:
from datetime import timedelta
class DateRangeIterable:
"""An iterable that contains its own iterator object."""
def __init__(self, start_date, end_date):
self.start_date = start_date
self.end_date = end_date
self._present_day = start_date
def __iter__(self):
return self
def __next__(self):
if self._present_day >= self.end_date:
raise StopIteration()
today = self._present_day
self._present_day += timedelta(days=1)
return today
此对象被设计为使用一对日期创建,当迭代时,它将在指定的日期间隔内生成每天,如下代码所示:
>>> from datetime import date
>>> for day in DateRangeIterable(date(2018, 1, 1), date(2018, 1, 5)):
... print(day)
...
2018-01-01
2018-01-02
2018-01-03
2018-01-04
>>>
在这里,for
循环正在我们的对象上开始一个新的迭代。此时,Python 将调用其上的iter()
函数,而该函数又将调用__iter__
魔术方法。在这个方法中,它被定义为返回self
,表示对象本身就是iterable
,因此在这个点上,循环的每个步骤都将调用该对象上的next()
函数,该函数将委托给__next__
方法。在这个方法中,我们决定如何生成元素并一次返回一个。当没有其他东西要产生时,我们必须通过引发StopIteration
异常向 Python 发出信号。
这意味着实际发生的情况类似于 Python 每次在我们的对象上调用next()
,直到出现StopIteration
异常,它知道必须停止for
循环:
>>> r = DateRangeIterable(date(2018, 1, 1), date(2018, 1, 5))
>>> next(r)
datetime.date(2018, 1, 1)
>>> next(r)
datetime.date(2018, 1, 2)
>>> next(r)
datetime.date(2018, 1, 3)
>>> next(r)
datetime.date(2018, 1, 4)
>>> next(r)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File ... __next__
raise StopIteration
StopIteration
>>>
这个例子是可行的,但它有一个小问题,一旦用完,iterable
将继续为空,因此提升StopIteration
。这意味着,如果我们在两个或多个连续的for
循环上使用此选项,则只有第一个循环有效,而第二个循环为空:
>>> r1 = DateRangeIterable(date(2018, 1, 1), date(2018, 1, 5))
>>> ", ".join(map(str, r1))
'2018-01-01, 2018-01-02, 2018-01-03, 2018-01-04'
>>> max(r1)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
ValueError: max() arg is an empty sequence
>>>
这是因为迭代协议的工作方式,iterable
构造了一个迭代器,而这个迭代器就是被迭代的迭代器。在我们的示例中,__iter__
刚刚返回self
,但我们可以让它在每次调用时创建一个新的迭代器。解决此问题的一种方法是创建DateRangeIterable
的新实例,这不是一个可怕的问题,但我们可以让__iter__
使用生成器(迭代器对象),每次都会创建:
class DateRangeContainerIterable:
def __init__(self, start_date, end_date):
self.start_date = start_date
self.end_date = end_date
def __iter__(self):
current_day = self.start_date
while current_day < self.end_date:
yield current_day
current_day += timedelta(days=1)
这一次它起作用了:
>>> r1 = DateRangeContainerIterable(date(2018, 1, 1), date(2018, 1, 5))
>>> ", ".join(map(str, r1))
'2018-01-01, 2018-01-02, 2018-01-03, 2018-01-04'
>>> max(r1)
datetime.date(2018, 1, 4)
>>>
不同之处在于,每个for
循环都在再次调用__iter__
,而这些循环中的每一个都在再次创建生成器。
这称为容器iterable
。
一般来说,在处理生成器时使用容器可伸缩性是一个好主意。
有关生成器的详细信息将在第 7 章、生成器、迭代器和异步编程中进行更详细的解释。
也许我们的对象没有定义__iter__()
方法,但是我们仍然希望能够对其进行迭代。如果对象上未定义__iter__
,则iter()
函数将查找__getitem__
的存在,如果未找到,则将引发TypeError
。
序列是一个实现了__len__
和__getitem__
的对象,它希望能够获得它所包含的元素,一次一个,顺序从零开始作为第一个索引。这意味着您应该在逻辑上小心,以便正确地实现__getitem__
以期望这种类型的索引,否则迭代将无法工作。
上一节中的示例的优点是使用更少的内存。这意味着它一次只保存一个日期,并且知道如何一个接一个地生成日期。然而,它有一个缺点,如果我们想要得到第 n个元素,我们没有办法,只能迭代 n 次直到达到它。这是计算机科学中内存和 CPU 使用之间的典型权衡。
使用iterable
的实现将使用更少的内存,但获取元素需要*O(n)*的时间,而实现序列将使用更多的内存(因为我们必须同时保存所有内容),但支持在恒定时间内进行索引,O(1)。
前面的表示法(例如,*O(n)称为渐近表示法(或“大 O”表示法),它描述了算法的复杂度顺序。在非常高的级别上,这意味着算法需要执行多少操作,作为输入(n)*大小的函数。有关这方面的更多信息,您可以查看本章末尾列出的(ALGO01),其中包含对渐近符号的详细研究。
这就是新实现的样子:
class DateRangeSequence:
def __init__(self, start_date, end_date):
self.start_date = start_date
self.end_date = end_date
self._range = self._create_range()
def _create_range(self):
days = []
current_day = self.start_date
while current_day < self.end_date:
days.append(current_day)
current_day += timedelta(days=1)
return days
def __getitem__(self, day_no):
return self._range[day_no]
def __len__(self):
return len(self._range)
以下是对象的行为方式:
>>> s1 = DateRangeSequence(date(2018, 1, 1), date(2018, 1, 5))
>>> for day in s1:
... print(day)
...
2018-01-01
2018-01-02
2018-01-03
2018-01-04
>>> s1[0]
datetime.date(2018, 1, 1)
>>> s1[3]
datetime.date(2018, 1, 4)
>>> s1[-1]
datetime.date(2018, 1, 4)
在前面的代码中,我们可以看到负索引也起作用。这是因为DateRangeSequence
对象将所有操作委托给其包装对象(alist
,这是保持兼容性和一致性行为的最佳方式。
在决定使用两种可能的实现中的哪一种时,评估内存和 CPU 使用之间的权衡。一般来说,迭代更可取(生成器更可取),但要记住每种情况的需求。
容器是实现__contains__
方法(通常返回Boolean
值)的对象。这个方法是在 Python 的in
关键字存在的情况下调用的。
如下所示:
element in container
在 Python 中使用时,会变成:
container.__contains__(element)
您可以想象,当正确实现此方法时,代码的可读性(和 Pythonic!)会有多高。
假设我们必须在一个有二维坐标的游戏地图上标出一些点。我们可能希望找到如下函数:
def mark_coordinate(grid, coord):
if 0 <= coord.x < grid.width and 0 <= coord.y < grid.height:
grid[coord] = MARKED
现在,检查第一个if
语句的条件的部分似乎很复杂;它没有揭示代码的意图,没有表现力,最糟糕的是它需要代码复制(在继续之前需要检查边界的代码的每个部分都必须重复if
语句)。
如果地图本身(代码上称为grid
)可以回答这个问题呢?更好的是,如果映射可以将此操作委托给更小(因此更内聚)的对象,该怎么办?
我们可以通过面向对象的设计和神奇的方法以更优雅的方式解决这个问题。在这种情况下,我们可以创建一个新的抽象来表示网格的限制,网格本身可以成为一个对象。图 2.2有助于说明这一点:
图 2.2:使用组合、在不同类中分配职责以及使用容器魔术方法的示例
插上一句,我要说的是,总的来说,类名指的是名词,它们通常是单数。因此,有一个名为Boundaries
的类听起来可能很奇怪,但如果我们考虑一下,也许对于这个特殊情况,可以说我们有一个表示网格所有边界的对象,特别是因为它的使用方式(在本例中,我们使用它来验证特定坐标是否在这些边界内)。
通过这种设计,我们可以询问map
是否包含坐标,map
本身可以有关于其限制的信息,并将查询传递给其内部合作者:
class Boundaries:
def __init__(self, width, height):
self.width = width
self.height = height
def __contains__(self, coord):
x, y = coord
return 0 <= x < self.width and 0 <= y < self.height
class Grid:
def __init__(self, width, height):
self.width = width
self.height = height
self.limits = Boundaries(width, height)
def __contains__(self, coord):
return coord in self.limits
这段代码本身就是一个更好的实现。首先,它做一个简单的组合,并使用委托来解决问题。这两个对象都是真正有凝聚力的,具有尽可能少的逻辑;方法很短,逻辑本身就说明了问题所在-coord in self.limits
几乎是要解决的问题的声明,表达了代码的意图。
从外部看,我们也可以看到好处。就好像 Python 正在为我们解决问题:
def mark_coordinate(grid, coord):
if coord in grid:
grid[coord] = MARKED
可以通过__getattr__
魔术方法控制从对象获取属性的方式。当我们调用类似于<myobject>.<myattribute>
的东西时,Python 将在对象的字典中查找<myattribute>
,并对其调用__getattribute__
。如果找不到该属性(即,该对象没有我们正在查找的属性),则调用额外的方法__getattr__
,将属性(myattribute)的名称作为参数传递。
通过接收此值,我们可以控制将内容返回到对象的方式。我们甚至可以创建新属性,等等。
在下面的清单中,演示了__getattr__
方法:
class DynamicAttributes:
def __init__(self, attribute):
self.attribute = attribute
def __getattr__(self, attr):
if attr.startswith("fallback_"):
name = attr.replace("fallback_", "")
return f"[fallback resolved] {name}"
raise AttributeError(
f"{self.__class__.__name__} has no attribute {attr}"
)
以下是对此类对象的一些调用:
>>> dyn = DynamicAttributes("value")
>>> dyn.attribute
'value'
>>> dyn.fallback_test
'[fallback resolved] test'
>>> dyn.__dict__["fallback_new"] = "new value"
>>> dyn.fallback_new
'new value'
>>> getattr(dyn, "something", "default")
'default'
第一个调用很简单,我们只需要请求对象具有的属性,然后得到它的值。第二个是此方法执行操作的地方,因为对象没有任何名为fallback_test
的内容,所以__getattr__
将使用该值运行。在该方法中,我们放置了返回字符串的代码,我们得到的是转换的结果。
第三个例子很有趣,因为创建了一个名为fallback_new
的新属性(实际上,此调用与运行dyn.``fallback_new = "new value"``)
相同,所以当我们请求该属性时,请注意,我们在__getattr__
中输入的逻辑不适用,仅仅是因为从未调用该代码。
最后一个例子是最有趣的。这里有一个微妙的细节,使巨大的差异。再看一下__getattr__
方法中的代码。注意当值不可检索时它引发的异常,AttributeError
。这不仅是为了一致性(以及异常中的消息),也是内置的getattr()
功能所需要的。如果此异常是任何其他异常,它将引发,并且不会返回默认值。
在实现像__getattr__
这样动态的方法时要小心,并谨慎使用。执行__getattr__
时,提高AttributeError
。
__getattr__
魔术法在很多情况下都很有用。它可用于创建另一个对象的代理。例如,如果您正在通过组合方式在另一个包装器对象上创建一个包装器对象,并且您希望将大多数方法委托给包装器对象,而不是复制和定义所有这些方法,那么您可以实现将在包装器对象上内部调用相同方法的__getattr__
。
另一个例子是当您知道需要动态计算的属性时。我在过去的一个项目中使用过它,与GraphQL
(一起工作 https://graphql.org/ 带Graphene
(的)https://graphene-python.org/ 。该库的工作方式是通过使用解析器方法。基本上,当请求属性X
时,每个名为resolve_X
的方法都被使用。因为已经有域对象可以解析Graphene
对象类中的每个属性X
,所以实现__getattr__
是为了知道从何处获取每个属性,而无需编写大量的样板代码。
当你看到避免大量重复代码和样板文件的机会时,请使用__getattr__
魔术方法,但不要滥用这种方法,因为它会使代码更难理解和解释。请记住,如果属性没有显式声明,只是动态显示,那么代码将更难理解。当使用这种方法时,您总是权衡代码的紧凑性和可维护性。
定义可以作为函数的对象是可能的(并且通常是方便的)。最常见的应用之一是创建更好的装饰器,但并不限于此。
当我们试图像执行常规函数一样执行对象时,会调用神奇的方法__call__
。传递给它的每个参数都将传递给__call__
方法。
通过对象以这种方式实现函数的主要优点是对象具有状态,因此我们可以跨调用保存和维护信息。这意味着,如果我们需要在不同调用之间维护内部状态,那么使用callable
对象可能是实现函数的更方便的方法。这方面的例子可以是我们希望通过记忆实现的函数,也可以是内部缓存。
当我们有一个对象时,像这样的语句object(*args, **kwargs)
在 Python 中被翻译成object.__call__(*args, **kwargs)
。
当我们想要创建作为参数化函数工作的可调用对象时,或者在某些情况下,创建带有内存的函数时,此方法非常有用。
下面的列表使用此方法构造一个对象,当使用参数调用该对象时,该对象返回使用相同值调用该对象的次数:
from collections import defaultdict
class CallCount:
def __init__(self):
self._counts = defaultdict(int)
def __call__(self, argument):
self._counts[argument] += 1
return self._counts[argument]
此类活动的一些例子如下:
>>> cc = CallCount()
>>> cc(1)
1
>>> cc(2)
1
>>> cc(1)
2
>>> cc(1)
3
>>> cc("something")
1
>>> callable(cc)
True
在本书的后面,我们将发现这种方法在创建装饰器时非常方便。
我们可以将前面章节中描述的概念总结为备忘单的形式,如下所示。对于 Python 中的每个操作,都会介绍涉及的神奇方法,以及它所代表的概念:
| 陈述 | 魔术 | 行为 | | `obj[key]``obj[i:j]``obj[i:j:k]` | `__getitem__(key)` | 可下标对象 | | `with obj: ...` | `__enter__ / __exit__` | 上下文管理器 | | `for i in obj: ...` | `__iter__ / __next__``__len__ / __getitem__` | 可编辑对象序列 | | `obj.` | `__getattr__` | 动态属性检索 | | `obj(*args, **kwargs)` | `__call__(*args, **kwargs)` | 可调用对象 |表 2.1:Python 中的魔术方法及其行为
正确实现这些方法(以及了解需要一起实现的方法集)的最佳方法是声明我们的类,以按照collections.abc
模块(中定义的抽象基类)实现相应的类 https://docs.python.org/3/library/collections.abc.html#collections-抽象基类。这些接口提供了需要实现的方法,因此您可以更轻松地正确定义类,并且还可以正确创建类型(在对象上调用isinstance()
函数时,这一点非常有效)。
我们已经看到了 Python 特有语法的主要特性。通过我们学习的特性(上下文管理器、可调用对象、创建我们自己的序列等等),我们现在能够编写与 Python 的保留字很好地混合的代码(例如,我们可以将with
语句与我们自己的上下文管理器一起使用,或者将in
操作符与我们自己的容器一起使用)
通过实践和经验,您将能够更加熟练地使用 Python 的这些特性,直到您将正在编写的逻辑封装在具有漂亮和小型接口的抽象后面成为第二天性。给它足够的时间,就会产生相反的效果:Python 将开始为您编程。也就是说,你会自然而然地想到在你的程序中有小而干净的界面,所以即使你用不同的语言创建软件,你也会尝试使用这些概念。例如,如果您发现自己在 Java 或 C(甚至是 Bash)中编程,您可能会发现一个上下文管理器可能有用的场景。现在,语言本身可能不支持这种开箱即用的方式,但这可能不会阻止您编写自己的抽象,以提供类似的保证。这是件好事。这意味着你已经内化了一种特定语言之外的好概念,你可以在不同的情况下应用它们。
所有编程语言都有它们的警告,Python 也不例外,因此为了更全面地理解 Python,我们将在下一节中回顾其中的一些。
除了理解语言的主要特征外,能够编写惯用代码还意味着意识到某些惯用语的潜在问题,以及如何避免这些问题。在本节中,我们将探讨一些常见问题,如果这些问题让您措手不及,可能会导致长时间的调试会话。
本节中讨论的大多数要点都是需要完全避免的,我敢说,几乎没有任何可能的场景可以证明反模式(或习语,在本例中)的存在是合理的。因此,如果您在正在处理的代码库中发现了这一点,可以按照建议的方式进行重构。如果您在进行代码审查时发现了这些特性,这就清楚地表明需要进行一些更改。
简单地说,不要使用可变对象作为函数的默认参数。如果您使用可变对象作为默认参数,您将得到预期的结果。
请考虑下列错误函数定义:
def wrong_user_display(user_metadata: dict = {"name": "John", "age": 30}):
name = user_metadata.pop("name")
age = user_metadata.pop("age")
return f"{name} ({age})"
实际上,这有两个问题。除了默认的可变参数外,函数体还改变了可变对象,从而产生了副作用。但主要问题是user_metadata
的默认参数。
这实际上只在第一次调用它时才起作用,没有参数。这是第二次,我们在没有向user_metadata
明确传递内容的情况下调用它。它将以KeyError
的形式失败,如下所示:
>>> wrong_user_display()
'John (30)'
>>> wrong_user_display({"name": "Jane", "age": 25})
'Jane (25)'
>>> wrong_user_display()
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File ... in wrong_user_display
name = user_metadata.pop("name")
KeyError: 'name'
解释很简单,在函数定义中将带有默认数据的字典分配给user_metadata
,该字典实际上创建了一次,user_metadata
变量指向它。当 Python 解释器解析文件时,它将读取函数,并在签名中找到一条语句,该语句创建字典并将其分配给参数。从那时起,字典只创建一次,在程序的整个生命周期中都是一样的。
然后,函数体修改这个对象,只要程序在运行,这个对象就在内存中保持活动状态。当我们向它传递一个值时,它将取代我们刚才创建的默认参数。当我们不需要这个对象时,会再次调用它,并且它自上次运行以来已被修改;下次运行时,将不包含键,因为它们在上次调用时已被删除。
修复也很简单,我们需要使用None
作为默认哨兵值,并在函数体上指定默认值。由于每个函数都有自己的作用域和生命周期,所以每次出现None
时,user_metadata
都会被分配给字典:
def user_display(user_metadata: dict = None):
user_metadata = user_metadata or {"name": "John", "age": 30}
name = user_metadata.pop("name")
age = user_metadata.pop("age")
return f"{name} ({age})"
让我们通过理解扩展内置类型的怪癖来结束本节。
扩展内置类型(如列表、字符串和字典)的正确方法是通过collections
模块。
例如,如果您创建了一个直接扩展dict
的类,您将获得可能不是您所期望的结果。这是因为在 CPython(一个 C 优化)中,类的方法不会相互调用(正如它们应该调用的那样),因此如果覆盖其中一个,其余的方法将不会反映这一点,从而导致意外的结果。例如,您可能希望覆盖__getitem__
,然后当您使用for
循环迭代对象时,您会注意到您在该方法上的逻辑没有应用。
例如,使用collections.UserDict
就可以解决所有问题,它为实际字典提供了一个透明的接口,并且更加健壮。
假设我们需要一个最初由数字创建的列表,将值转换为字符串,并添加前缀。第一种方法看起来似乎解决了问题,但它是错误的:
class BadList(list):
def __getitem__(self, index):
value = super().__getitem__(index)
if index % 2 == 0:
prefix = "even"
else:
prefix = "odd"
return f"[{prefix}] {value}"
乍一看,对象的行为似乎与我们所希望的一样。但是,如果我们尝试迭代它(毕竟,它是一个list
,我们会发现我们没有得到我们想要的:
>>> bl = BadList((0, 1, 2, 3, 4, 5))
>>> bl[0]
'[even] 0'
>>> bl[1]
'[odd] 1'
>>> "".join(bl)
Traceback (most recent call last):
...
TypeError: sequence item 0: expected str instance, int found
join
函数将尝试迭代(在list
上运行for
循环),但需要string
类型的值。我们希望这能起作用,因为我们修改了__getitem__
方法,使其始终返回string
。然而,根据结果,我们可以得出结论,我们的__getitem__
修改版本没有被调用。
这个问题实际上是 CPython 的实现细节,而在 PyPy 等其他平台上,这并没有发生(请参阅本章末尾参考文献中 PyPy 和 CPython 之间的差异)。
不管怎样,我们应该编写可移植且与所有实现兼容的代码,因此我们将通过扩展UserList
而不是list
来修复它:
from collections import UserList
class GoodList(UserList):
def __getitem__(self, index):
value = super().__getitem__(index)
if index % 2 == 0:
prefix = "even"
else:
prefix = "odd"
return f"[{prefix}] {value}"
现在情况看起来好多了:
>>> gl = GoodList((0, 1, 2))
>>> gl[0]
'[even] 0'
>>> gl[1]
'[odd] 1'
>>> "; ".join(gl)
'[even] 0; [odd] 1; [even] 2'
不要直接从dict
延伸;使用collections.UserDict
代替。对于列表,使用collections.UserList
,对于字符串,使用collections.UserString
。
至此,我们了解了 Python 的所有主要概念。不仅是如何编写与 Python 本身很好融合的惯用代码,而且还可以避免某些陷阱。下一节是补充。
在结束本章之前,我想简单介绍一下异步编程,因为虽然它与干净的代码本身没有严格的关系,但异步代码已经变得越来越流行,接下来的想法是,为了有效地处理代码,我们必须能够阅读并理解它,因为能够阅读异步代码很重要。
异步编程与干净的代码无关。因此,本节中描述的 Python 特性不会使代码库更易于维护。本节介绍 Python 中用于协同路由的语法,因为它可能对读者有用,本书后面可能会出现关于协同路由的示例。
异步编程背后的思想是在代码中包含可以挂起的部分,以便代码的其他部分可以运行。通常,当我们运行 I/O 操作时,我们非常希望保持代码运行,并在这段时间内将 CPU 用于其他方面。
这改变了编程模型。我们不需要同步调用,而是以事件循环调用的方式编写代码,事件循环负责调度协程,以便在同一进程和线程中运行所有协程。
我们的想法是创建一系列的协程,并将它们添加到事件循环中。当事件循环开始时,它将从它所拥有的协程中挑选,并安排它们运行。在某个时刻,当我们的一个协程需要执行一个 I/O 操作时,我们可以触发它并向事件循环发回信号以再次取得控制权,然后在该操作保持运行的同时安排另一个协程。在某个时刻,事件循环将从它停止的最后一个点恢复我们的协同路由,并将从那里继续。请记住,异步编程的优点是不会阻塞 I/O 操作。这意味着当 I/O 操作就位时,代码可以跳转到其他位置,然后返回,但这并不意味着有多个进程同时运行。执行模型仍然是单线程的。
为了在 Python 中实现这一点,曾经(现在仍然)有很多可用的框架。但是在 Python 的旧版本中,没有特定的语法允许这样做,因此框架的工作方式有点复杂,或者乍一看不明显。从 Python3.5 开始,语言中添加了声明协同路由的特定语法,这改变了我们用 Python 编写异步代码的方式。在此之前,在标准库中引入了一个默认的事件循环模块asyncio
。有了 Python 的这两个里程碑,异步编程就更好了。
虽然本节使用asyncio
作为异步处理的模块,但这并不是唯一的模块。您可以使用任何库编写异步代码(在标准的库之外有很多库,例如trio
()https://github.com/python-trio/trio 和curio
(https://github.com/dabeaz/curio 来命名只是一对)。Python 为编写协同程序提供的语法可以被视为 API。只要您选择的库符合该 API,您就应该能够使用它,而不必更改您的协同程序的声明方式。
与异步编程相比,语法上的区别在于,协同程序类似于函数,但它们的名称前用async def
定义。当在一个协同程序中,我们想要调用另一个(可以是我们的,也可以在第三方库中定义),我们通常会在调用之前使用await
关键字。当调用await
时,这会向事件循环发出信号,以收回控制权。此时,事件循环将恢复其执行,协同路由将留在那里等待其非阻塞操作继续,同时,代码的另一部分将运行(事件循环将调用另一个协同路由)。在某个点上,事件循环将再次调用我们原来的协同程序,而这一个将从它停止的点(在与await
语句的行之后)恢复。
我们可能在代码中定义的典型协同程序具有以下结构:
async def mycoro(*args, **kwargs):
# … logic
await third_party.coroutine(…)
# … more of our logic
如前所述,有了定义协同路由的新语法。此语法引入的一个区别是,与常规函数相反,当我们调用此定义时,它不会运行其中的代码。相反,它将创建一个协程对象。此对象将包含在事件循环中,并且在某个时刻必须等待(否则定义中的代码将永远不会运行):
result = await mycoro(…) # doing result = mycoro() would be erroneous
不要忘记等待您的协同程序,否则它们的代码将永远不会运行。注意asyncio
给出的警告。
如前所述,Python 中有几个异步编程库,其中的事件循环可以运行前面定义的协程。特别是,对于asyncio
,有一个内置函数来运行协同程序,直到其完成:
import asyncio
asyncio.run(mycoro(…))
关于协同程序如何在 Python 中工作的细节超出了本书的范围,但是本介绍应该让读者更熟悉语法。这就是说,协程在技术上是在生成器之上实现的,我们将在第 7 章、生成器、迭代器和异步编程中详细探讨。
在本章中,我们探讨了 Python 的主要特性,目的是了解其最显著的特性,这些特性使 Python 与其他语言相比成为一种独特的语言。沿着这条道路,我们探索了 Python 的不同方法、协议及其内部机制。
与前一章不同,本章更关注 Python。本书主题的一个关键要点是,干净的代码不仅仅是遵循格式规则(当然,这对于良好的代码库是必不可少的)。它们是必要条件,但不是充分条件。在接下来的几章中,我们将看到更多与代码相关的想法和原则,目的是更好地设计和实现我们的软件解决方案。
通过本章的概念和思想,我们探索了 Python 的核心:它的协议和神奇的方法。现在应该很清楚,拥有 Pythonic、惯用代码的最佳方式不仅是遵循格式约定,而且是充分利用 Python 提供的所有特性。这意味着您可以通过使用特定的 magic 方法、上下文管理器编写更易于维护的代码,或者通过使用理解和赋值表达式编写更简洁的语句。
我们还熟悉了异步编程,现在我们应该可以轻松地阅读 Python 中的异步代码了。这一点很重要,因为异步编程正变得越来越流行,它对本书后面探讨的未来主题很有用。
在下一章中,我们将把这些概念付诸实践,将软件工程的一般概念与用 Python 编写它们的方式联系起来。
读者将在以下参考文献中找到关于本章所涉及主题的更多信息。Python 中索引如何工作的决策基于(EWD831),它分析了数学和编程语言中范围的几种备选方案:
- EWD831:为什么编号应该从零开始(https://www.cs.utexas.edu/users/EWD/transcriptions/EWD08xx/EWD831.html
- PEP-343:与(的“with”语句 https://www.python.org/dev/peps/pep-0343/
- CC08:由Robert C.Martin撰写的名为干净代码:敏捷软件工艺手册的书
- iter()函数:https://docs.python.org/3/library/functions.html#iter
- PyPy 与 CPython 的差异:https://pypy.readthedocs.io/en/latest/cpython_differences.html#subclasses-内置类型
- 灌木丛艺术:http://media.pragprog.com/articles/jan_03_enbug.pdf
- ALGO01:由托马斯·H·科曼、查尔斯·E·莱瑟森、罗纳德·L·里维斯特和克利福德·斯坦撰写的书,书名为算法导论、【T12 第三版(麻省理工出版社)