第九章. 装饰器和闭包
有人对将这个功能命名为“装饰器”的选择提出了一些抱怨。主要的抱怨是该名称与其在 GoF 书中的用法不一致。¹ 名称 decorator 可能更多地归因于其在编译器领域的用法—语法树被遍历并注释。
PEP 318—函数和方法的装饰器
函数装饰器让我们在源代码中“标记”函数以增强其行为。这是强大的东西,但要掌握它需要理解闭包—当函数捕获在其体外定义的变量时,我们就得到了闭包。
Python 中最晦涩的保留关键字是 nonlocal
,引入于 Python 3.0。如果你遵循严格的以类为中心的面向对象编程规范,作为 Python 程序员可以过上富有成效的生活而永远不使用它。然而,如果你想要实现自己的函数装饰器,你必须理解闭包,然后 nonlocal
的必要性就显而易见了。
除了在装饰器中的应用外,闭包在使用回调函数的任何类型编程和在适当时以函数式风格编码时也是必不可少的。
本章的最终目标是准确解释函数装饰器的工作原理,从最简单的注册装饰器到更复杂的带参数装饰器。然而,在达到这个目标之前,我们需要涵盖:
- Python 如何评估装饰器语法
- Python 如何确定变量是局部的
- 闭包的存在及工作原理
nonlocal
解决了什么问题
有了这个基础,我们可以进一步探讨装饰器的主题:
- 实现一个行为良好的装饰器
- 标准库中强大的装饰器:
@cache
、@lru_cache
和@singledispatch
- 实现一个带参数的装饰器
本章新内容
Python 3.9 中新增的缓存装饰器 functools.cache
比传统的 functools.lru_cache
更简单,因此我首先介绍它。后者在“使用 lru_cache”中有介绍,包括 Python 3.8 中新增的简化形式。
“单分派泛型函数”进行了扩展,现在使用类型提示,这是自 Python 3.7 以来使用 functools.singledispatch
的首选方式。
“带参数的装饰器”现在包括一个基于类的示例,示例 9-27。
我将第十章,“具有头等函数的设计模式”移到了第 II 部分的末尾,以改善书籍的流畅性。“装饰器增强策略模式”现在在该章节中,以及使用可调用对象的策略设计模式的其他变体。
我们从一个非常温和的装饰器介绍开始,然后继续进行章节开头列出的其余项目。
装饰器 101
装饰器是一个可调用对象,接受另一个函数作为参数(被装饰的函数)。
装饰器可能对被装饰的函数进行一些处理,并返回它或用另一个函数或可调用对象替换它。²
换句话说,假设存在一个名为 decorate
的装饰器,这段代码:
@decorate def target(): print('running target()')
与编写以下内容具有相同的效果:
def target(): print('running target()') target = decorate(target)
最终结果是一样的:在这两个片段的末尾,target
名称绑定到 decorate(target)
返回的任何函数上—这可能是最初命名为 target
的函数,也可能是另一个函数。
要确认被装饰的函数是否被替换,请查看示例 9-1 中的控制台会话。
示例 9-1. 装饰器通常会用不同的函数替换一个函数
>>> def deco(func): ... def inner(): ... print('running inner()') ... return inner # ① ... >>> @deco ... def target(): # ② ... print('running target()') ... >>> target() # ③ running inner() >>> target # ④ <function deco.<locals>.inner at 0x10063b598>
①
deco
返回其 inner
函数对象。
②
target
被 deco
装饰。
③
调用被装饰的 target
实际上运行 inner
。
④
检查发现 target
现在是对 inner
的引用。
严格来说,装饰器只是一种语法糖。正如我们刚才看到的,你总是可以像调用任何常规可调用对象一样简单地调用装饰器,传递另一个函数。有时这实际上很方便,特别是在进行 元编程 时——在运行时更改程序行为。
三个关键事实概括了装饰器的要点:
- 装饰器是一个函数或另一个可调用对象。
- 装饰器可能会用不同的函数替换被装饰的函数。
- 装饰器在模块加载时立即执行。
现在让我们专注于第三点。
Python 执行装饰器时
装饰器的一个关键特点是它们在被装饰的函数定义后立即运行。通常是在 导入时间(即 Python 加载模块时)运行。考虑 示例 9-2 中的 registration.py。
示例 9-2. registration.py 模块
registry = [] # ① def register(func): # ② print(f'running register({func})') # ③ registry.append(func) # ④ return func # ⑤ @register # ⑥ def f1(): print('running f1()') @register def f2(): print('running f2()') def f3(): # ⑦ print('running f3()') def main(): # ⑧ print('running main()') print('registry ->', registry) f1() f2() f3() if __name__ == '__main__': main() # ⑨
①
registry
将保存被 @register
装饰的函数的引用。
②
register
接受一个函数作为参数。
③
显示正在被装饰的函数,以供演示。
④
在 registry
中包含 func
。
⑤
返回 func
:我们必须返回一个函数;在这里我们返回接收到的相同函数。
⑥
f1
和 f2
被 @register
装饰。
⑦
f3
没有被装饰。
⑧
main
显示 registry
,然后调用 f1()
、f2()
和 f3()
。
⑨
只有当 registration.py 作为脚本运行时才会调用 main()
。
将 registration.py 作为脚本运行的输出如下:
$ python3 registration.py running register(<function f1 at 0x100631bf8>) running register(<function f2 at 0x100631c80>) running main() registry -> [<function f1 at 0x100631bf8>, <function f2 at 0x100631c80>] running f1() running f2() running f3()
注意,register
在任何模块中的其他函数之前运行(两次)。当调用 register
时,它接收被装饰的函数对象作为参数,例如 。
模块加载后,registry
列表保存了两个被装饰函数 f1
和 f2
的引用。这些函数以及 f3
只有在被 main
显式调用时才会执行。
如果 registration.py 被导入(而不是作为脚本运行),输出如下:
>>> import registration running register(<function f1 at 0x10063b1e0>) running register(<function f2 at 0x10063b268>)
此时,如果检查 registry
,你会看到:
>>> registration.registry [<function f1 at 0x10063b1e0>, <function f2 at 0x10063b268>]
示例 9-2 的主要观点是强调函数装饰器在模块导入时立即执行,但被装饰的函数只有在显式调用时才运行。这突出了 Python 程序员所称的 导入时间 和 运行时 之间的区别。
注册装饰器
考虑到装饰器在实际代码中通常的应用方式,示例 9-2 在两个方面都有些不同寻常:
- 装饰器函数在与被装饰函数相同的模块中定义。真正的装饰器通常在一个模块中定义,并应用于其他模块中的函数。
register
装饰器返回与传入的相同函数。实际上,大多数装饰器定义一个内部函数并返回它。
即使 示例 9-2 中的 register
装饰器返回未更改的装饰函数,这种技术也不是无用的。许多 Python 框架中使用类似的装饰器将函数添加到某个中央注册表中,例如将 URL 模式映射到生成 HTTP 响应的函数的注册表。这些注册装饰器可能会或可能不会更改被装饰的函数。
我们将在 “装饰器增强的策略模式”(第十章)中看到一个注册装饰器的应用。
大多数装饰器确实会改变被装饰的函数。它们通常通过定义内部函数并返回它来替换被装饰的函数来实现。几乎总是依赖闭包才能正确运行使用内部函数的代码。要理解闭包,我们需要退一步,回顾一下 Python 中变量作用域的工作原理。
变量作用域规则
在示例 9-3 中,我们定义并测试了一个函数,该函数读取两个变量:一个局部变量a
—定义为函数参数—和一个在函数中任何地方都未定义的变量b
。
示例 9-3. 读取局部变量和全局变量的函数
>>> def f1(a): ... print(a) ... print(b) ... >>> f1(3) 3 Traceback (most recent call last): File "<stdin>", line 1, in <module> File "<stdin>", line 3, in f1 NameError: global name 'b' is not defined
我们得到的错误并不令人惊讶。继续从示例 9-3 中,如果我们为全局b
赋值然后调用f1
,它可以工作:
>>> b = 6 >>> f1(3) 3 6
现在,让我们看一个可能会让你惊讶的例子。
查看示例 9-4 中的f2
函数。它的前两行与示例 9-3 中的f1
相同,然后对b
进行赋值。但在赋值之前的第二个print
失败了。
示例 9-4. 变量b
是局部的,因为它在函数体中被赋值
>>> b = 6 >>> def f2(a): ... print(a) ... print(b) ... b = 9 ... >>> f2(3) 3 Traceback (most recent call last): File "<stdin>", line 1, in <module> File "<stdin>", line 3, in f2 UnboundLocalError: local variable 'b' referenced before assignment
请注意,输出以3
开头,这证明了print(a)
语句已执行。但第二个print(b)
从未运行。当我第一次看到这个时,我感到惊讶,认为应该打印6
,因为有一个全局变量b
,并且在print(b)
之后对局部b
进行了赋值。
但事实是,当 Python 编译函数体时,它决定将b
视为局部变量,因为它是在函数内部赋值的。生成的字节码反映了这个决定,并将尝试从局部作用域获取b
。稍后,当调用f2(3)
时,f2
的函数体获取并打印局部变量a
的值,但在尝试获取局部变量b
的值时,它发现b
是未绑定的。
这不是一个错误,而是一个设计选择:Python 不要求您声明变量,但假设在函数体中分配的变量是局部的。这比 JavaScript 的行为要好得多,后者也不需要变量声明,但如果您忘记声明变量是局部的(使用var
),您可能会在不知情的情况下覆盖全局变量。
如果我们希望解释器将b
视为全局变量,并且仍然在函数内部为其赋新值,我们使用global
声明:
>>> b = 6 >>> def f3(a): ... global b ... print(a) ... print(b) ... b = 9 ... >>> f3(3) 3 6 >>> b 9
在前面的示例中,我们可以看到两个作用域的运作:
模块全局作用域
由分配给任何类或函数块之外的值的名称组成。
f3 函数的局部作用域
由分配给参数的值或直接在函数体中分配的名称组成。
另一个变量可能来自的作用域是非局部,对于闭包是至关重要的;我们稍后会看到它。
在更深入地了解 Python 中变量作用域工作原理之后,我们可以在下一节“闭包”中讨论闭包。如果您对示例 9-3 和 9-4 中的函数之间的字节码差异感兴趣,请参阅以下侧边栏。
闭包
在博客圈中,有时会混淆闭包和匿名函数。许多人会因为这两个特性的平行历史而混淆它们:在函数内部定义函数并不那么常见或方便,直到有了匿名函数。而只有在有嵌套函数时闭包才重要。因此,很多人会同时学习这两个概念。
实际上,闭包是一个函数—我们称之为f
—具有扩展作用域,包含在f
的函数体中引用的不是全局变量或f
的局部变量的变量。这些变量必须来自包含f
的外部函数的局部作用域。
函数是匿名的与否并不重要;重要的是它可以访问在其函数体之外定义的非全局变量。
这是一个难以理解的概念,最好通过一个例子来解释。
考虑一个avg
函数来计算不断增长的数值序列的平均值;例如,商品的整个历史上的平均收盘价。每天都会添加一个新的价格,并且平均值是根据到目前为止的所有价格计算的。
从一张干净的画布开始,这就是如何使用avg
:
>>> avg(10) 10.0 >>> avg(11) 10.5 >>> avg(12) 11.0
avg
是从哪里来的,它在哪里保留了先前值的历史记录?
起步时,示例 9-7 是基于类的实现。
示例 9-7. average_oo.py:用于计算移动平均值的类
class Averager(): def __init__(self): self.series = [] def __call__(self, new_value): self.series.append(new_value) total = sum(self.series) return total / len(self.series)
Averager
类创建可调用的实例:
>>> avg = Averager() >>> avg(10) 10.0 >>> avg(11) 10.5 >>> avg(12) 11.0
现在,示例 9-8 是一个功能实现,使用高阶函数make_averager
。
示例 9-8. average.py:用于计算移动平均值的高阶函数
def make_averager(): series = [] def averager(new_value): series.append(new_value) total = sum(series) return total / len(series) return averager
当调用时,make_averager
返回一个averager
函数对象。每次调用averager
时,它都会将传递的参数附加到序列中,并计算当前平均值,如示例 9-9 所示。
示例 9-9. 测试示例 9-8
>>> avg = make_averager() >>> avg(10) 10.0 >>> avg(11) 10.5 >>> avg(15) 12.0
注意示例的相似之处:我们调用Averager()
或make_averager()
来获取一个可调用对象avg
,它将更新历史序列并计算当前平均值。在示例 9-7 中,avg
是Averager
的一个实例,在示例 9-8 中,它是内部函数averager
。无论哪种方式,我们只需调用avg(n)
来将n
包含在序列中并获得更新后的平均值。
很明显,Averager
类的avg
保留历史记录的地方:self.series
实例属性。但第二个示例中的avg
函数从哪里找到series
呢?
请注意,series
是make_averager
的局部变量,因为赋值series = []
发生在该函数的主体中。但当调用avg(10)
时,make_averager
已经返回,并且它的局部作用域早已消失。
在averager
中,series
是一个自由变量。这是一个技术术语,意味着一个在局部作用域中未绑定的变量。参见图 9-1。
图 9-1. averager
的闭包将该函数的作用域扩展到包括自由变量series
的绑定。
检查返回的averager
对象显示了 Python 如何在__code__
属性中保存局部和自由变量的名称,该属性表示函数的编译体。示例 9-10 展示了这些属性。
示例 9-10. 检查由示例 9-8 创建的函数
>>> avg.__code__.co_varnames ('new_value', 'total') >>> avg.__code__.co_freevars ('series',)
series
的值保存在返回的函数avg
的__closure__
属性中。avg.__closure__
中的每个项目对应于avg.__code__.co_freevars
中的一个名称。这些项目是cells
,它们有一个名为cell_contents
的属性,其中可以找到实际值。示例 9-11 展示了这些属性。
示例 9-11. 从示例 9-9 继续
>>> avg.__code__.co_freevars ('series',) >>> avg.__closure__ (<cell at 0x107a44f78: list object at 0x107a91a48>,) >>> avg.__closure__[0].cell_contents [10, 11, 12]
总结一下:闭包是一个函数,保留在函数定义时存在的自由变量的绑定,以便在函数被调用时使用,而定义作用域不再可用时可以使用它们。
请注意,函数可能需要处理非全局外部变量的唯一情况是当它嵌套在另一个函数中并且这些变量是外部函数的局部作用域的一部分时。
非局部声明
我们先前的make_averager
实现效率不高。在示例 9-8 中,我们将所有值存储在历史序列中,并且每次调用averager
时都计算它们的sum
。更好的实现方式是只存储总和和到目前为止的项目数,并从这两个数字计算平均值。
示例 9-12 是一个有问题的实现,只是为了说明一个观点。你能看出它在哪里出错了吗?
示例 9-12. 一个破损的高阶函数,用于计算不保留所有历史记录的运行平均值
def make_averager(): count = 0 total = 0 def averager(new_value): count += 1 total += new_value return total / count return averager
流畅的 Python 第二版(GPT 重译)(五)(2)https://developer.aliyun.com/article/1484581