流畅的 Python 第二版(GPT 重译)(五)(1)https://developer.aliyun.com/article/1484580
如果尝试 示例 9-12,你会得到以下结果:
>>> avg = make_averager() >>> avg(10) Traceback (most recent call last): ... UnboundLocalError: local variable 'count' referenced before assignment >>>
问题在于语句 count += 1
实际上意味着与 count = count + 1
相同,当 count
是一个数字或任何不可变类型时。因此,我们实际上是在 averager
的主体中对 count
赋值,这使其成为一个局部变量。同样的问题也影响到 total
变量。
我们在 示例 9-8 中没有这个问题,因为我们从未给 series
赋值;我们只调用了 series.append
并在其上调用了 sum
和 len
。所以我们利用了列表是可变的这一事实。
但对于像数字、字符串、元组等不可变类型,你只能读取,而不能更新。如果尝试重新绑定它们,比如 count = count + 1
,那么实际上是隐式创建了一个局部变量 count
。它不再是一个自由变量,因此不会保存在闭包中。
为了解决这个问题,Python 3 中引入了 nonlocal
关键字。它允许你将一个变量声明为自由变量,即使它在函数内部被赋值。如果向 nonlocal
变量赋予新值,闭包中存储的绑定将会改变。我们最新的 make_averager
的正确实现看起来像 示例 9-13。
示例 9-13. 计算不保留所有历史记录的运行平均值(使用 nonlocal
修复)
def make_averager(): count = 0 total = 0 def averager(new_value): nonlocal count, total count += 1 total += new_value return total / count return averager
在学习了 nonlocal
的使用之后,让我们总结一下 Python 的变量查找工作原理。
变量查找逻辑
当定义一个函数时,Python 字节码编译器根据以下规则确定如何获取其中出现的变量 x
:³
- 如果有
global x
声明,x
来自并被赋值给模块的x
全局变量。⁴ - 如果有
nonlocal x
声明,x
来自并被赋值给最近的周围函数的x
局部变量,其中x
被定义。 - 如果
x
是参数或在函数体中被赋值,则x
是局部变量。 - 如果引用了
x
但未被赋值且不是参数:
x
将在周围函数体的本地作用域(非本地作用域)中查找。- 如果在周围作用域中找不到,将从模块全局作用域中读取。
- 如果在全局作用域中找不到,将从
__builtins__.__dict__
中读取。
现在我们已经介绍了 Python 闭包,我们可以有效地使用嵌套函数实现装饰器。
实现一个简单的装饰器
示例 9-14 是一个装饰器,用于记录装饰函数的每次调用并显示经过的时间、传递的参数以及调用的结果。
示例 9-14. clockdeco0.py: 显示函数运行时间的简单装饰器
import time def clock(func): def clocked(*args): # ① t0 = time.perf_counter() result = func(*args) # ② elapsed = time.perf_counter() - t0 name = func.__name__ arg_str = ', '.join(repr(arg) for arg in args) print(f'[{elapsed:0.8f}s] {name}({arg_str}) -> {result!r}') return result return clocked # ③
①
定义内部函数 clocked
来接受任意数量的位置参数。
②
这行代码之所以有效,是因为 clocked
的闭包包含了 func
自由变量。
③
返回内部函数以替换装饰的函数。
示例 9-15 演示了 clock
装饰器的使用。
示例 9-15. 使用 clock
装饰器
import time from clockdeco0 import clock @clock def snooze(seconds): time.sleep(seconds) @clock def factorial(n): return 1 if n < 2 else n*factorial(n-1) if __name__ == '__main__': print('*' * 40, 'Calling snooze(.123)') snooze(.123) print('*' * 40, 'Calling factorial(6)') print('6! =', factorial(6))
运行 示例 9-15 的输出如下所示:
$ python3 clockdeco_demo.py **************************************** Calling snooze(.123) [0.12363791s] snooze(0.123) -> None **************************************** Calling factorial(6) [0.00000095s] factorial(1) -> 1 [0.00002408s] factorial(2) -> 2 [0.00003934s] factorial(3) -> 6 [0.00005221s] factorial(4) -> 24 [0.00006390s] factorial(5) -> 120 [0.00008297s] factorial(6) -> 720 6! = 720
工作原理
记住这段代码:
@clock def factorial(n): return 1 if n < 2 else n*factorial(n-1)
实际上是这样的:
def factorial(n): return 1 if n < 2 else n*factorial(n-1) factorial = clock(factorial)
因此,在这两个示例中,clock
将factorial
函数作为其func
参数(参见示例 9-14)。然后创建并返回clocked
函数,Python 解释器将其分配给factorial
(在第一个示例中,在幕后)。实际上,如果导入clockdeco_demo
模块并检查factorial
的__name__
,您会得到以下结果:
>>> import clockdeco_demo >>> clockdeco_demo.factorial.__name__ 'clocked' >>>
所以factorial
现在实际上持有对clocked
函数的引用。从现在开始,每次调用factorial(n)
,都会执行clocked(n)
。实质上,clocked
执行以下操作:
- 记录初始时间
t0
。 - 调用原始
factorial
函数,保存结果。 - 计算经过的时间。
- 格式化并显示收集的数据。
- 返回第 2 步保存的结果。
这是装饰器的典型行为:它用新函数替换装饰函数,新函数接受相同的参数并(通常)返回装饰函数应该返回的内容,同时还进行一些额外处理。
提示
在 Gamma 等人的设计模式中,装饰器模式的简短描述以“动态地为对象附加额外的责任”开始。函数装饰器符合这一描述。但在实现层面上,Python 装饰器与原始设计模式作品中描述的经典装饰器几乎没有相似之处。“讲台”有更多相关内容。
示例 9-14 中实现的clock
装饰器存在一些缺陷:它不支持关键字参数,并且掩盖了装饰函数的__name__
和__doc__
。示例 9-16 使用functools.wraps
装饰器从func
复制相关属性到clocked
。此外,在这个新版本中,关键字参数被正确处理。
示例 9-16. clockdeco.py:改进的时钟装饰器
import time import functools def clock(func): @functools.wraps(func) def clocked(*args, **kwargs): t0 = time.perf_counter() result = func(*args, **kwargs) elapsed = time.perf_counter() - t0 name = func.__name__ arg_lst = [repr(arg) for arg in args] arg_lst.extend(f'{k}={v!r}' for k, v in kwargs.items()) arg_str = ', '.join(arg_lst) print(f'[{elapsed:0.8f}s] {name}({arg_str}) -> {result!r}') return result return clocked
functools.wraps
只是标准库中可用的装饰器之一。在下一节中,我们将介绍functools
提供的最令人印象深刻的装饰器:cache
。
标准库中的装饰器
Python 有三个内置函数专门用于装饰方法:property
、classmethod
和staticmethod
。我们将在“使用属性进行属性验证”中讨论property
,并在“classmethod 与 staticmethod”中讨论其他内容。
在示例 9-16 中,我们看到另一个重要的装饰器:functools.wraps
,一个用于构建行为良好的装饰器的辅助工具。标准库中一些最有趣的装饰器是cache
、lru_cache
和singledispatch
,它们都来自functools
模块。我们将在下一节中介绍它们。
使用functools.cache
进行记忆化
functools.cache
装饰器实现了记忆化:⁵一种通过保存先前调用昂贵函数的结果来优化的技术,避免对先前使用的参数进行重复计算。
提示
functools.cache
在 Python 3.9 中添加。如果需要在 Python 3.8 中运行这些示例,请将@cache
替换为@lru_cache
。对于 Python 的早期版本,您必须调用装饰器,写成@lru_cache()
,如“使用 lru_cache”中所述。
一个很好的演示是将@cache
应用于痛苦缓慢的递归函数,以生成斐波那契数列中的第n个数字,如示例 9-17 所示。
示例 9-17. 计算斐波那契数列中第 n 个数字的非常昂贵的递归方式
from clockdeco import clock @clock def fibonacci(n): if n < 2: return n return fibonacci(n - 2) + fibonacci(n - 1) if __name__ == '__main__': print(fibonacci(6))
运行fibo_demo.py的结果如下。除了最后一行外,所有输出都是由clock
装饰器生成的:
$ python3 fibo_demo.py [0.00000042s] fibonacci(0) -> 0 [0.00000049s] fibonacci(1) -> 1 [0.00006115s] fibonacci(2) -> 1 [0.00000031s] fibonacci(1) -> 1 [0.00000035s] fibonacci(0) -> 0 [0.00000030s] fibonacci(1) -> 1 [0.00001084s] fibonacci(2) -> 1 [0.00002074s] fibonacci(3) -> 2 [0.00009189s] fibonacci(4) -> 3 [0.00000029s] fibonacci(1) -> 1 [0.00000027s] fibonacci(0) -> 0 [0.00000029s] fibonacci(1) -> 1 [0.00000959s] fibonacci(2) -> 1 [0.00001905s] fibonacci(3) -> 2 [0.00000026s] fibonacci(0) -> 0 [0.00000029s] fibonacci(1) -> 1 [0.00000997s] fibonacci(2) -> 1 [0.00000028s] fibonacci(1) -> 1 [0.00000030s] fibonacci(0) -> 0 [0.00000031s] fibonacci(1) -> 1 [0.00001019s] fibonacci(2) -> 1 [0.00001967s] fibonacci(3) -> 2 [0.00003876s] fibonacci(4) -> 3 [0.00006670s] fibonacci(5) -> 5 [0.00016852s] fibonacci(6) -> 8 8
浪费是显而易见的:fibonacci(1)
被调用了八次,fibonacci(2)
被调用了五次,等等。但只需添加两行代码来使用cache
,性能就得到了很大改善。参见示例 9-18。
示例 9-18. 使用缓存实现更快的方法
import functools from clockdeco import clock @functools.cache # ① @clock # ② def fibonacci(n): if n < 2: return n return fibonacci(n - 2) + fibonacci(n - 1) if __name__ == '__main__': print(fibonacci(6))
①
这行代码适用于 Python 3.9 或更高版本。有关支持较早 Python 版本的替代方法,请参阅“使用 lru_cache”。
②
这是堆叠装饰器的一个示例:@cache
应用于@clock
返回的函数。
堆叠装饰器
要理解堆叠装饰器的意义,回想一下@
是将装饰器函数应用于其下方的函数的语法糖。如果有多个装饰器,它们的行为类似于嵌套函数调用。这个:
@alpha @beta def my_fn(): ...
与此相同:
my_fn = alpha(beta(my_fn))
换句话说,首先应用beta
装饰器,然后将其返回的函数传递给alpha
。
在示例 9-18 中使用cache
,fibonacci
函数仅对每个n
值调用一次:
$ python3 fibo_demo_lru.py [0.00000043s] fibonacci(0) -> 0 [0.00000054s] fibonacci(1) -> 1 [0.00006179s] fibonacci(2) -> 1 [0.00000070s] fibonacci(3) -> 2 [0.00007366s] fibonacci(4) -> 3 [0.00000057s] fibonacci(5) -> 5 [0.00008479s] fibonacci(6) -> 8 8
在另一个测试中,计算fibonacci(30)
时,示例 9-18 在 0.00017 秒内完成了所需的 31 次调用(总时间),而未缓存的示例 9-17 在 Intel Core i7 笔记本上花费了 12.09 秒,因为它调用了fibonacci(1)
832,040 次,总共 2,692,537 次调用。
被装饰函数接受的所有参数必须是可散列的,因为底层的lru_cache
使用dict
来存储结果,键是由调用中使用的位置和关键字参数生成的。
除了使愚蠢的递归算法可行外,@cache
在需要从远程 API 获取信息的应用程序中表现出色。
警告
如果缓存条目数量非常大,functools.cache
可能会消耗所有可用内存。我认为它更适合用于短暂的命令本。在长时间运行的进程中,我建议使用适当的maxsize
参数使用functools.lru_cache
,如下一节所述。
使用 lru_cache
functools.cache
装饰器实际上是围绕旧的functools.lru_cache
函数的简单包装器,后者更灵活,与 Python 3.8 及更早版本兼容。
@lru_cache
的主要优势在于其内存使用受maxsize
参数限制,其默认值相当保守,为 128,这意味着缓存最多同时保留 128 个条目。
LRU 的首字母缩写代表最近最少使用,意味着长时间未被读取的旧条目将被丢弃,以腾出空间给新条目。
自 Python 3.8 以来,lru_cache
可以以两种方式应用。这是最简单的使用方法:
@lru_cache def costly_function(a, b): ...
另一种方式——自 Python 3.2 起可用——是将其作为函数调用,使用()
:
@lru_cache() def costly_function(a, b): ...
在这两种情况下,将使用默认参数。这些是:
maxsize=128
设置要存储的条目的最大数量。缓存满后,最近最少使用的条目将被丢弃以为新条目腾出空间。为了获得最佳性能,maxsize
应为 2 的幂。如果传递maxsize=None
,LRU 逻辑将被禁用,因此缓存工作速度更快,但条目永远不会被丢弃,这可能会消耗过多内存。这就是@functools.cache
的作用。
typed=False
确定不同参数类型的结果是否分开存储。例如,在默认设置中,被视为相等的浮点数和整数参数仅存储一次,因此对f(1)
和f(1.0)
的调用将只有一个条目。如果typed=True
,这些参数将产生不同的条目,可能存储不同的结果。
这是使用非默认参数调用@lru_cache
的示例:
@lru_cache(maxsize=2**20, typed=True) def costly_function(a, b): ...
现在让我们研究另一个强大的装饰器:functools.singledispatch
。
单分发通用函数
想象我们正在创建一个用于调试 Web 应用程序的工具。我们希望为不同类型的 Python 对象生成 HTML 显示。
我们可以从这样的函数开始:
import html def htmlize(obj): content = html.escape(repr(obj)) return f'<pre>{content}</pre>'
这将适用于任何 Python 类型,但现在我们想扩展它以生成一些类型的自定义显示。一些示例:
str
用'
替换嵌入的换行符,并使用
\n'
标签代替
。
int
以十进制和十六进制形式显示数字(对 bool
有特殊情况)。
list
输出一个 HTML 列表,根据其类型格式化每个项目。
float
和 Decimal
通常输出值,但也以分数形式呈现(为什么不呢?)。
我们想要的行为在 Example 9-19 中展示。
Example 9-19. htmlize()
生成针对不同对象类型定制的 HTML
>>> htmlize({1, 2, 3}) # ① '<pre>{1, 2, 3}</pre>' >>> htmlize(abs) '<pre><built-in function abs></pre>' >>> htmlize('Heimlich & Co.\n- a game') # ② '<p>Heimlich & Co.<br/>\n- a game</p>' >>> htmlize(42) # ③ '<pre>42 (0x2a)</pre>' >>> print(htmlize(['alpha', 66, {3, 2, 1}])) # ④ <ul> <li><p>alpha</p></li> <li><pre>66 (0x42)</pre></li> <li><pre>{1, 2, 3}</pre></li> </ul> >>> htmlize(True) # ⑤ '<pre>True</pre>' >>> htmlize(fractions.Fraction(2, 3)) # ⑥ '<pre>2/3</pre>' >>> htmlize(2/3) # ⑦ '<pre>0.6666666666666666 (2/3)</pre>' >>> htmlize(decimal.Decimal('0.02380952')) '<pre>0.02380952 (1/42)</pre>'
①
原始函数为 object
注册,因此它作为一个通用函数来处理与其他实现不匹配的参数类型。
②
str
对象也会进行 HTML 转义,但会被包裹在
中,并在每个 '\n'
前插入
换行符。
③
int
以十进制和十六进制的形式显示,在
中。
④
每个列表项根据其类型进行格式化,并将整个序列呈现为 HTML 列表。
⑤
尽管 bool
是 int
的子类型,但它得到了特殊处理。
⑥
以分数形式展示 Fraction
。
⑦
以近似分数等价形式展示 float
和 Decimal
。
函数 singledispatch
因为在 Python 中我们没有 Java 风格的方法重载,所以我们不能简单地为我们想要以不同方式处理的每种数据类型创建 htmlize
的变体。在 Python 中的一个可能的解决方案是将 htmlize
转变为一个分发函数,使用一系列的 if/elif/…
或 match/case/…
调用专门函数,如 htmlize_str
,htmlize_int
等。这种方法对我们模块的用户来说是不可扩展的,而且很笨重:随着时间的推移,htmlize
分发器会变得太大,它与专门函数之间的耦合会非常紧密。
functools.singledispatch
装饰器允许不同模块为整体解决方案做出贡献,并让您轻松为属于第三方包的类型提供专门函数,而这些包您无法编辑。如果您用 @singledispatch
装饰一个普通函数,它将成为一个通用函数的入口点:一组函数以不同方式执行相同操作,取决于第一个参数的类型。这就是所谓的单分派。如果使用更多参数来选择特定函数,我们将有多分派。Example 9-20 展示了如何实现。
警告
functools.singledispatch
自 Python 3.4 起存在,但自 Python 3.7 起才支持类型提示。Example 9-20 中的最后两个函数展示了在 Python 3.4 以来所有版本中都有效的语法。
Example 9-20. @singledispatch
创建一个自定义的 @htmlize.register
来将几个函数捆绑成一个通用函数
from functools import singledispatch from collections import abc import fractions import decimal import html import numbers @singledispatch # ① def htmlize(obj: object) -> str: content = html.escape(repr(obj)) return f'<pre>{content}</pre>' @htmlize.register # ② def _(text: str) -> str: # ③ content = html.escape(text).replace('\n', '<br/>\n') return f'<p>{content}</p>' @htmlize.register # ④ def _(seq: abc.Sequence) -> str: inner = '</li>\n<li>'.join(htmlize(item) for item in seq) return '<ul>\n<li>' + inner + '</li>\n</ul>' @htmlize.register # ⑤ def _(n: numbers.Integral) -> str: return f'<pre>{n} (0x{n:x})</pre>' @htmlize.register # ⑥ def _(n: bool) -> str: return f'<pre>{n}</pre>' @htmlize.register(fractions.Fraction) # ⑦ def _(x) -> str: frac = fractions.Fraction(x) return f'<pre>{frac.numerator}/{frac.denominator}</pre>' @htmlize.register(decimal.Decimal) # ⑧ @htmlize.register(float) def _(x) -> str: frac = fractions.Fraction(x).limit_denominator() return f'<pre>{x} ({frac.numerator}/{frac.denominator})</pre>'
①
@singledispatch
标记了处理 object
类型的基本函数。
②
每个专门函数都使用 @«base».register
进行装饰。
③
运行时给定的第一个参数的类型决定了何时使用这个特定的函数定义。专门函数的名称并不重要;_
是一个很好的选择,可以让这一点清晰明了。⁶
④
为了让每个额外的类型得到特殊处理,需要注册一个新的函数,并在第一个参数中使用匹配的类型提示。
⑤
numbers
ABCs 对于与 singledispatch
一起使用很有用。⁷
⑥
bool
是numbers.Integral
的子类型,但singledispatch
逻辑寻找具有最具体匹配类型的实现,而不考虑它们在代码中出现的顺序。
⑦
如果您不想或无法向装饰的函数添加类型提示,可以将类型传递给@«base».register
装饰器。这种语法适用于 Python 3.4 或更高版本。
⑧
@«base».register
装饰器返回未装饰的函数,因此可以堆叠它们以在同一实现上注册两个或更多类型。⁸
在可能的情况下,注册专门的函数以处理抽象类(ABCs)如numbers.Integral
和abc.MutableSequence
,而不是具体实现如int
和list
。这样可以使您的代码支持更多兼容类型的变化。例如,Python 扩展可以提供numbers.Integral
的子类作为int
类型的替代方案。
流畅的 Python 第二版(GPT 重译)(五)(3)https://developer.aliyun.com/article/1484583