第7章 函数装饰器和闭包
装饰器用于在源码中“标记”函数,动态地增强函数的行为。
了解装饰器前提是理解闭包。
闭包除了在装饰器中有用以外,还是回调式编程和函数式编程风格的基础。
1. 装饰器基础知识
- 装饰器是callable对象,其参数是被装饰的函数。
- 装饰器将被装饰的函数处理后返回,或者将被装饰的函数替换成另一个函数或可调用对象。
- Python也支持类装饰器,参见第21章。
4. 第一大特性:装饰器能把被装饰的函数替换成其他函数
5. 第二大特性:装饰器在加载模块时立即执行。看例子3,和下一小节详细说明
6. 严格来说,装饰器只是语法糖。
例子1. 效果一样的写法:
#假设有个名为decorate的装饰器
@decorate
def target():
pass
def target():
pass
target = decorate(target)
例子2. 装饰器通常把函数替换成另一个函数
#deco函数返回inner函数对象
def deco(func):
def inner():
print('running inner()')
return inner
#使用deco装饰器装饰target
@deco
def target():
print('running target()')
#调用被装饰的target()会运行inner()
target()
#查看target地址,其实是inner()的引用
target
例子3. 装饰器在加载模块时立即执行
可以看出I am deco和running traget()立即输出。
2. Python何时执行装饰器
- 装饰器的第二大特性:它们在被装饰的函数定义之后立即运行(联想到Flask框架中的URL映射和绑定)。通常是在导入时(即Python加载模块时)。
- 大多数装饰器会在内部定义一个函数,然后将其返回。但有些装饰器返回被装饰的函数,很多Python Web框架使用这样的装时期把函数添加到注册处,例如把URL模式映射到生成HTTP响应的函数上的注册处。这种注册装饰器可能会也可能不会修改被装饰的函数。
3. 使用装饰器改进“策略”模式
- 改进第6章的5.1。
- 原本的hardcode,当有新的策略(新的promotion)的时候,要手动添加。现在用装饰器装饰新的策略,自动添加进列表。
- 优点1:促销策略函数无需使用特殊的名称(例如不同_promo结尾)
- 优点2: 临时禁用某个促销策略,只需要把装饰器注释掉。
- 优点3:促销折扣策略可以在其他模块中定义,模块化。
promos = []
def promotion(promo_func):
promos.append(promo_func)
return promo_func
@promotion
def pro1():
pass
@promotion
def pro2():
pass
#类也可以被装饰
@promotion
class pro3():
pass
#注意,方法和类存入列表的形式不同
#选择最佳策略的函数不变
def best_promo():
"""选择可用的最佳折扣"""
return max(promo(order) for promo in promos)
4. 变量作用域
- 为理解闭包,先了解Python中的变量作用域。
例子1. b是局部变量,因为在函数的定义体中给它赋值了
b = 6
#到print(b)时会报错
def f2(a):
print(a)
print(b)
b = 9
原因:
在函数中给b赋值了,Python认为它是局部变量。当获取并打印局部变量a后,尝试获取局部变量b的值时,发现b没有绑定值。
1. 这不是缺陷,这是设计选择:Python不要求声明变量,但是假定在函数定义体中赋值的变量是局部变量。
2. 这比JavaScript的行为好多了,JavaScript也不要求声明变量(在函数中使用var关键字进行显式申明的变量是作为局部变量,而没有用var关键字,使用直接赋值方式声明的是全局变量),但是如果忘记把变量声明为局部变量(使用var),可能在不知情的情况下获取全局变量。
例子2. 在函数中,如果让解释器把b当成全局变量,要使用global声明:
b = 6
def f(a):
global b
print(a)
print(b)
b = 9
f(3) # 3 9
b # 9
例子3. 用dis.dis查看例子1和2的字节码的不同
#反汇编模块
from dis import dis
dis(s)
#LOAD_FAST是读本地变量进栈,LOAD_GLOBAL读取全局变量, 区别在b处的操作不同。
5. 闭包(closure)
- 闭包是指延伸了作用域的函数,其中包括函数定义体中的引用、定义体之外的非全局变量。
- 闭包和匿名函数:在内部里定义函数,只有涉及嵌套函数才有闭包问题。内部函数是不是匿名没有关系。
- 闭包关键是能访问定义体之外定义的非全局变量(自由变量)。
问题:自定义avg函数,计算不断增加的系列值的均值,关键是如何保存历史值。
方案1: 用类实现计算平均值
class Average():
def __init__(self):
#实例初始化时初始化一个列表
self.series = []
def __call__(self, new_value):
self.series.append(new_value)
#归约函数sum()
total = sum(self.series)
return total/len(self.series)
avg = Average()
print(avg(10))
print(avg(11))
print(avg(12))
例子2. 函数式实现,计算平均值的高阶函数
def make_average():
#series是make_average的局部变量,因为在这个函数定义体中初始化了series
series = []
def average(new_value):
series.append(new_value)
total = sum(series)
return total/len(series)
return average
例子1在self.series实例属性存储历史值。例子2在自由变量(free variable)series中存历史值。
总结:
- 闭包是引用了自由变量的函数。
- In computer programming, the term free variable refers to variables used in a function that are not local variables nor parameters of that function
- free variable: variables that are used locally, but defined in an enclosing scope
6. nonlocal声明
例子1. 有缺陷的计算平均值的高阶函数
def make_average():
count = 0
total = 0
def average(new_value):
count += 1
total += new_value
return total/count
return average
avg = make_average()
#报错,当count是不可变类型时,因为count += 1 相当于 count = count + 1。我们在average的定义体中为count赋值,Python当成它是局部变量,total也受到这个问题影响。
#series.append没遇到这个问题,因为利用了列表是可变对象这一事实,没有给series赋值。
avg(1)
对数字、字符串、元组等不可变类型来说,只能读取,不能更新。如果尝试重新绑定,例如count = count + 1, 会隐式创建局部变量count,这样count再不是自由变量了,也不存在闭包了。
为了解决这个问题,Python3引入了nonlocal声明。它的作用是把变量标记为自由变量。
def make_average():
count = 0
total = 0
def average():
nonlocal count, total
count += 1
total += new_value
return total / count
return average
7. 实现一个简单的装饰器
例子1. 把经过的时间、传入的参数、调用的结果打印出来
#b.py
import time
def clock(func):
#定义内部函数clocked, 它接受任意个位置参数
def clocked(*args):
start = time.perf_counter()
#这行代码可用,因为clocked的闭包中包含自由变量func
result = func(*args)
elapsed = time.perf_counter() - start
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
#a.py
import time
from b 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))
此处clock函数的缺点:
- 不支持关键字参数
- 掩盖了被装饰函数的__name__和__doc__属性
由这个例子看出:
这是装饰器的典型行为:把被装饰的函数替换成新函数,二者接受相同的参数,通常返回被装饰函数本该返回的值,同时还做些额外的操作
例子2. 解决上个例子的两个缺点
- 用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 = ['[%0.8fs] %s(%s) -> %r ' % (elapsed, name, arg_str, result]
return result
return clocked
8. 标准库中的装饰器
- Python内置了三个装饰器property(在19.2节讨论)、classmethod(在9.4节讨论)、staticmethod(在9.4节讨论)。
2. 另一个常见的装饰器是functools.wraps,它的作用是协助构建行为良好的装饰器,如上面的例子。- 标准库中最值得关注的是functools.lru_cache和functools.singledispatch装饰器。
8.1 functools.lru_cache()
例子1. 生成第n个斐波那契数
递归方式非常耗时,中文电子书P323
#clock为上面例子中的装饰器
from clockdeco import clock
def fibonacci(n):
if n < 2:
return n
return fibonacci(n-2) + fibonacci(n-1)
if __name__ == '__main__':
print(fibonacci(6))
例子2. 优化例子1
- functools.lru_cache实现了备忘录(memoization)功能的装饰器。这是一项优化技术,它把耗时的函数的结果保存起来,避免传入相同参数时重复计算。LRU代表“Least Recently Used”,表明缓存不会无限制增长,一段时间不用的条目会被扔掉。
- functools.lru_cache适合优化例子1这种慢速递归函数。
- functools.lru_cache在Web中获取信息的应用中也能发挥巨大作用。
- functools.lru_cache有两个可选的配置参数(maxsize=128, typed=False)
- functools.lru_cahe用字典存储结果,并且key根据调用时传入的位置参数和关键字参数创建,所以被lru_cache装饰的函数,它的所有参数都必须是可散列的。
import functools
from clockdeco import clock
#lru_cache加()的原因是,lru_cache可以接受配置参数。
@functools.lrucache()
#这里叠放了装饰器:@lru_cache()应用到@clock返回的函数上。
@clock
def fibonacci(n):
if n < 2:
return n
return fibonacci(n-2) + fibonacci(n-1)
if __name__ == '__main__':
print(fibonacci(6))
8.2 functools.singledispatch装饰器, 单分派泛函数
- 中文电子书P326,根据不同的Python对象输出不同格式的HTML
- 问题:因为Python不支持重载方法或函数,所以我们不能使用不同的签名定义htmlize的变体。
- 笨拙的解决方法:把htmlize变成一个分派函数,使用一串if/elif/elif,调用专门的函数,如htmlize_str、htmlize_int等。但这样不利于模块拓展,htmlize会变得很大,而且它与各个专门函数之间的耦合也很紧密。
解决方法2. Python3.4新增的(PyPi中的包可以向后兼容)functools.singledispatch装饰器可以把整体方案拆成多个模块。使用@singledispatch装饰的普通函数会变成泛函数(generic function):根据第一个参数的类型(一个参数为单分派,多个参数为多分派),以不同的方式执行相同操作的一组函数。
- Java的重载和if/elif/elif定义的分派函数的缺点是代码单元(类或函数)承担的职责过重。singledispatch的优点是支持模块化拓展,各个模块可以为它支持的各种类型注册专门函数。
from functools import singledispatch
from collections import abc
import numbers
import html
# 1.singledispatch标记处理object类型的基函数。htmlize变成了泛函数
@singledispatch
def htmlize(obj):
content = html.escape(repr(obj))
return '{}
'.format(content)
# 2.各个专门函数用@<>.register(<>)装饰
@htmlize.register(str)
# 3.专门函数的名称无关紧要
def _(text):
content = html.escape(text).replace('\n', '
\n')
return '{0}
'.format(content)
# 4.numbers.Integral是int的虚拟超类
@htmlize.register(numbers.Integral)
def _(n):
return '{0} (0x{0:x})
'.format(n)
# 5.叠放多个register装饰器,让一个函数支持不同的类型。
@htmlize.register(tuple)
@htmlize.register(abc.MutableSequence)
def _(seq):
inner = '\n '.join(htmlize(item) for item in seq)
return '\n- ' + inner + '
\n
'
只要可能,注册的专门函数应该处理抽象基类(如numbers.Integral和abc.MutableSequence),不要处理具体实现(如int和list)。这样,代码支持的兼容类型更广泛。例如,Python拓展可以子类化numbers.Integral,使用固定的位数实现int类型。
使用抽象基类检查类型,可以让代码支持这些抽象基类现有和未来的具体子类或虚拟子类。在第11章讨论。
singledispatch提供的特性很多,查看PEP443"Single-dispatch generic functions"
9.叠加装饰器
@d1和@d2按顺序应用到f函数上,相当于f = d1(d2(f))
@d1
@d2
def f():
pass
#等同于
def f():
pass
f = d1(d2(f))
10. 参数化装饰器
- 解析源码中的装饰器时,Python把被装饰的函数作为第一个参数传给装饰器函数。问题:怎么让装饰器接受其他参数?
- 解决方案:创建一个装饰器的工厂函数,把参数传给它,返回一个装饰器,然后再把它应用到要装饰的函数上。
例子1. 给出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)
print(f1)
例子2.为例子1的register添加一个可选的active参数,便于启用或禁用register执行的函数注册功能。
新的register函数不是装饰器,而是装饰器工厂函数。调用它会返回真正的装饰器,这才是应用到目标函数上的装饰器。
# 1.set对象添加和删除速度更快
registry = set()
# 2.register是一个装饰器工厂函数,返回一个装饰器,接受一个可选的关键字参数
def register(active=True):
# 3.decorate这个内部函数才是真正的装饰器;它的参数是被装饰的函数
def decorate(func):
print('running register(active=%s) -> decorate(%s)' % (active, func))
# 4.active是自由变量,从decorate的闭包中获取。只有True时才注册func
if active:
registry.add(func)
else:
# 5.如果active不为真,而且func在registry中,那么把它删除
registry.discard(func)
# 6.decorate是装饰器,返回一个函数
return func
# 7. register是工厂函数,返回decorate装饰器
return decorate
# 8. @register工厂函数必须作为函数调用,并且传入可选参数
@register(active=False)
def f1():
print('running f1()')
@register(active=True)
def f2():
print('running f2()')
def f3():
print('running f3()')
# print(registry)
# print(f1())
# print(f2())
# print(f3())
这里的关键是,register()是一个装饰器工厂函数,返回decorate装饰器,把它应用到被装饰的函数上。(思考:Flask中的URL映射也是这样?装饰器的.index()是工厂函数。一般情况下,装饰器作为函数调用,即有括号(),就是工厂函数?)
11. 参数化clock装饰器
- 中文电子书P334
总结:
- 大部分工业级的装饰器比上述所有例子都要复杂。参数化装饰器至少涉及两层嵌套函数。
- Graham Dumpleton和Lennart Regebro认为,装饰器最好通过实现__call__方法的类实现。作者同意使用它们建议的方式实现非平凡的装饰器, 使用函数解说这个语言特性的基本思想更容易理解。
- 真正理解装饰器,需要区分导入时和运行时,还要知道变量作用域、闭包和新增的nonlocal(重新绑定既不在本地作用域中也不在全局作用域中的名称)声明。
- 就拓展功能而言,装饰器模式比子类化更灵活。
- 在实现层面,Python装饰器与装饰器涉及模式不同,但有相似之处。在特定情况下,Python程序中使用函数装饰器实现装饰器模式,但是实现装饰器模式更好使用类表示装饰器和要包装的组件。