0%

函数装饰器和闭包

装饰器的一大特性是,能把被装饰的函数替换成其他函数。第二个特性是,装饰器在加载模块时立即执行。

下面是第一个特性:

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 0x10063b598>

被装饰的target现在是inner的引用。

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
registry = []
def register(func):
print('running register(%s)' % func)
registry.append(func)
return func

@register
def f1():
print('runningf1()')

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

def f3():
print('running f3()')

def main():
print('running main()')
print('registry ->', registry)
f1()
f2()
f3()

if__name__=='__main__':
main()

运行:

1
2
3
4
5
6
7
8
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()

如果直接导入:

1
2
3
4
5
>>> import registration
running register(<function f1 at 0x10063b1e0>)
running register(<function f2 at 0x10063b268>)
>>> registration.registry
[<function f1 at 0x10063b1e0>, <function f2 at 0x10063b268>]

函数装饰器在导入模块时立即执行,而被装饰的函数只在明确调用时运行。这突出了 Python 程序员所说的导入时和运行时之间的区别。

使用装饰器改进“策略”模式

之前的模式的主要问题是,定义体中有函数的名称,但是best_promo 用来判断哪个折扣幅度最大的 promos 列表中也有函数名称。这种重复是个问题,因为新增策略函数后可能会忘记把它添加到promos 列表中,导致 best_promo 忽略新策略,而且不报错,为系统引入了不易察觉的缺陷。
下面是用装饰器实现:

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
30
31
32
promos = []

def promotion(promo_func): # promotion 把 promo_func 添加到 promos 列表中,然后原封不动地将其返回。
promos.append(promo_func)
return promo_func


@promotion # 被 @promotion 装饰的函数都会添加到 promos 列表中。
def fidelity(order):
"""为积分为1000或以上的顾客提供5%折扣"""
return order.total() * .05 if order.customer.fidelity >= 1000 else 0


@promotion
def bulk_item(order):
"""单个商品为20个或以上时提供10%折扣"""
discount = 0
for item in order.cart:
if item.quantity >= 20:
discount += item.total() * .1
return discount

@promotion
def large_order(order):
"""订单中的不同商品达到10个或以上时提供7%折扣"""
distinct_items = {item.product for item in order.cart}
if len(distinct_items) >= 10:
return order.total() * .07
return 0

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

这个方案有几个优点。

  • 促销策略函数无需使用特殊的名称(即不用以 _promo 结尾)。
  • @promotion 装饰器突出了被装饰的函数的作用,还便于临时禁用某个促销策略:只需把装饰器注释掉。
  • 促销折扣策略可以在其他模块中定义,在系统中的任何地方都行,只要使用 @promotion 装饰即可。

变量作用域规则

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不要求声明变量,但是假定在函数定义体中赋值的变量是局部变量。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
In [8]: b = 6
In [9]: def f3(a):
...: global b
...: print(a)
...: print(b)
...: b = 9
In [10]: f3(3)
3
6
In [11]: b
Out[11]: 9

In [12]: f3(3)
3
9

闭包

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

看下面的示例:

1
2
3
4
5
6
7
8
9
class Average:
def __init__(self):
self.series = []


def __call__(self, new_value):
self.series.append(new_value)
total = sum(self.series)
return total / len(self.series)

使用:

1
2
3
4
5
6
7
>>> avg = Averager()
>>> avg(10)
10.0
>>> avg(11)
10.5
>>> avg(12)
11.0

或者可以用函数实现:

1
2
3
4
5
6
7
8
9
def make_averager():   
series = [] # 自由变量。 #
#
def averager(new_value): #
series.append(new_value) # 这一块就是闭包
total = sum(series) #
return total / len(series) #

return averager
1
2
3
4
5
6
7
>>> avg = make_averager()
>>> avg(10)
10.0
>>> avg(11)
10.5
>>> avg(12)
11.0

这两个示例有共通之处:调用 Averager() 或make_averager() 得到一个可调用对象 avg,它会更新历史值,然后计算当前均值。

在 averager 函数中,series 是自由变量(free variable)。
averager 的闭包延伸到那个函数的作用域之外,包含自由变量 series 的绑定。

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

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

nolocal声明

下面用另一种方式实现上面的make_averager():

1
2
3
4
5
6
7
8
def make_averager():
count = 0
total = 0
def averager(new_value):
count += 1
total += new_value
return total / count
return averager
1
2
3
4
5
6
>>> 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 变量也受这个问题影响。

为了解决这个问题,Python 3 引入了 nonlocal 声明。它的作用是把变量标记为自由变量,即使在函数中为变量赋予新值了,也会变成自由变量。

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

实现一个简单的装饰器

1
2
3
4
5
6
7
8
9
10
11
12
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 # 返回内部函数,取代被装饰的函数。

使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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))

工作原理

1
2
3
4
5
6
7
8
@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。

上面实现的 clock 装饰器有几个缺点:不支持关键字参数,而且遮盖了被装饰函数的 __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
importimporttime
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

参数化装饰器

为了便于启用或禁用 register 执行的函数注册功能,我们为它提供一个可选的 active 参数,设为 False 时,不注册被装饰的函数。
从概念上看,这个新的 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
registry = set()  # registry 现在是一个 set 对象,这样添加和删除函数的速度更快。

def register(active=True):
def decorate(func): # decorate 这个内部函数是真正的装饰器;注意,它的参数是一个函数。
print('running register(active=%s)->decorate(%s))' % (active, func))
if active: # 只有 active 参数的值(从闭包中获取)是 True 时才注册 func
registry.add(func)
else:
registry.discard(func) # 如果 active 不为真,而且 func 在 registry 中,那么把它删除
return func # decorate 是装饰器,必须返回一个函数

return decorate # register 是装饰器工厂函数,因此返回 decorate


@register(active=False) # @register 工厂函数必须作为函数调用,并且传入所需的参数
def f1():
print('running f1()')

@register() # 即使不传入参数,register 也必须作为函数调用
def f2():
print('running f2()')

def f3():
print('running f3()')

这里的关键是,register() 要返回 decorate,然后把它应用到被装饰的函数上。