0%

流畅的 Python(4):函数装饰器和闭包

函数装饰器用于在源码中标记函数,以某种方式增强函数的行为。这是一项强大的功能,若想彻底掌握它,首先必须理解闭包。除了在装饰器中有用处之外,闭包还是回调式异步编程和函数式编程风格的基础。

装饰器的基础知识

装饰器是可调用的对象,其参数是另一个函数(被装饰的函数)。装饰器可能会处理被装饰的函数,然后把它返回,或者将其替换成另一个函数或可调用对象。例如对于装饰器 dedocate

1
2
3
@dedocrate
def target():
print('running target()')

等效于

1
2
3
4
def target():
print('running target()')

target = decorate(target)

两种写法的最终结果一样,代码执行完成后得到的 target 不一定是原来的 target,而是 decorate(target) 返回的函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
>>> 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 0x10e238550>

可以看到,target 其实是 inner 的引用。严格来说,装饰器只是语法糖,装饰器可以像常规的可调用对象那样调用,其参数是另一个函数。有时这样做更方便,尤其是做元编程(在运行时改变程序的行为)时。综上,装饰器的一大特性就是,能把被装饰的函数替换成其他函数。

Python 何时执行装饰器

装饰器的一个关键特性是,它们在被装饰函数函数定义之后立即运行。这通常是在导入时(即 Python 加载模块时):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#!/usr/bin/env python3

registery = []

def register(func):
print('running register(%s)' % func)
registery.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('registery ->', registery)
f1()
f2()
f3()

if __name__ == '__main__':
main()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ ./register.py
running register(<function f1 at 0x104abff70>)
running register(<function f2 at 0x104acd040>)
running main()
registery -> [<function f1 at 0x104abff70>, <function f2 at 0x104acd040>]
running f1()
running f2()
running f3()

>>> import register
running register(<function f1 at 0x1048b0dc0>)
running register(<function f2 at 0x1048b01f0>)
>>> register.registery
[<function f1 at 0x1048b0dc0>, <function f2 at 0x1048b01f0>]

可以看到,register 在模块中其他函数之前运行。调用 register 时,传给它的参数是被装饰的函数。直接导入 register.py 模块时,register 也被运行。函数装饰器在导入模块时立即执行,而被装饰的函数只有在明确调用时才执行。这就是导入时和运行时之间的区别。

register 装饰器返回的函数与通过参数传入的相同。实际上,大多数装饰会在内部定义一个函数,然后将其返回。但是这种原封不动地返回被装饰的函数也不是没有用处,很多 Python 框架使用这种装饰器把函数添加到某个中央注册处。下面这个例子,使用装饰器改进上一篇文章中的电商折扣示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#!/usr/bin/env python3

promos = []

def promotion(promo_func):
promos.append(promo_func)
return promo_func

@promotion
def fidelity(order):
return order.total() * 0.05 if order.customer.fidelity >= 1000 else 0

@promotion
def bulkitem(order):
discount = 0
for item in order.cart:
if item.quantity >= 20:
discount += item.total() * 0.1
return discount

@promotion
def large_order_promo(order):
distinct_items = {item.product for item in order.cart}
if len(distinct_items) >= 10:
return order.total() * 0.07
return 0

def best_promo(order):
return max(promo(order) for promo in promos)

这里被 @promotion 装饰的函数都会被添加到 promos 列表当中,而 @promotion 装饰器也突出了被装饰的函数的作用。

大多数装饰器都会修改被装饰函数,通常它们会定义一个内部函数,然后将其返回,替换被装饰的函数。使用内部函数的代码几乎都要靠闭包才能正确运作,为了理解闭包,首先需要理解 Python 中变量的作用域。

变量作用域规则

1
2
3
4
5
6
7
8
>>> b = 1
>>> def func(a):
... print(a)
... print(b)
...
>>> func(3)
3
1

在这个例子中,函数 func 中访问局部变量 a 和 和全局变量 b。但是如果在函数 func 体内有个为变量 b 赋值的语句,那么结果就不一样了:

1
2
3
4
5
6
7
8
9
10
11
12
>>> b = 1
>>> def func(a):
... print(a)
... print(b)
... b = 2
...
>>> func(3)
3
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "<stdin>", line 3, in func
UnboundLocalError: local variable 'b' referenced before assignment

这里变量 b 并没有被输出,即使 print(b) 之前已经定义了全局变量 b。这是因为 Python 编译函数的定义体时,它判断 b 是局部变量(在函数中给它赋值了)。这不是设计缺陷,而是设计选择:Python 不要求声明变量,但是假定在函数定义体内赋值的变量是局部变量,

如果想在函数赋值时想让解释器把 b 当成全局变量,就要使用 global 声明:

1
2
3
4
5
6
7
8
9
10
11
12
>>> b = 1
>>> def func(a):
... global b
... print(a)
... print(b)
... b = 6
...
>>> func(3)
3
1
>>> b
6

闭包

闭包指延伸了作用域的函数,其中包含函数定义体中引用、但是不在定义体中定义的非全局变量。函数是不是匿名的没有关系,关键是它能访问定义体之外定义的非全局变量

假如要实现一个名为 avg 的函数,它计算不断增加的系列值的均值。用类可以这样实现:

1
2
3
4
5
6
7
8
9
#!/usr/bin/env python3

class Averager():
def __init__(self):
self.series = []

def __call__(self, new_value):
self.series.append(new_value)
return sum(self.series) / len(self.series)
1
2
3
4
5
6
7
>>> a = Averager()
>>> a(10)
10.0
>>> a(11)
10.5
>>> a(12)
11.0

以下是函数式实现:

1
2
3
4
5
6
7
def make_averager():
series = [];
def averager(new_value):
series.append(new_value)
return sum(series) / len(series)

return averager
  • 调用 make_averager() 返回一个函数对象,每次调用该函数对象,它会把参数添加到 series 中
  • series 是 make_averager() 函数的局部变量,因为在函数定义体中初始化了 series
  • 在 averager 函数中,series 是自由变量,自由变量指未在本地作用域中绑定的变量。可以认为 averager 的闭包延伸了到了该函数作用域之外,包含了自由变量 series 的绑定

内省返回的 averager 函数对象,__code__ 属性中保存了局部变量和自由变量的名称:

1
2
3
4
>>> a.__code__.co_varnames
('new_value',)
>>> a.__code__.co_freevars
('series',)

series 的绑定在函数对象的 __closure__ 属性中。__closure__ 属性中的各元素对应于 __code__.co_freevars 中的一个名称,这些元素是 cell 对象,有个 cell_contents 属性,保存着真正的值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
>>> a.__closure__
(<cell at 0x10fa0d5b0: list object at 0x10fab40c0>,)
>>> a.__closure__[0]
<cell at 0x10fa0d5b0: list object at 0x10fab40c0>
>>> a.__closure__[0].cell_contents
[]
>>> a(10)
10.0
>>> a.__closure__[0].cell_contents
[10]
>>> a(11)
10.5
>>> a.__closure__[0].cell_contents
[10, 11]

闭包是一种函数,它会保留定义函数时存在的自由变量的绑定,这样调用函数时,虽然定义作用域不可用了,但是仍能使用那些绑定。注意,只有嵌套在其他函数中的函数才可能需要处理不在全局作用域中的外部变量。

nonlocal 声明

如果换一种实现方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#!/usr/bin/env python3

def make_averager():
total = 0
count = 0
def averager(new_value):
total += new_value
count += 1
return total / count

return averager

a = make_averager()
print(a(10))
print(a(11))
print(a(12))

执行该程序,却出现如下错误:

1
UnboundLocalError: local variable 'total' referenced before assignment
  • 因为这里 total 是不可变类型,所以就地运算符 total += new_value 其实等效于 total = total + new_value,这样就在函数体内为 total 赋值了,这样 total 就变成局部变量了,所以就出现未赋值、先引用的问题。count 也存在同样的问题

对于不可变类型来说,如果出现重新绑定,就会隐式创建局部变量,这样它就不再是自由变量了,也不会保存在闭包中。为了解决这个问题,Python3 引入了 nonlocal 声明,它的作用是把变量标记为自由变量,这样即使在函数中为变量赋予了新值,该变量也仍然是自由变量。如果为 nonlocal 声明的变量赋予新值,闭包中保存的绑定会更新。

1
2
3
4
5
6
7
8
9
10
def make_averager():
total = 0
count = 0
def averager(new_value):
nonlocal total, count
total += new_value
count += 1
return total / count

return averager

实现一个简单的装饰器

如下实现了一个简单的装饰器,它会在每次调用被装饰的函数时计时,然后把经过的时间、传入的参数和调用的结果打印出来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#!/usr/bin/env python3

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('[%0.8fs] %s(%s) -> %r' % (elapsed, name, arg_str, result))
return result
return clocked

@clock
def snooze(seconds):
time.sleep(seconds)

if __name__ == '__main__':
print('*' * 40, 'Calling snooze(2)')
snooze(2)

工作原理如下:

  • clocked 的闭包中中包含了自由变量 func,所以 clocked 可以调用 func
  • clock 返回内部函数,取代被装饰的函数
  • 以下代码等价:
1
2
3
@clock
def snooze(seconds):
time.sleep(seconds)
1
2
3
def snooze(seconds):
time.sleep(seconds)
snooze = clock(snooze)

通过 snooze 的 name 属性可以确认这一点。

1
2
3
4
5
>>> import clock_decorate
>>> clock_decorate.snooze
<function clock.<locals>.clocked at 0x10ec80dc0>
>>> clock_decorate.snooze.__name__
'clocked'

另外也可以看到闭包 clocked 中包含了对自由变量 func 的绑定:

1
2
3
4
>>> clock_decorate.snooze.__code__.co_freevars
('func',)
>>> clock_decorate.snooze.__closure__[0].cell_contents
<function snooze at 0x10ec80ca0>

这是装饰器的典型行为:把被装饰的函数替换成新函数,二者接受相同的参数,而且通常返回被装饰的函数本该返回的值,同时还会做些额外操作。

clocked 装饰器还可以继续改进,例如其不支持关键字参数,另外被装饰函数的 __name____doc__ 属性。通过 functools.wraps 装饰器能把相关的属性从 func 复制到 clocked 中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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 = []
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

此时,再看 snooze 的 __name__ 属性,就不再是装饰函数的 __name__ 了:

1
2
>>> clock_decorate.snooze.__name__
'snooze'

标准库中的装饰器

Python 内置了三个用于装饰方法的函数,property、classmethod 和 staticmethod。另外常见的装饰器包括:

  • functools.wraps:用于构建行为良好的装饰器
  • functools.lru_cache:它实现了备忘功能,这是一项优化技术,它把耗时的函数的结果保存起来,避免传入相同的参数时重复计算。LRU 表示 Least Recently Used,表明缓存不会无限制增长,一段时间不用的条目会被扔掉
  • functools.singledispatch:可以把整体方案拆分成多个模块,甚至可以为你无法修改的类提供专门的函数。使用 @singledispatch 装饰的普通函数会变成泛函数。根据第一个参数的类型,以不同的方式执行相同操作的一组函数

首先介绍 functools.lru_cache 的用法。例如对于如下函数:

1
2
3
4
5
6
@functools.lru_cache()
@clock
def fibonacci(n):
if n < 2:
return n
return fibonacci(n - 2) + fibonacci(n - 1)

使用 lru_cache 之前,运算结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
[0.00000055s] fibonacci(1) -> 1
[0.00000046s] fibonacci(0) -> 0
[0.00000039s] fibonacci(1) -> 1
[0.00001501s] fibonacci(2) -> 1
[0.00006440s] fibonacci(3) -> 2
[0.00000033s] fibonacci(0) -> 0
[0.00000035s] fibonacci(1) -> 1
[0.00000959s] fibonacci(2) -> 1
[0.00000034s] fibonacci(1) -> 1
[0.00000042s] fibonacci(0) -> 0
[0.00000034s] fibonacci(1) -> 1
[0.00000970s] fibonacci(2) -> 1
[0.00001857s] fibonacci(3) -> 2
[0.00003692s] fibonacci(4) -> 3
[0.00011073s] fibonacci(5) -> 5

使用 lru_cache 之后,运算结果如下:

1
2
3
4
5
6
[0.00000049s] fibonacci(1) -> 1
[0.00000049s] fibonacci(0) -> 0
[0.00001045s] fibonacci(2) -> 1
[0.00006564s] fibonacci(3) -> 2
[0.00000068s] fibonacci(4) -> 3
[0.00007694s] fibonacci(5) -> 5

除了优化递归算法之外,lru_cache 在从 Web 中获取信息的应用中也能发挥作用。lru_cache 可以使用两个可选的参数来配置:

  • max_size 参数指定了存储多少个调用的结果,应该设置为 2 的幂
  • typed 参数可以把不同参数类型得到的结果分开保存

接下来再介绍 functools.singledispatch 的用法:假设需要开发一个 Web 调试工具,想通过 HTML 显示不同的 Python 对象:

1
2
3
4
5
import html

def htmlize(obj):
content = html.escape(repr(obj))
return '<pre>{}</pre>'.format(content)

这个函数适用于任何 Python 类型,但是如果想对其进行扩展,例如对于 str、int、list 等类型定制化其输出。因为 Python 不支持重载方法或函数,所以不能使用不同的签名定义 htmlize 的变体。一种可行方法是把 htmlize 变成一个分配函数,使用一系列的 if/elif/else 根据不同的类型调用专门的函数。但是这样不利于模块的用户扩展。

使用 singledispatch 创建一个自定义的 htmlize.register 装饰器,把多个函数绑在一起组成一个泛函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#!/usr/bin/env python3

from functools import singledispatch
from collections import abc
import numbers
import html

@singledispatch
def htmlize(obj):
content = html.escape(repr(obj))
return '<pre>{}</pre>'.format(content)

@htmlize.register(str)
def _(text):
content = html.escape(text).replace('\n', '<br>\n')
return '<p>{0}</p>'.format(content)

@htmlize.register(numbers.Integral)
def _(n):
return '<pre>{0} (0x{0:x})</pre>'.format(n)

@htmlize.register(tuple)
@htmlize.register(abc.MutableSequence)
def _(seq):
inner = '</li>\n<li>'.join(htmlize(item) for item in seq)
return '<ul>\n<li>' + inner + '</li>\n</ul>'
  • 使用 @singledispatch 标记处理一般类型的基函数
  • 各个专门函数使用 @base_function.register(type) 进行装饰
  • 专门函数的名称无关紧要,所以这里使用了 _
  • 为每个需要特殊处理的类型注册一个函数,这里使用抽象基类,这样支持的兼容类型更广
  • 可以叠放多个 register 装饰器,让同一个函数支持不同的类型

singledispatch 机制可以在系统的任何地方和任何模块中注册专门函数,可以为不是自己编写的或者不能修改的类添加自定义函数。@singledispatch 的优点是支持模块化扩展:各个模块可以为它支持的各个类型注册一个专门函数。

叠放装饰器

装饰器是函数,因此可以组合起来使用。把 @d1@d2,作用相当于 f = d1(d2(f))。如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#!/usr/bin/env python3

def decorate1(func):
def wrapper():
print("running in decorate1")
return func()
return wrapper

def decorate2(func):
def wrapper():
print("running in decorate2")
return func()
return wrapper

@decorate1
@decorate2
def func():
print("running func")

func()

参数化装饰器

解析源码中的装饰器时,Python 把被装饰的函数作为第一个参数传给装饰器函数。那如果装饰器需要接受其他参数呢?可以通过如下方式实现:

  • 创建一个装饰器工厂函数,把参数传给它,返回一个装饰器
  • 把返回的装饰器应用到要装饰的函数上

如下展示了一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
registery = set()

def register(active = True):
def decorate(func):
print('running register(active=%s)->decorate(%s)' % (active, func))
if active:
registery.add(func)
else:
registery.discard(func)
return func
return decorate

@register(active=False)
def f1():
print('running f1()')

@register()
def f2():
print('running f2()')

def f3():
print('running f3()')
  • register 接受一个参数,用于表示是否注册指定的函数
  • register 内部的 decorate 才是真正的装饰器,它的参数是一个函数
  • decorate 通过访问闭包里的自由变量 active 来判断是否注册 func
  • register 可以认为是装饰器工厂函数,返回装饰器
  • 必须使用 @register() 的方式来装饰函数,相当于首先通过 register() 函数调用产生装饰器,@ 语法作用于返回的装饰器上,对指定函数进行装饰。其效果等效于 register()(func)

参数化装饰器通常会把被装饰的函数替换掉,结构上需要多一层嵌套,下面还有一个小的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#!/usr/bin/env python3

DEFAULT_FMT = "args: {0} {1}"

def decorate_factory(fmt = DEFAULT_FMT):
def decorate(func):
def wrapper(*args, **kwargs):
print(fmt.format(args, kwargs))
return func(*args, **kwargs)
return wrapper
return decorate


@decorate_factory()
def func(*args, **kwargs):
print("running func")

func(1, a = 2)

在构建工业级装饰器时,最好通过实现了 __call__ 方法的类实现,而不是通过函数实现。