【节选自《流畅的Python》第7章-函数装饰器和闭包】
综述
函数装饰器用于在源码中“标记”函数,以某种方式增强函数的行为。这是一项强大的功能,但是若想掌握,必须理解闭包。
nonlocal 是新近出现的保留关键字,在 Python 3.0 中引入。作为 Python 程序员,如果严格遵守基于类的面向对象编程方式,即便不知道这个关键字也不会受到影响。然而,如果你想自己实现函数装饰器,那就必须了解闭包的方方面面,因此也就需要知道nonlocal。
除了在装饰器中有用处之外,闭包还是回调式异步编程和函数式编程风格的基础。
一、装饰器基础知识
装饰器是可调用的对象,其参数是另一个函数(被装饰的函数)。 装饰器可能会处理被装饰的函数,然后把它返回,或者将其替换成另一个函数或可调用对象。
假如有个名为 decorate 的装饰器:
@decorate def target(): print('running target()')
上述代码的效果与下述写法一样:
def target(): print('running target()') target = decorate(target)
两种写法的最终结果一样:上述两个代码片段执行完毕后得到的 target 不一定是原来那个 target 函数,而是 decorate(target) 返回的函数。
为了确认被装饰的函数会被替换,请看示例 7-1 中的控制台会话。
示例 7-1 装饰器通常把函数替换成另一个函数
def deco(func): def inner(): print('running inner()') return inner #1 @deco def target(): #2 print('running target()') target() #3 print(target) #4
1、deco 返回 inner 函数对象。(注意:不能写成return inner()!)
2、使用 deco 装饰 target。
3、调用被装饰的 target 其实会运行 inner。
4、审查对象,发现 target 现在是 inner 的引用。
严格来说,装饰器只是语法糖。如前所示,装饰器可以像常规的可调用对象那样调用,其参数是另一个函数。有时,这样做更方便,尤其是做元编程(在运行时改变程序的行为)时。
综上,装饰器的一大特性是,能把被装饰的函数替换成其他函数。第二个特性是,装饰器在加载模块时立即执行。下一节会说明。
二、Python何时执行装饰器
装饰器的一个关键特性是,它们在被装饰的函数定义之后立即运行。这通常是在导入时(即 Python 加载模块时),如示例 7-2 中的 registration.py 模块所示。
示例 7-2 registration.py 模块
registry = [] #1 def register(func): #2 print('running register(%s)' % func) #3 registry.append(func) #4 return func #5 @register #6 def f1(): print('running f1()') @register def f2(): print('runnning f2()') def f3(): #7 print('running f3()') def main(): #8 print('running main()') print('registry -> ', registry) f1() f2() f3() if __name__ == '__main__': main() #9
1、registry 保存被 @register 装饰的函数引用。
2、register 的参数是一个函数。
3、为了演示,显示被装饰的函数。
4、把 func 存入 registry。
5、返回 func:必须返回函数;这里返回的函数与通过参数传入的一样。
6、f1 和 f2 被 @register 装饰。
7、f3 没有装饰。
8、main 显示 registry,然后调用 f1()、f2() 和 f3()。
9、 只有把 registration.py 当作脚本运行时才调用 main()。
把 registration.py 当作脚本运行得到的输出如下:
running register() running register( ) running main() registry -> [ , ] running f1() running f2() running f3()
注意,register 在模块中其他函数之前运行(两次)。调用 register 时,传给它的参数是被装饰的函数,例如
加载模块后,registry 中有两个被装饰函数的引用:f1 和 f2。这两个函数,以及 f3,只在 main 明确调用它们时才执行。
如果导入 registration.py 模块(不作为脚本运行),输出如下:
>>> import registration running register() running register( )
此时查看 registry 的值,得到的输出如下:
>>> registration.registry [, ]
示例 7-2 主要想强调,函数装饰器在导入模块时立即执行,而被装饰的函数只在明确调用时运行。这突出了 Python 程序员所说的导入时和运行时之间的区别。
考虑到装饰器在真实代码中的常用方式,示例 7-2 有两个不寻常的地方。
1、装饰器函数与被装饰的函数在同一个模块中定义。实际情况是,装饰器通常在一个模块中定义,然后应用到其他模块中的函数上。
2、register 装饰器返回的函数与通过参数传入的相同。实际上,大多数装饰器会在内部定义一个函数,然后将其返回。
虽然示例 7-2 中的 register 装饰器原封不动地返回被装饰的函数,但是这种技术并非没有用处。很多 Python Web 框架使用这样的装饰器把函数添加到某种中央注册处,例如把URL 模式映射到生成 HTTP 响应的函数上的注册处。这种注册装饰器可能会也可能不会修改被装饰的函数。
三、变量作用域规则
在示例 7-4 中,我们定义并测试了一个函数,它读取两个变量的值:一个是局部变量 a,是函数的参数;另一个是变量 b,这个函数没有定义它。
示例 7-4 一个函数,读取一个局部变量和一个全局变量
def f1(a): print(a) print(b) f1(3)
输出:
3 Traceback (most recent call last): File "<...>", line 1, inFile "<...>", line 3, in f1 NameError: name 'b' is not defined
出现错误并不奇怪。 在示例 7-4 中,如果先给全局变量 b 赋值,然后再调用 f,那就不会出错:
def f1(a): print(a) print(b) b = 6 f1(3) # 输出: # 3 # 6
下面看一个可能会让你吃惊的示例。
看一下示例 7-5 中的 f2 函数。前两行代码与示例 7-4 中的 f1 一样,然后为 b 赋值,再打印它的值。可是,在赋值之前,第二个 print 失败了。
示例 7-5 b 是局部变量,因为在函数的定义体中给它赋值了
b = 6 def f2(a): print(a) print(b) b = 9 f2(3)
输出:
3 Traceback (most recent call last): File "<...>", line 1, inFile "<...>", line 3, in f2 UnboundLocalError: local variable 'b' referenced before assignment
注意,首先输出了 3,这表明 print(a) 语句执行了。但是第二个语句 print(b) 执行不了。一开始我很吃惊,我觉得会打印 6,因为有个全局变量 b,而且是在 print(b) 之后为局部变量 b 赋值的。可事实是,Python 编译函数的定义体时,它判断 b 是局部变量,因为在函数中给它赋值了,Python 会尝试从本地环境获取 b。后面调用 f2(3)时, f2 的定义体会获取并打印局部变量 a 的值,但是尝试获取局部变量 b 的值时,发现b 没有绑定值。
这不是缺陷,而是设计选择:Python 不要求声明变量,但是在函数定义体中赋值的变量就是局部变量。这比 JavaScript 的行为好多了,JavaScript 也不要求声明变量,但是如果忘记把变量声明为局部变量(使用 var),可能会在不知情的情况下获取全局变量。
如果在函数中赋值时想让解释器把 b 当成全局变量,要使用 global 声明:
b = 6 def f3(a): global b print(a) print(b) b = 9 print(f3(3)) # Output: # 3 # 6 print(b) # Output: # 9 print(f3(3)) # Output: # 3 # 9 b = 30 print(b) # Output: # 30
了解 Python 的变量作用域之后,下一节可以讨论闭包了。
四、闭包
在博客圈,人们有时会把闭包和匿名函数弄混。这是有历史原因的:在函数内部定义函数不常见,直到开始使用匿名函数才会这样做。而且,只有涉及嵌套函数时才有闭包问题。因此,很多人是同时知道这两个概念的。
其实,闭包指延伸了作用域的函数,其中包含函数定义体中引用、但是不在定义体中定义的非全局变量。函数是不是匿名的没有关系,关键是它能访问定义体之外定义的非全局变量。
这个概念难以掌握,最好通过示例理解。
假如有个名为 avg 的函数,它的作用是计算不断增加的系列值的均值;例如,整个历史中某个商品的平均收盘价。每天都会增加新价格,因此平均值要考虑至目前为止所有的价格。
起初,avg 是这样使用的:
>>> avg(10) 10.0 >>> avg(11) 10.5 >>> avg(12) 11.0
avg 从何而来,它又在哪里保存历史值呢?
初学者可能会像示例 7-8 那样使用类实现。
示例 7-8 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
示例 7-9 是函数式实现,使用高阶函数 make_averager。
示例 7-9 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 时,它会把参数添加到系列值中,然后计算当前平均值,如示例 7-10 所示。
示例 7-10 测试示例 7-9
>>> avg = make_averager() >>> avg(10) 10.0 >>> avg(11) 10.5 >>> avg(12) 11.0
注意,这两个示例有共通之处:调用 Averager() 或 make_averager() 得到一个可调用对象 avg,它会更新历史值,然后计算当前均值。在示例 7-8 中,avg 是 Averager 的实例;在示例 7-9 中是内部函数 averager。不管怎样,我们都只需调用 avg(n),把 n 放入系列值中,然后重新计算均值。
Averager 类的实例 avg 在哪里存储历史值很明显:self.series 实例属性。但是第二个示例中的 avg 函数在哪里寻找 series 呢?
注意,series 是 make_averager 函数的局部变量,因为那个函数的定义体中初始化了series:series = []。可是,调用 avg(10) 时,make_averager 函数已经返回了,而它的本地作用域也一去不复返了。
在 averager 函数中,series 是自由变量(free variable)。这是一个技术术语,指未在本地作用域中绑定的变量,参见图 7-1。
图 7-1:averager 的闭包延伸到那个函数的作用域之外,包含自由变量 series 的绑定
审查返回的 averager 对象,我们发现 Python 在 __code__ 属性(表示编译后的函数定义体)中保存局部变量和自由变量的名称,如示例 7-11 所示。
示例 7-11 审查 make_averager(见示例 7-9)创建的函数
>>> avg.__code__.co_varnames ('new_value', 'total') >>> avg.__code__.co_freevars ('series',)
series 的绑定在返回的 avg 函数的 __closure__ 属性中。avg.__closure__ 中的各个元素对应于 avg.__code__.co_freevars 中的一个名称。这些元素是 cell 对象,有个cell_contents 属性,保存着真正的值。这些属性的值如示例 7-12 所示。
示例 7-12 接续示例 7-11
>>> avg.__code__.co_freevars ('series',) >>> avg.__closure__ (,) >>> avg.__closure__[0].cell_contents [10, 11, 12] |
综上,闭包是一种函数,它会保留定义函数时存在的自由变量的绑定,这样调用函数时,虽然定义作用域不可用了,但是仍能使用那些绑定。
注意,只有嵌套在其他函数中的函数才可能需要处理不在全局作用域中的外部变量。
五、nonlocal声明
前面实现 make_averager 函数的方法效率不高。在示例 7-9 中,我们把所有值存储在历史数列中,然后在每次调用 averager 时使用 sum 求和。更好的实现方式是,只存储目前的总值和元素个数,然后使用这两个数计算均值。
示例 7-13 中的实现有缺陷,只是为了阐明观点。你能看出缺陷在哪儿吗?
示例 7-13 计算移动平均值的高阶函数,不保存所有历史值,但有缺陷
def make_averager(): count = 0 total = 0 def averager(new_value): count += 1 total += new_value return total/count return averager
尝试使用示例 7-13 中定义的函数,会得到如下结果:
>>> avg = make_averager() >>> avg(10) Traceback (most recent call last): ... UnboundLocalError: local variable 'count' referenced before assignment
问题是,当 count 是数字或任何不可变类型时,count += 1 语句的作用其实与 count = count + 1 一样。因此,我们在 averager 的定义体中为 count 赋值了,这会把count 变成局部变量。total 变量也受这个问题影响。
示例 7-9 没遇到这个问题,因为我们没有给 series 赋值,我们只是调用series.append,并把它传给 sum 和 len。也就是说,我们利用了列表是可变的对象这一事实。
但是对数字、字符串、元组等不可变类型来说,只能读取,不能更新。如果尝试重新绑定,例如 count = count + 1,其实会隐式创建局部变量 count。这样,count 就不是自由变量了,因此不会保存在闭包中。
为了解决这个问题,Python 3 引入了 nonlocal 声明。它的作用是把变量标记为自由变量,即使在函数中为变量赋予新值了,也会变成自由变量。如果为 nonlocal 声明的变量赋予新值,闭包中保存的绑定会更新。最新版 make_averager 的正确实现如示例 7-14 所示。
示例 7-14 计算移动平均值,不保存所有历史(使用 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 avg = make_averager() print(avg(10))
至此,我们了解了 Python 闭包,下面可以使用嵌套函数正式实现装饰器了。
六、实现一个简单的装饰器
示例 7-15 定义了一个装饰器,它会在每次调用被装饰的函数时计时,然后把经过的时间、传入的参数和调用的结果打印出来。
示例 7-15 一个简单的装饰器,输出函数的运行时间
import time def clock(func): def clocked(*args): #1 t0 = time.perf_counter() result = func(*args) #2 elapsed = time.perf_counter() - t0 name = func.__name__ arg_str = ', '.join(repr(arg) for arg in args) print('[%0.8fs] %s(%s) -> %r' % (elapsed, name, arg_str, result)) return result return clocked #3
1、定义内部函数 clocked,它接受任意个定位参数。
2、这行代码可用,是因为 clocked 的闭包中包含自由变量 func。
3、返回内部函数,取代被装饰的函数。示例 7-16 演示了 clock 装饰器的用法。
示例 7-16 使用 clock 装饰器
# clockdeco_demo.py import time from clockdeco 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))
运行示例 7-16 得到的输出如下:
**************************************** Calling snooze(123) [0.12405610s] snooze(.123) -> None **************************************** Calling factorial(6) [0.00000191s] factorial(1) -> 1 [0.00004911s] factorial(2) -> 2 [0.00008488s] factorial(3) -> 6 [0.00013208s] factorial(4) -> 24 [0.00019193s] factorial(5) -> 120 [0.00026107s] 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)
因此,在两个示例中,factorial 会作为 func 参数传给 clock(参见示例 7-15)。然后, clock 函数会返回 clocked 函数,Python 解释器在背后会把 clocked 赋值给factorial。
其实,导入 clockdeco_demo 模块后查看 factorial 的 __name__ 属性,会得到如下结果:
>>> import clockdeco_demo >>> clockdeco_demo.factorial.__name__ 'clocked'
所以,现在 factorial 保存的是 clocked 函数的引用。自此之后,每次调用factorial(n),执行的都是 clocked(n)。clocked 大致做了下面几件事。
(1) 记录初始时间 t0。
(2) 调用原来的 factorial 函数,保存结果。
(3) 计算经过的时间。
(4) 格式化收集的数据,然后打印出来。
(5) 返回第 2 步保存的结果。
这是装饰器的典型行为:把被装饰的函数替换成新函数,二者接受相同的参数,而且(通常)返回被装饰的函数本该返回的值,同时还会做些额外操作。
示例 7-15 中实现的 clock 装饰器有几个缺点:不支持关键字参数,而且遮盖了被装饰函数的 __name__ 和 __doc__ 属性。
示例 7-17 使用 functools.wraps 装饰器把相关的属性从 func 复制到 clocked 中。此外,这个新版还能正确处理关键字参数。
示例 7-17 改进后的 clock 装饰器
# clockdeco2.py import time import functools def clock(func): @functools.wraps(func) def clocked(*args, **kwargs): t0 = time.time() result = func(*args, **kwargs) elapsed = time.time() - t0 name = func.__name__ arg_lst = [] if args: arg_lst.append(', '.join(repr(arg) for arg in args)) if kwargs: pairs = ['%s=%r' % (k, w) for k, w in sorted(kwargs.items())] arg_lst.append(', '.join(pairs)) arg_str = ', '.join(arg_lst) print('[%0.8fs] %s(%s) -> %r ' % (elapsed, name, arg_str, result)) return result return clocked
functools.wraps 只是标准库中拿来即用的装饰器之一。下一节将介绍 functools 模块中最让人印象深刻的两个装饰器:lru_cache 和 singledispatch。
七、叠放装饰器
把 @d1 和 @d2 两个装饰器按顺序应用到 f 函数上,作用相当于 f = d1(d2(f))。
也就是说,下述代码:
@d1 @d2 def f(): print('f')
等同于:
def f(): print('f') f = d1(d2(f))
八、参数化装饰器
解析源码中的装饰器时,Python 把被装饰的函数作为第一个参数传给装饰器函数。那怎么让装饰器接受其他参数呢?
答案是:创建一个装饰器工厂函数,把参数传给它,返回一个装饰器,然后再把它应用到要装饰的函数上。不明白什么意思?
当然。下面以我们见过的最简单的装饰器为例说明:示例 7-22 中的 register。
示例 7-22 示例 7-2 中 registration.py 模块的删减版,这里再次给出是为了便于讲解
registry = [] def register(func): print('running register(%s)' % func) registry.append(func) return func @register def f1(): print('running f1()') print('running main()') print('registry -> ', registry) f1()
1、一个参数化de注册装饰器
为了便于启用或禁用 register 执行的函数注册功能,我们为它提供一个可选的 active参数,设为 False 时,不注册被装饰的函数。实现方式参见示例 7-23。
从概念上看,这个新的 register 函数不是装饰器,而是装饰器工厂函数。调用它会返回真正的装饰器,其中参数作为自由变量在真正的装饰器中被引用!这才是应用到目标函数上的装饰器。
示例 7-23 为了接受参数,新的 register 装饰器必须作为函数调用
registry = set() #1 def register(active=True): #2 def docorate(func): #3 print('running register(active=%s)->decorate(%s)' % (active, func)) if active: #4 registry.add(func) else: registry.discard(func) #5 return func #6 return docorate #7 @register(active=False) #8 def f1(): print('running f1()') @register() #9 def f2(): print('running f2()') def f3(): print('running f3()')
模块加载后,会输出:
running register(active=False)->decorate(
running register(active=True)->decorate(
再一次说明了:函数装饰器在导入模块时会立即执行。
1、registry 现在是一个 set 对象,这样添加和删除函数的速度更快。
2、register 接受一个可选的关键字参数。
3、decorate 这个内部函数是真正的装饰器;注意,它的参数是一个函数。
4、只有 active 参数的值(从闭包中获取)是 True 时才注册 func。
5、 如果 active 不为真,而且 func 在 registry 中,那么把它删除。
6、 decorate 是装饰器,必须返回一个函数。
7、 register 是装饰器工厂函数,因此返回 decorate。
8、@register 工厂函数必须作为函数调用,并且传入所需的参数。
9、即使不传入参数,register 也必须作为函数调用(@register()),即要返回真正的装饰器 decorate。
这里的关键是,register() 要返回 decorate,然后把它应用到被装饰的函数上,其中active是自由变量。
示例 7-23 中的代码在 registration_param.py 模块中。如果导入,得到的结果如下:
>>> import registration_param running register(active=False)->decorate() running register(active=True)->decorate( ) >>> registration_param.registry { }
注意,只有 f2 函数在 registry 中;f1 不在其中,因为传给 register 装饰器工厂函数的参数是 active=False,所以应用到 f1 上的 decorate 没有把它添加到 registry中。
如果不使用 @ 句法,那就要像常规函数那样使用 register;若想把 f 添加到 registry中,则装饰 f 函数的句法是 register()(f);不想添加(或把它删除)的话,句法是register(active=False)(f)。示例 7-24 演示了如何把函数添加到 registry 中,以及如何从中删除函数。
示例 7-24 使用示例 7-23 中的 registration_param 模块
>>> from registration_param import * running register(active=False)->decorate() running register(active=True)->decorate( ) >>> registry #1 { } >>> register()(f3) #2 running register(active=True)->decorate( ) >>> registry #3 { , } >>> register(active=False)(f2) #4 running register(active=False)->decorate( ) >>> registry #5 { }
1、导入这个模块时,f2 在 registry 中。
2、register() 表达式返回 decorate,然后把它应用到 f3 上。
3、前一行把 f3 添加到 registry 中。
4、这次调用从 registry 中删除 f2。
5、确认 registry 中只有 f3。
参数化装饰器的原理相当复杂,我们刚刚讨论的那个比大多数都简单。参数化装饰器通常会把被装饰的函数替换掉,而且结构上需要多一层嵌套。接下来会探讨这种函数金字塔。
2、参数化clock装饰器
本节再次探讨 clock 装饰器,为它添加一个功能:让用户传入一个格式字符串,控制被装饰函数的输出。参见示例 7-25。
示例 7-25 clockdeco_param.py 模块:参数化 clock 装饰器
import time DEFAULT_FMT = '[{elapsed:0.8f}s]{name}{args} -> {result}' def clock(fmt=DEFAULT_FMT): #1 def decorate(func): #2 def clocked(*_args): #3 t0 = time.time() _result = func(*_args) #4 elapsed = time.time() name = func.__name__ args = ', '.join(repr(arg) for arg in _args) #5 result = repr(_result) #6 print(fmt.format(**locals())) #7 return result #8 return clocked #9 return decorate #10 if __name__ == '__main__': @clock() def snooze(scconds): time.sleep(scconds) for i in range(3): snooze(.123)
1、 clock 是参数化装饰器工厂函数。
2、 decorate 是真正的装饰器。
3、 clocked 包装被装饰的函数。
4、 _result 是被装饰的函数返回的真正结果。
5、 _args 是 clocked 的参数,args 是用于显示的字符串。
6、result 是 _result 的字符串表示形式,用于显示。
7、 这里使用 **locals() 是为了在 fmt 中引用 clocked 的局部变量。
8、 clocked 会取代被装饰的函数,因此它应该返回被装饰的函数返回的值。
9、 decorate 返回 clocked。
10、 clock 返回 decorate。
11、在这个模块中测试,不传入参数调用 clock(),因此应用的装饰器使用默认的格式str。
在 shell 中运行示例 7-25,会得到下述结果:
$ python3 clockdeco_param.py [0.12412500s] snooze(0.123) -> None [0.12411904s] snooze(0.123) -> None [0.12410498s] snooze(0.123) -> None
示例 7-26 和示例 7-27 是另外两个模块,它们使用了 clockdeco_param 模块中的新功能,随后是两个模块输出的结果。
示例 7-26 clockdeco_param_demo1.py
import time from clockdeco_param import clock @clock('{name}: {elapsed}s') def snooze(seconds): time.sleep(seconds) for i in range(3): snooze(.123)
示例 7-26 的输出:
$ python3 clockdeco_param_demo1.py
snooze: 0.12414693832397461s
snooze: 0.1241159439086914s
snooze: 0.12412118911743164s
示例 7-27 clockdeco_param_demo2.py
import time from clockdeco_param import clock @clock('{name}({args}) dt={elapsed:0.3f}s') def snooze(seconds): time.sleep(seconds) for i in range(3): snooze(.123)
示例 7-27 的输出:
$ python3 clockdeco_param_demo2.py snooze(0.123) dt=0.124s snooze(0.123) dt=0.124s snooze(0.123) dt=0.124s
受本书篇幅限制,我们对装饰器的探讨到此结束。延伸阅读中的资料讨论了构建工业级装饰器的技术,尤其是 Graham Dumpleton 的博客和 wrapt 模块。