函数装饰器和闭包

函数装饰器

函数装饰器用于在源码中“标记”函数,以某种方式增强函数的行为。这是一项强大的功能,但是若想掌握,必须理解闭包。

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

假如有个名为 decorate 的装饰器:

1
2
3
4
5
6
7
def deco(func):
def inner():
print('running inner()')
return inner
@decorate
def target():
print('running target()')

上述代码的效果与下述写法一样:

1
2
3
4
5
6
7
def deco(func):
def inner():
print('running inner()')
return inner
def target():
print('running target()')
target = decorate(target)

两种写法的最终结果一样:上述两个代码片段执行完毕后得到的 target 不一定是原来那
个 target 函数,而是 decorate(target) 返回的函数,也就是inner的引用。

1
2
3
4
5
6
7
8
9
10
11
12
13
>>>def deco(func):
>>> func()
>>> def inner():
>>> print('running inner()')
>>> return inner # deco 返回 inner 函数对象。
>>>@decorate
>>>def target(): # 使用 deco 装饰 target。
>>> print('running target()')
>>>target() # 调用被装饰的 target 其实会运行 inner。
>>>running target()
>>>running inner()
>>> target # 审查对象,发现 target 现在是 inner 的引用。
<function deco.<locals>.inner at 0x10063b598>

装饰器可以像常规的可调用对象那样调用,其参数是另一个函数。有时,这样做更方便,尤其是做元编程(在运行时改变程序的行为)时。综上,装饰器的一大特性是,能把被装饰的函数替换成其他函数。第二个特性是,装饰器在加载模块时立即执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
registry = []  # registry 保存被 @register 装饰的函数引用。
def register(func): # register 的参数是一个函数。
print('running register(%s)' % func) # 为了演示,显示被装饰的函数。
registry.append(func) #把 func 存入 registry。
return func # 返回 func:必须返回函数;这里返回的函数与通过参数传入的一样。
@register # f1 和 f2 被 @register 装饰。
def f1():
print('running f1()')
@register
def f2():
print('running f2()')
def f3(): # f3 没有装饰。
print('running f3()')
def main(): # main 显示 registry,然后调用 f1()、f2() 和 f3()。
print('running main()')
print('registry ->', registry)
f1()
f2()
f3()
if __name__=='__main__':
main() # 只有把 registration.py 当作脚本运行时才调用 main()。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ 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()

>>> 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>]

上例子主要说明了装饰器的一个关键特性是,它们在被装饰的函数定义之后立即运行这通常是在导入时(即 Python 加载模块时)函数装饰器在导入模块时立即执行,而被装饰的函数只在明确调用时运行。这突出了 Python 程序员所说的导入时和运行时之间的区别。

  • 装饰器通常在一个模块中定义,然后应用到其他模块中的函数上。
  • 大多数装饰器会在内部定义一个函数,然后将其返回。

下面给出三种修饰器的输出,以便思考

  1. 1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    >>> def deco(func):
    ... def inner():
    ... print('running inner()')
    ... return func
    ...
    >>> @deco
    ... def target():
    ... print('running target()')
    ...
    >>> target()
    running target()

    <<<running target()
  2. 1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    >>> def deco(func):
    ... def inner():
    ... print('running inner()')
    ... return inner
    ...
    >>> @deco
    ... def target():
    ... print('running target()')
    ...
    >>> target()
    running inner()

3.

1
2
3
4
5
6
7
8
9
10
11
12
13
>>> def deco(func):
... func()
... def inner():
... print('running inner()')
... return inner
...
>>> @deco
... def target():
... print('running target()')
...
running target()
>>> target()
running inner()

变量作用域规则

给出三个例子对比

1.

1
2
3
4
5
6
7
8
9
10
>>> 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

2.

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

3.

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

在第三个例子中Python 编译函数的定义体时,它判断 b 是局部变量,因为在函数中给它赋值了。生成的字节码证实了这种判断,Python 会尝试从本地环境获取 b。后面调用 f2(3)时, f2 的定义体会获取并打印局部变量 a 的值,但是尝试获取局部变量 b 的值时,发现b 没有绑定值。

这不是缺陷,而是设计选择:Python 不要求声明变量,但是假定在函数定义体中赋值的变量是局部变量。这比 JavaScript 的行为好多了,JavaScript 也不要求声明变量,但是如果忘记把变量声明为局部变量(使用 var),可能会在不知情的情况下获取全局变量。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
>>> b = 6
>>> def f3(a):
... global b
... print(a)
... print(b)
... b = 9
...
>>> f3(3)
3
6
>>> b
9
>>> f3(3)
3
9
>>> b = 30
>>> b
30

闭包

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

示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def make_averager():
series = []
def averager(new_value):
series.append(new_value)
total = sum(series)
return total/len(series)
return averager

>>> avg = make_averager()
>>> avg(10)
10.0
>>> avg(11)
10.5
>>> avg(12)
11.0

series 是 make_averager 函数的局部变量,因为那个函数的定义体中初始化了series:series = []。
在 averager 函数中,series 是自由变量(free variable)。这是一个技术术语,指未在本地作用域中绑定的变量
averager 的闭包延伸到那个函数的作用域之外,包含自由变量 series 的绑定

审查 make_averager(见下例)创建的函数

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

series 的绑定在返回的 avg 函数的 closure 属性中。avg.closure 中的各个元素对应于 avg.code.co_freevars 中的一个名称。这些元素是 cell 对象,有个cell_contents 属性,保存着真正的值。这些属性的值如下例 所示。
示例  接续上例

1
2
3
4
5
6
>>> avg.__code__.co_freevars
('series',)
>>> avg.__closure__
(<cell at 0x107a44f78: list object at 0x107a91a48>,)
>>> avg.__closure__[0].cell_contents
[10, 11, 12]

综上,闭包是一种函数,它会保留定义函数时存在的自由变量的绑定,这样调用函数时,虽然定义作用域不可用了,但是仍能使用那些绑定。

只有嵌套在其他函数中的函数才可能需要处理不在全局作用域中的外部变量。

nonlocal声明

错误示例

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

>>> avg = make_averager()
>>> avg(10)
Traceback (most recent call last):
...
UnboundLocalError: local variable 'count' referenced before assignment

对数字、字符串、元组等不可变类型来说,只能读取,不能更新。如果尝试重新绑定,例如 count = count + 1,其实会隐式创建局部变量 count。这样,count 就不是自由变量了,因此不会保存在闭包中。
而之前的列表series,我们没有给 series 赋值,我们只是调用series.append,并把它传给 sum 和 len。也就是说,我们利用了列表是可变的对象这一事实。

为了解决这个问题,Python 3 引入了 nonlocal 声明。
它的作用是把变量标记为自由变量,即使在函数中为变量赋予新值了,也会变成自由变量。如果为 nonlocal 声明的变量赋予新值,闭包中保存的绑定会更新。最新版 make_averager 的正确实现如示例 7-14 所示。

正确示例

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

对于python2中没有nonlocal声明的替代方法
把内部函数需要修改的变量(如 count 和 total)存储为可变对象(如字典或简单的实例)的元素或属性,并且把那个对象绑定给一个自由变量。也就是利用可变对象的可变。

修饰器原理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import time
def clock(func):
def clocked(*args): # 定义内部函数 clocked,它接受任意个定位参数。
t0 = time.perf_counter()
result = func(*args) # 这行代码可用,是因为 clocked 的闭包中包含自由变量 func。
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 # 返回内部函数,取代被装饰的函数。示例 7-16 演示了 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))
1
2
3
@clock
def factorial(n):
return 1 if n < 2 else n*factorial(n-1)

等价于

1
2
3
def factorial(n):
return 1 if n < 2 else n*factorial(n-1)
factorial = clock(factorial)

在修饰器里factorial 会作为 func 参数传给 clock。然后,factorial其实已经成为了clocked的自由变量了,然后 clock 函数会返回 clocked 函数,Python 解释器在背后会把 clocked 赋值给factorial。此时的factorial已经是clocked函数的引用了。而真正的一开始的factorial函数已经是clocked函数的自由变量。从而形成嵌套函数
我们可以查看 factorial 的 name 属性,
会得到如下结果:

1
2
3
>>> import clockdeco_demo
>>> clockdeco_demo.factorial.__name__
'clocked'

所以,现在 factorial 保存的是 clocked 函数的引用。自此之后,每次调用factorial(n),执行的都是 clocked(n)。
clocked 大致做了下面几件事。

(1) 记录初始时间 t0。
(2) 调用原来的 factorial 函数,保存结果。
(3) 计算经过的时间。
(4) 格式化收集的数据,然后打印出来。
(5) 返回第 2 步保存的结果。

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

使用functools.lru_cache做备忘

functools.lru_cache 是非常实用的装饰器,它实现了备忘(memoization)功能。这是一项优化技术,它把耗时的函数的结果保存起来,避免传入相同的参数时重复计算。LRU三个字母是“Least Recently Used”的缩写,表明缓存不会无限制增长,一段时间不用的缓存条目会被扔掉。

示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import functools
from clockdeco import clock
@functools.lru_cache() # 注意,必须像常规函数那样调用 lru_cache。这一行中有一对括号:@functools.lru_cache()。这么做的原因是,lru_cache 可以接受配置参数,稍后说明。
@clock # 这里叠放了装饰器:@lru_cache() 应用到 @clock 返回的函数上。
def fibonacci(n):
if n < 2:
return n
return fibonacci(n-2) + fibonacci(n-1)
if __name__=='__main__':
print(fibonacci(6))

#这样一来,执行时间减半了,而且 n 的每个值只调用一次函数:
$ python3 fibo_demo_lru.py
[0.00000119s] fibonacci(0) -> 0
[0.00000119s] fibonacci(1) -> 1
[0.00010800s] fibonacci(2) -> 1
[0.00000787s] fibonacci(3) -> 2
[0.00016093s] fibonacci(4) -> 3
[0.00001216s] fibonacci(5) -> 5
[0.00025296s] fibonacci(6) -> 8

特别要注意,lru_cache 可以使用两个可选的参数来配置。它的签名是:

1
functools.lru_cache(maxsize=128, typed=False)

maxsize 参数指定存储多少个调用的结果。缓存满了之后,旧的结果会被扔掉,腾出空间。为了得到最佳性能,maxsize 应该设为 2 的幂。

typed 参数如果设为 True,把不同参数类型得到的结果分开保存,即把通常认为相等的浮点数和整数参数(如 1 和 1.0)区分开。

顺便说一下,因为 lru_cache 使用字典存储结果,而且键根据调用时传入的定位参数和关键字参数创建,所以被 lru_cache 装饰的函数,它的所有参数都必须是可散列的。

functools.singledispatch 装饰器

单分派泛函数

Python 3.4 新增的 functools.singledispatch 装饰器可以把整体方案拆分成多个模块,甚至可以为你无法修改的类提供专门的函数。使用 @singledispatch 装饰的普通函数会变成泛函数(generic function):根据第一个参数的类型,以不同方式执行相同操作的一组函数。

这就是单分派。如果根据多个参数选择专门的函数,那就是多分派了。