函数装饰器允许在源码中 标记 函数,以某种方式增强函数的行为。这是一个强大的功能,但是如果想掌握,则必须理解闭包,即捕获函数主体外部定义的变量。除了在装饰器中有用,闭包还是回调式编程和函数式编程风格的重要基础。
装饰器基础
装饰器是一种可调用对象,其参数是另一个函数(被装饰的函数)。装饰器可能会对被装饰的函数做些处理,然后返回函数,或者把函数替换成另一个函数或可调用对象。
假如有一个名为 decorate 的装饰器:
1 |
|
上述代码等效于:
1 | def target(): |
上述两个代码片段执行完毕后,target 名称都会绑定 decorate(target) 返回的函数——可能是原来那个名为 target 的函数,也可能是另一个函数。
1 | def decorate(f): |
严格来说,装饰器只是语法糖。如前所述,装饰器可以像常规的可调用对象那样调用,传入另一个函数。有时,这样做其实更方便,尤其是做元编程。综上所述,装饰器有以下 3 个基本性质:
- 装饰器是一个函数或其他可调用对象
- 装饰器可以把被装饰的函数替换成别的函数
- 装饰器在加载模块时立即执行
Python 何时执行装饰器
装饰器的一个关键性质是,它们在被装饰的函数定义之后立即运行。这通常是在导入时(例如,当 Python 加载模块时):
1 | registry = [] |
1 | # python registry.py |
- register在模块中其他函数之前运行(两次)。调用 register 时,传给它的参数是被装饰的函数
- 如果是导入 registration.py 模块,也可以看到 f1 和 f2 被注册了
因此,函数装饰器在导入模块时立即执行,而被装饰的函数只在显式调用时运行。由此可以看出 Python 程序员所说的导入时和运行时之间有什么区别。
很多 Python 框架会使用 register 这样的装饰器把函数添加到某种中央注册处,例如把 URL 模式映射到生成 HTTP 响应的函数的注册处。这种注册装饰器可能会也可能不会更改被装饰的函数。
不过,大多数装饰器会更改被装饰的函数。通常的做法是,返回在装饰器内部定义的函数,取代被装饰的函数。涉及内部函数的代码基本上离不开闭包。为了理解闭包,需要后退一步,先研究 Python 中的变量作用域规则。
变量作用域规则
先来看一个例子:
1 | b = 6 |
Python编译函数主体时,判断 b 是局部变量,因为在函数内给它赋值了。所以,Python 会尝试从局部作用域中获取b。后面调用 f2(3) 时,f2 的主体顺利获取并打印局部变量 a 的值,但是在尝试获取局部变量 b 的值时,发现 b 没有绑定值,此时是不会使用全局变量 b。
这不是 bug,而是一种设计选择:Python 不要求声明变量,但是会假定在函数主体中赋值的变量是局部变量。在函数中赋值时,如果想让解释器把 b 当成全局变量,为它分配一个新值,就要使用 global 声明:
1 | b = 6 |
通过以上示例可以发现两种作用域:
- 模块全局作用域:在类或函数块外部分配值的名称
- 函数局部作用域:通过参数或者在函数主体中直接分配值的名称
变量还有可能出现在第 3 个作用域中,我们称之为 非局部 作用域。这个作用域是闭包的基础。我们下文介绍。
闭包
其实,闭包就是延伸了作用域的函数,包括函数 f 主体中引用的非全局变量和局部变量。这些变量必须来自包含 f 的外部函数的局部作用域。函数是不是匿名的没有关系,关键是它能访问主体之外定义的非全局变量。
如下是一个使用闭包的例子:
1 | def make_averager(): |
使用类似的基于类的实现:
1 | class Averager(): |
这两个示例有相似之处:调用 Averager() 或 make_averager() 得到一个可调用对象 avg,它会更新历史值,然后计算当前平均值。只不过一个是类的实例,一种则是闭包(内部函数)。
作为 Averager 类的实例,avg 在哪里存储历史值很明显:即实例属性 self.series。那闭包呢?series 是 make_averager 函数的局部变量,因为赋值语句 series = [] 在 make_averager 函数的主体中。但是,调用 avg(10) 时,make_averager 函数已经返回,局部作用域早就 烟消云散 了。
在 averager 函数中,series 是自由变量(free variable)。自由变量是一个术语,指未在局部作用域中绑定的变量。
查看返回的 averager 对象,我们发现 Python 在 __code__ 属性(表示编译后的函数主体)中保存局部变量和自由变量的名称:
1 | avg.__code__.co_varnames |
series 的值在返回的 avg 函数的__closure__属性中。avg.__closure__ 中的各项对应 avg.__code__.co_freevars 中的一个名称。这些项是 cell 对象,有一个名为 cell_contents 的属性,保存着真正的值:
1 | avg.__closure__ |
综上所述,闭包是一个函数,它保留了定义函数时存在的自由变量的绑定。如此一来,调用函数时,虽然定义作用域不可用了,但是仍能使用那些绑定。注意,只有嵌套在其他函数中的函数才可能需要处理不在全局作用域中的外部变量。这些外部变量位于外层函数的局部作用域内。
nonlocal 声明
对于如下代码:
1 | def make_averager(): |
count += 1 语句的作用其实与 count = count + 1 一样。因此,实际上我们在 averager 的主体中为 count 赋值了,这会把 count 变成局部变量(total 变量也是类似的)。如此一来,count 就不是自由变量了,因此不会保存到闭包中。
为了解决这个问题,Python3 引入了 nonlocal 关键字。它的作用是把变量标记为自由变量,即便在函数中为变量赋予了新值。如果为 nonlocal 声明的变量赋予新值,那么闭包中保存的绑定也会随之更新。
1 | def make_averager(): |
学会使用 nonlocal 之后,接下来让我们总结一下 Python 查找变量的方式。Python 字节码编译器根据以下规则获取函数主体中出现的变量 x:
- 如果是
global x声明,则 x 来自模块全局作用域,并赋予那个作用域中 x 的值 - 如果是
nonlocal x声明,则 x 来自最近一个定义它的外层函数,并赋予那个函数中局部变量 x 的值 - 如果 x 是参数,或者在函数主体中赋了值,那么 x 就是局部变量
- 如果引用了 x,但是没有赋值也不是参数,则遵循以下规则:
- 在外层函数主体的局部作用域(非局部作用域)内查找 x
- 如果在外层作用域内未找到,则从模块全局作用域内读取
- 如果在模块全局作用域内未找到,则从
__builtins__.__dict__中读取
实现一个简单的装饰器
如下实现一个简单的装饰器,该装饰器会在每次调用被装饰的函数时计时,把运行时间、传入的参数和调用的结果打印出来:
1 | import time |
这里实现的关键是 clocked 的闭包中包含自由变量 func。之后在使用装饰器时:
1 |
|
等价于以下内容:
1 | def factorial(n): |
- factorial 函数都作为 func 参数传给 clock 函数
- clock 函数返回 clocked 函数,Python解释器把 clocked 赋值给 factorial
查看 factorial 的 name 属性,会看到如下结果:
1 | import clockdeco_demo |
可见,现在 factorial 保存的其实是 clocked 函数的引用。这是装饰器的典型行为:把被装饰的函数替换成新函数,新函数接受的参数与被装饰的函数一样,而且(通常)会返回被装饰的函数本该返回的值,同时还会做一些额外操作。
改善这个装饰器的实现,使用 functools.wraps 装饰器把相关的属性从 func 身上复制到了 clocked 中,例如 __name__ 和 __doc__。
1 | import time |
标准库中的装饰器
functools.wraps 的作用是协助构建行为良好的装饰器。标准库中最吸引人的几个装饰器,即 cache、 lru_cache 和 singledispatch,均来自 functools 模块。
使用 functools.cache 做备忘
functools.cache 装饰器实现了备忘(memoization)。这是一项优化技术,能把耗时的函数得到的结果保存起来,避免传入相同的参数时重复计算。
1 | import functools |
- 生成第 n 个斐波那契数这种慢速递归函数适合使用
@cache - 这里叠放了装饰器:
@cache应用到@clock返回的函数上
如果想理解叠放装饰器,那么需要记住一点:@ 是一种语法糖,其作用是把装饰器函数应用到下方的函数上。多个装饰器的行为就像调用嵌套函数一样。
1 |
|
等同于,也就是说,首先应用 beta 装饰器,然后再把返回的函数传给 alpha。
1 | my_fn = alpha(beta(my_fn)) |
被装饰的函数所接受的参数必须可哈希,因为底层 lru_cache 使用 dict 存储结果,字典的键取自传入的位置参数和关键字参数。除了优化递归算法,@cache 在从远程 API 中获取信息的应用程序中也能发挥巨大作用。
使用 lru_cache
functools.cache 装饰器只是对较旧的 functools.lru_cache 函数的简单包装。其实,functools.lru_cache 更灵活,主要优势是可以通过 maxsize 参数限制内存用量上限。maxsize 参数的默认值相当保守,只有 128,即缓存最多只能有 128 条。LRU 是 Least Recently Used 的首字母缩写,表示一段时间不用的缓存条目会被丢弃,为新条目腾出空间。
1 |
|
- 为了得到最佳性能,应将 maxsize 设为 2 的次方
- 如果传入 maxsize=None,则 LRU 逻辑将被彻底禁用,因此缓存速度更快,但是条目永远不会被丢弃。
@functools.cache就是如此 typed=False参数决定是否把不同参数类型得到的结果分开保存
functools.singledispatch
假设我们在开发一个调试 Web 应用程序的工具,想生成 HTML,以显示不同类型的 Python 对象。
1 | import html |
这个函数适用于任何 Python 类型,但是现在我们想扩展一下,以特别的方式显示如下类型:
- int: 以十进制和十六进制显示数(bool 除外)
- list:输出一个 HTML 列表,根据各项的类型进行格式化
- float 和 Decimal:正常输出值,外加分数形式
…
因为 Python 不支持 Java 那种方法重载,所以不能使用不同的签名定义 htmlize 的变体,以不同的方式处理不同的数据类型。在 Python 中,常见的做法是把 htmlize 变成一个分派函数,使用一串 if/elif/... 或 match/case/... 调用专门的函数,这样不仅不便于模块的用户扩展,还显得笨拙。
functools.singledispatch 装饰器可以把整体方案拆分成多个模块,甚至可以为第三方包中无法编辑的类型提供专门的函数。使用 @singledispatch 装饰的普通函数变成了泛化函数(generic function,指根据第一个参数的类型,以不同方式执行相同操作的一组函数)的入口,这才称的上 单分配。如果根据多个参数选择专门的函数,那就是 多分派。
1 | from functools import singledispatch |
@singledispatch标记的是处理 object 类型的基函数- 各个专门函数使用
@«base».register装饰 - 运行时传入的第一个参数的类型决定何时使用这个函数。专门函数的名称无关紧要,
_是一个不错的选择,简单明了 - 为每个需要特殊处理的类型注册一个函数,把第一个参数的类型提示设为相应的类型
singledispatch逻辑会寻找与指定类型最匹配的实现,与实现在代码中出现的顺序无关- 如果不想或者不能为被装饰的类型添加类型提示,则可以把类型传给
@«base».register装饰器 @«base».register装饰器会返回装饰之前的函数,因此可以叠放多个register装饰器,让同一个实现支持两个或更多类型- 应尽量注册处理抽象基类(例如
numbers.Integral和abc.MutableSequence)的专门函数,而不直接处理具体实现(例如 int 和 list)。这样的话,代码支持的兼容类型更广泛 - 在单分派中使用抽象基类或
typing.Protocol可以让代码支持抽象基类或实现协议的类当前和未来的具体子类或虚拟子类
singledispatch 机制的一个显著特征是,你可以在系统的任何地方和任何模块中注册专门函数。如果后来在新模块中定义了新类型,则可以轻易添加一个新的自定义函数来处理新类型。此外,还可以为不是自己编写的或者不能修改的类编写自定义函数。
参数化装饰器
解析源码中的装饰器时,Python 会把被装饰的函数作为第一个参数传给装饰器函数。那如何让装饰器接受其他参数呢?答案是创建一个装饰器工厂函数来接收那些参数,然后再返回一个装饰器,应用到被装饰的函数上。
如下例子中,新的 register 函数不是装饰器,而是装饰器工厂函数。调用 register 函数才能返回应用到目标函数上的装饰器。此时新的 register 必须作为函数调用:
1 | registry = set() |
- 这里 register 中的内部函数
decorate才是一个真正的装饰器,它的参数是一个函数 - 因为 decorate 是装饰器,所以必须返回一个函数
- register 是装饰器工厂函数,因此返回
decorate @register工厂函数必须作为函数调用,并且传入所需的参数。即使不传入参数,register 也必须作为函数调用@register(),返回真正的装饰器 decorate
如果不使用 @ 句法,那么就要像常规函数那样调用 register:
- 如果想把
f添加到 registry 中,那么装饰 f 函数的句法是register()(f) - 如果不想添加 f(或把它删除),则句法是
register(active=False)(f)
参数化装饰器的原理相当复杂,刚刚讨论的那个例子比大多数例子简单。参数化装饰器通常会把被装饰的函数替换掉,而且结构上需要多一层嵌套。
1 | import time |
- clock 是参数化装饰器工厂函数
- decorate 是真正的装饰器
- clocked 包装被装饰的函数
- 这里使用
**locals()是为了在 fmt 中引用 clocked 的局部变量 - clocked 将取代被装饰的函数,因此它应该返回被装饰的函数返回的值
基于类的 clock 装饰器
对更复杂的装饰器来说,基于类实现或许更易于理解和维护。如下通过定义 __call__ 方法的类实现了参数化装饰器 clock:
1 | import time |
clock(my_format)类构造函数返回一个clock实例,my_format 被存储为self.fmt- 有了
__call__方法,clock 实例就成为可调用对象了。调用实例的结果是把被装饰的函数替换成 clocked。 clocked 包装被装饰的函数