装饰器用于在源码中 “标记” 函数,以某种方式增强函数行为。这是一项强大的功能,但是如果想掌握,必须理解闭包
nonlocal 是新出现的关键字,在 Python 3.0 中引入。作为 Python 程序员,如果严格遵守基于类的面向对象编程方式,即使不知道这个关键字也没事,但是如果想自己实现函数装饰器,那就必须了解闭包的方方面面,因此也就需要知道 nonlocal
这一章中我们主要讨论的话题如下:、
- Python 如何计算装饰器语法
- Python 如何判断变量是不是局部的
- 闭包存在的原因和工作原理
- nonlocal 能解决什么问题
掌握这些知识,可以进一步探讨装饰器:
- 实现行为良好的装饰器
- 标准库中有用的装饰器
- 实现一个参数化装饰器
下面我们先介绍基础知识:
基础知识¶
假如有个 decorate 装饰器
@decorate
def target():
print('running target()')
上面的写法与下面效果一样:
def target():
print('running target()')
target = decorate(target)
def deco(func):
def inner():
print('running inner()')
return inner()
@deco
def target():
print('running target()')
target
Python 何时执行装饰器¶
装饰器一个关键特性是,它们被装饰的函数定义之后立即运行。这通常是在导入模块(Python 加载模块时),如下面的 register.py 模块
#!/usr/bin/env python
# encoding: utf-8
registry = []
def register(func):
print('running register(%s)' % func)
registry.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('registry ->', registry)
f1()
f2()
f3()
if __name__ == '__main__':
main()
# 运行后答案如下
# running register(<function f1 at 0x7fbac67ca6a8>)
# running register(<function f2 at 0x7fbac67ca730>)
# running main()
# registry -> [<function f1 at 0x7fbac67ca6a8>, <function f2 at 0x7fbac67ca730>]
# running f1()
# running f2()
# running f3()
看到 register 在模块中其它函数之前运行(两次)。调用 register 时,传给它的参数是被装饰的函数,例如 <function f1 at 0x7fbac67ca6a8>
加载模块后,registry 有两个被装饰函数的引用:f1 和 f2。这两个函数,以及 f3,只在 main 明确调用它们时候才执行
如果单纯导入 register.py
import register
此时查看 registry 的值:
register.registry
这主要为了强调,函数装饰器在导入模块时立即执行,而被装饰的函数只在明确调用时执行。这突出了 Python 程序员说的 导入时 和 运行时 的区别
考虑到装饰器在真实代码中的常用方式,上面的例子有两个不同寻常的地方:
- 装饰器函数与被装饰的函数在同一个模块定义,实际上装饰器通常在一个模块中定义,然后应用到其它模块中的函数上
- register 装饰器返回的函数与通过参数传入的相同,实际上大多数装饰器会在内部定义一个函数,然后返回
使用装饰器改进 “策略” 模式¶
在上一章的商品折扣例子中有一个问题,每次通过 best_promo 函数判断最大折扣,这个函数也需要一个折扣函数列表,如果忘了添加,会导致一些不容易被发现的问题。下面使用注册装饰器解决了这个问题:
promos = []
def promotion(promo_func):
promos.append(promo_func)
return promo_func
@promotion
def fidelity_promo(order):
'''为积分为 1000 或以上的顾客提供 5% 折扣'''
return order.total() * .05 if order.customer.fidelity >= 1000 else 0
@promotion
def bulk_item_promo(order):
'''单个商品为 20 个或以上时提供 %10 折扣'''
discount = 0
for item in order.cart:
if item.quantity >= 20:
discount += item.total() * .1
return discount
@promotion
def large_order_promo(order):
'''订单中的不同商品达到 10 个或以上时提供 %7 折扣'''
def best_promo(order):
return max(promo(order) for promo in promos)
这个方案有几个优点
- 促销策略函数无需使用特殊名(即不用以 _promo 结尾)
- @promotion 装饰器突出了被装饰函数的作用,还可以临时禁止某个折扣策略,只需要把装饰器注释
- 促销折扣策略可以在其它模块中定义,在系统任意地方都行,只要使用 @promotion 装饰器即可
不过,多数的装饰器会修改被装饰函数。通常,它们会定义一个内部函数,然后将其返回,替换被装饰的函数。使用内部函数的代码几乎都要靠闭包才能正确工作。为了理解闭包,我们先退后一步,了解 Python 中变量的作用域
变量作用域规则¶
def f1(a):
print(a)
print(b)
f1(3)
这里出现错误不奇怪,如果先给全局变量 b 赋值,然后调用 f1,就不会出错
b = 6
f1(3)
这也很正常,下面是一个会让你大吃一惊的例子:),前面代码和上面的例子一样,可是,在赋值之前,第二个 print 失败了。
b = 6
def f2(a):
print(a)
print(b)
b = 9
f2(3)
首先输出了 3,表明 print(a) 执行了。但是 print(b) 无法执行,这是因为,Python 编译函数的定义时,判断 b 是局部变量,因为在函数中给它赋值了。生成的字节码证实了这种判断,Python 会尝试从本地环境获取 b。后面调用 f2(3) 时,f2 的定义体会获取并打印局部变量 a 的值,但是尝试获取局部变量 b 的时候,发现 b 没有绑定值。
这不是缺陷,而是设计选择,Python 不要求声明变量,但是假定在函数定义体中赋值的变量是局部变量。
如果在函数中赋值时想让解释器把 b 当成全局变量,要求使用 global 声明:
b = 6
def f3(a):
global b
print(a)
print(b)
b = 9
f3(3)
f3(3)
b = 30
b
了解了 Python 的变量作用域之后,我们就可以讨论闭包了
闭包¶
人们有时会把闭包和匿名函数弄混,这是有历史原因的,在函数内部定义函数不常见,直到开始使用匿名函数才这么做。而且,只有涉及嵌套函数时才有闭包的问题。因此,很多人是同时知道这两个概念的
其实,闭包指延伸了作用域的函数,其中包含函数定义体中引用、但是不在定义体中定义的非全局变量。函数是不是匿名的没有关系,关键是它能访问定义体之外定义的非全局变量。
我们通过例子来理解,加入 avg 函数是计算不断增加的系列值的均值,例如整个历史中的某个商品的平均收盘价,每天都会增加新价格,因此平均值要考虑至目前为止的所有价格。
一开始,avg 是这样的:
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)
avg = Averager()
avg(10)
avg(11)
avg(12)
下面是函数式实现,使用高阶函数 make_averager,调用 make_averager,返回一个 averager 函数对象。每次调用 averager 时,都会把参数添加到系列值中,然后计算当前平均值
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)
avg(11)
avg(12)
注意,这两个例子有共通之处,调用 Averager() 或 make_averager() 得到一个可调用对象 avg,它会更新历史值,然后计算当前均值。
Averager 将历史值存在哪里很明显,self.series 属性中,avg 函数将 series 存在哪里呢?
注意 series 是 make_averager 的局部变量,因为那个函数的定义体中初始化了 series,series = [],可是调用 avg(10) 时候,make_averager 已经返回了,而它本身地作用域也没有了
在 averager 函数中,series 是自由变量。这是一个技术术语,指未在本地作用域绑定的变量,averager 的闭包延伸到这个函数的作用于之外,包含自由变量 series 的绑定
审查返回的 averager 对象,我们会在 __code__ 属性发现保存局部变量和自由变量的名称
avg.__code__.co_varnames
avg.__code__.co_freevars
series 的绑定在返回的 avg 函数的 __closure__ 属性中,avg.__closure__ 中的各个元素对应于 avg.__code__.co_freevars 中的一个名称。这些元素是 cell 对象,有个 cell_contents 属性,保存着真正的值。这些属性如下所示:
avg.__code__.co_freevars
avg.__closure__
avg.__closure__[0].cell_contents
综上,闭包是一种函数,它会保留定义函数时存在的自有变量的绑定,这样调用函数时,虽然定义域不可用了,但是仍然能使用这些绑定
注意,只有嵌套在其它函数中的函数才可能需要处理不在全局作用域中的外部变量
nonlocal 声明¶
前面实现的计算平均值方法效率不高,因为每次把值存到历史数列中,遍历历史数列求平均数,更好的方法是只存储目前的平均数和个数,然后用这两个数计算平均值
下面是一个有缺陷的的程序,只是为了阐明某个观点,我们来看一下:
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)
问题是,当 count 是数字和不可变类型时, count += 1 和 count = count + 1 是等价的。我们在 averager 函数中为 count 赋值了,这会把 Count变成局部变量,total 变量也会受这个问题影响。
在前面的例子没有这个问题,因为我们没有给 series 赋值,我们只是调用 series.append(), 并把 series 传给 sum 和 len。也就是说,我们利用了列表是可变对象的这一事实
但是对于数字,字符串,元组等不可变类型来说,只能读取,不能更新。如果尝试像上面重新绑定,就会隐式的创建局部变量 count。这样 count 就不是自由变量了,因此不会保存到闭包中
为了解决这个问题,Python 3 引入了 nonlocal 声明。它的作用是把变量标记为自由变量,即使在函数中为变量赋予新值了,也会变成自由变量。如果为 nonlocal 声明的变量赋予新值,闭包中保存的绑定会更新。最新版 make_averager 的正确实现如下所示:
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()
avg(10)
avg(11)
Python 2 中的处理方法可以把 count 和 total 存储为可变对象,例如列表和字典就可以了。
实现一个简单的装饰器¶
下面定义一个装饰器,会在每次调用被装饰的函数时计时,然后把经过的时间、传入的参数和调用结果打印
import time
def clock(func):
def clocked(*args):
#返回计时器的精准时间(系统的运行时间),包含整个系统的睡眠时间。系统起始运行时间不确定,所以一般只有两个时间差才有效
t0 = time.perf_counter()
result = func(*args)
elapsed = time.perf_counter() - t0
name = func.__name__
# repr 方法用得好,用的对象例如列表不能用 str(),但是可以用 repr() 获取对象的标准字符串表示形式
arg_str = ', '.join(repr(arg) for arg in args)
# %r 把对象转成标准字符串形式,因为不知道返回值类型
print('[%0.8fs]%s(%s) -> %r' % (elapsed, name, arg_str, result))
return result
return clocked
import time
@clock
def snooze(seconds):
time.sleep(seconds)
@clock
def factorial(n):
return 1 if n < 2 else n * factorial(n - 1)
print('*' * 40, 'Calling snooze(.123)')
snooze(.123)
print('*' * 40, 'Calling factorial(6)')
factorial(6)
clock 中还定义 clocked 函数的原因是 clock 函数中的内容在模块引入后会被执行,所以再嵌套一个函数,保证不会在模块引入后直接执行装饰器内容,然后在 clocked 中对原函数计时
工作原理¶
@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,然后 clock 函数会返回 clocked 函数, Python 解释器在背后会把 clocked 赋值给 factorial。可以看到查看 factorial 的 __name__ 属性会得到以下结果:
factorial.__name__
所以,现在 factorial 保存的是 clocked 函数的引用。此后,调用 factorial(n),执行的都是 clocked(n)。clocked 大致做了以下几件事
- 记录初试时间 t0
- 调用原来的 factorial 函数,保存结果
- 计算经过的时间
- 格式化收集的数据,打印
- 返回第二步保存的结果
上面的 clock 装饰器有几个缺点,不支持关键字参数,而且遮盖了被装饰函数的 __name__ 和 __doc__ 属性。下面使用 functools.wraps 装饰器可以把相关属性从 func 复制到 clocked 中。此外还能正确处理关键字参数
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
import time
@clock
def snooze(seconds):
time.sleep(seconds)
@clock
def factorial(n):
return 1 if n < 2 else n * factorial(n - 1)
print('*' * 40, 'Calling snooze(.123)')
snooze(.123)
print('*' * 40, 'Calling factorial(6)')
factorial(6)
factorial.__name__
看到 factorial 的属性已经被复制到了 clocked 中。functools.wraps 只是标准库中拿来即用的装饰器之一,下面介绍 functools 模块中最令人印象深刻的两个装饰器: lru_cache 和 singledispatch
标准库中的装饰器¶
Python 内置了 3 个用于装饰器方法的函数:property, classmethod, staticmethod。property 在第 19 章讨论, 另外两个在第 9 章讨论
另一个常见的装饰器是 functools.wraps,它的作用是协助构建行为良好的装饰器。我们在前面用过,现在来介绍标准库中最值得关注的两个装饰器 lru_cache 和全新的 singledispatch(Python 3.4 新增)。这两个装饰器都在 functools 模块中定义。接下来分别讨论它们
使用 functools.lru_cache 做备忘¶
functools.lru_cache 是一个非常实用的装饰器,它实现了备忘功能。这是一项优化技术,它把耗时的函数结果保存起来,避免传入相同的参数时重复计算。 LRU 3 个字母是 “Least Recently Used” 的缩写,表明缓存不会无限制增长,一段时间不用的缓存条目会被扔掉
下面是一个生成 n 个斐波那契数的例子:
@clock
def fibonacci(n):
if n < 2:
return n
return fibonacci(n - 2) + fibonacci(n - 1)
print(fibonacci(6))
看到很浪费时间, fibonacci(1) 调用了 8 次, fibonacci(2) 调用了 5 次。但是如果增加两行代码,性能会显著改善,如下:
@functools.lru_cache() # note
@clock
def fibonacci(n):
if n < 2:
return n
return fibonacci(n - 2) + fibonacci(n - 1)
print(fibonacci(6))
注意,必须像常规函数那样调用 lru_cache。这一行中有一对括号: @functools.lru_cache()。这么做的原因是,lru_cache() 可以接受配置参数,稍后说明
这里还叠放了装饰器,lru_cache() 应用到了 @clock 返回的函数上
这样一来,看到执行时间减半,而且 n 的每个值只调用一次函数。
除了优化递归算法之外,lru_cache 在从 Web 中获取信息的应用也能发挥巨大作用。特别要注意,lru_cache 可以使用两个可选的参数来配置。它的签名是:functools.lru_cache(maxsize = 128, typed = False)
maxsize 指定最多存储多少个调用结果,缓存满了后,旧的结果会被扔掉,为了得到最佳性能, maxsize 应该设为 2 的次幂。typed 参数如果设为 True,把不同参数类型得到的结构分开保存,即把通常认为相等的浮点数和整数参数(如 1 和 1.0)区分开。顺便说一下,因为 lru_cache 使用字典存储结果,而且键根据调用时传入的定位参数和关键字创建,所以被 lru_cache 装饰的函数,它的所有的参数必须是可散列的。
单分派泛函数¶
假如我们在调试一个 Web 应用程序,想生成 HTML,显示不同类型的 Python 对象。
我们可能会编写这样的函数:
import html
def htmlize(obj):
content = html.escape(repr(obj)) # 对字符串进行转义,详情见注释说明
return '<pre>{}</pre>'.format(content)
'''
html.escape(s, quote=True)
Convert the characters &, < and > in string s to HTML-safe sequences.
Use this if you need to display text that might contain such characters in HTML.
If the optional flag quote is true, the characters (") and (') are also translated;
this helps for inclusion in an HTML attribute value delimited by quotes, as in <a href="...">.
'''
这个函数适用于任何 Python 类型,但是我们想做个扩展,让它使用特别的方式显示某些类型。
- str: 把内部的换行符换成
<br>\n;不使用<pre>,而是使用<p> - int: 以十进制和十六进制显示数字
- list:输出一个 HTML 列表,根据各个元素的类型进行格式化
我们想要的行为如下所示:
htmlize({1, 2, 3}) # 默认情况下, 在 <pre> 标签内显示 HTML 转义后的字符串表示形式
htmlize(abs)
htmlize('Heimlich & Co.\n- a game')# str 对象也是显示 HTML 转义后的形式,\n 换成 <br>\n 并且放到 <p> 标签内
htmlize(42) # 数字显示 10进制和 16进制形式
print(htmlize(['alpha', 66, {3, 2, 1}])) #列表根据各自的类型格式化显示
Python 不支持重载方法或函数,所以我们不能使用不同签名定义 htmlize 的变体。也无法使用不同方式处理不同的数据类型。在 Python 中,一种常见的做法是把 htmlize 变成一个分派函数,使用一串 if else 来调用专门的函数,但是这样太笨,而且不好维护
Python 3.4 新增了 functools.singledispatch 装饰器可以把整体方案拆成多个模块,甚至可以为你无法修改的类提供专门的函数。使用 @singledispathc 会把普通的函数变成泛函数。根据第一参数的类型,以不同方式执行相同操作的一组函数
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) #各个函数用 @base_function.register(type) 装饰
def _(text): #专门函数的名称不重要, _ 是个不错的选择
content = html.escape(text).replace('\n', '<br>\n')
return '<p>{0}</p>'.format(content)
@htmlize.register(numbers.Integral) # Integral 是 int 的虚拟超类
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>'
只要有可能,注册的专门函数应该处理抽象基类(例如 numbers.Integral 和 abc.MutableSequence),不要处理具体实现(如 int 和 list)。这样,代码支持的兼容类型更广泛
叠放装饰器¶
上面已经用过了叠放装饰器的方式,@lru_cache 应用到 @clock 装饰 fibonacci 得到的结果上。上面的例子最后也用到了两个 @htmlize.register 装饰器
把 @d1 和 @d2 两个装饰器按顺序应用到 f 函数上,作用相当于 f = d1(d2(f)),也就是说
@d1
@d2
def f():
print('f')
等同于
def f():
print('f')
f = d1(d2(f))
除了叠放装饰器以外,我们还用到了接收参数的装饰器,例如上面的 htmlize.register(type)
参数化装饰器¶
Python 把被装饰的函数作为第一个参数传给装饰器函数。我们可以创建一个装饰器工厂函数,把参数传给他,返回一个装饰器,然后再把它应用到要装饰器函数上,我们以见过的最简单的装饰器为例说明:
registry = []
def register(func):
print('funning register(%s)' % func)
registry.append(func)
return func
@register
def f1():
print('running f1()')
print('running main()')
print('registry ->', registry)
f1()
为了便于启动和禁用 register 执行的函数注册功能,我们为他提供一个可选的 activate 参数,设为 False 时,不注册被装饰的函数。实现如下,从概念上来看,这个新的 register 函数不是装饰器,而是装饰器工厂函数。调用它会返回真正的装饰器,这才是应用到目标上的装饰器
为了接受参数,新的 register 装饰器必须作为函数调用
registry = set() #添加删除元素更快(相比列表)
def register(active = True):
def decorate(func): #这是真正的装饰器,它的参数是一个函数
print('running register(active=%s)->decorate(%s)' % (active, func))
if active:
registry.add(func)
else:
registry.discard(func)
return func #decorate 是装饰器函数,所以返回 func
return decorate # register 是装饰器工厂函数,返回 decorate
@register(active = False) # @register 工厂函数必须作为函数调用,传入需要的参数
def f1():
print('running f1()')
@register() # 如果不传入参数也要作为函数调用
def f2():
print("running f2()")
def f3():
print('running f3()')
registry
这里的关键是,register() 要返回 decorate,并把它应用到被装饰器函数上,注意只有 f2 在 registry 中,因为 f1 传给装饰器工厂函数的参数是 False。如果不能使用 @ 语法,那就要像常规函数那样使用 register,若想把 f 添加到 registry 中,则装饰 f 函数的语法是 register()(f)。下面演示了如何把函数添加到 registry 中,以及如何从中删除函数
register()(f3)
registry
register(active=False)(f2)
registry
参数化装饰器原理相当复杂,我们讨论的是很简单的内容,参数化装饰器通常会把被装饰的函数替换掉,而且结构上需要多一层嵌套,接下来会讨论这种函数金字塔
参数化 clock 装饰器¶
我们这次为 clock 装饰器添加一个功能,让用户传入一个格式字符串,控制被装饰函数的输出,见下面例子,为了方便起见,我们下面用的是最初实现的 clock,而不是示例中使用 @functools.wraps 的改进后的版本,因为那一版增加了一层函数
import time
DEFAULT_FMT = '[{elapsed:0.8f}s] {name}({args}) -> {result}'
def clock(fmt = DEFAULT_FMT): # 参数化装饰器工厂函数
def decorate(func): # 真正的装饰器
def clocked(*_args): # 包装被装饰器的函数
t0 = time.time()
_result = func(*_args)
elapsed = time.time() - t0
name = func.__name__
args = ', '.join(repr(arg) for arg in _args)
result = repr(_result)
print(fmt.format(**locals())) #是为了在 fmt 中引用 clocked 的局部变量 --> 用得好!
return result
return clocked
return decorate
@clock() #不传入参数调用 clock(),应用的装饰器默认格式的 str
def snooze(seconds):
time.sleep(seconds)
for i in range(3):
snooze(.123)
下面是用户传入自定义的格式字符串的调用:
@clock('{name}: {elapsed}s')
def snooze(seconds):
time.sleep(seconds)
for i in range(3):
snooze(.123)
@clock('{name}({args}) dt={elapsed:0.3f}s')
def snooze(seconds):
time.sleep(seconds)
for i in range(3):
snooze(.123)