函数装饰器与闭包
此笔记记录于《流畅的 python》,大部分为其中的摘要,少部分为笔者自己的理解;笔记为 jupyter 转的 markdown,原始版 jupyter 笔记在这个仓库
作为 Python 程序员,如果严格遵守基于类的面向对象编程方式,即便不知道这个关键字也不会受到影响。然而,如果你想自己实现函数装饰器,那就必须了解闭包的方方面面,因此也就需要知道 nonlocal。
除了在装饰器中有用处之外,闭包还是回调式异步编程和函数式编程风格的基础。
装饰器基础知识
装饰器是可调用的对象,其参数是另一个函数(被装饰的函数)。装饰器可能会处理被装饰的函数,然后把它返回,或者将其替换成另一个函数或可调用对象。
@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() # 用被装饰的 target 其实会运行 inner
running inner()
target # target 现在是 inner 的引用
<function __main__.deco.<locals>.inner()>
严格来说,装饰器只是语法糖。如前所示,装饰器可以像常规的可调用对象那样调用,其参数是另一个函数。有时,这样做更方便,尤其是做元编程(在运行时改变程序的行为)时。
python 何时执行装饰器
装饰器的一个关键特性是,它们在被装饰的函数定义之后立即运行。这通常是在导入时(即 Python 加载模块时)
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 0x00000219F2A5C040>)
running register(<function f2 at 0x00000219F2B6A320>)
running main()
registry-> [<function f1 at 0x00000219F2A5C040>, <function f2 at 0x00000219F2B6A320>]
running f1()
running f2()
running f3()
变量作用域规则
b = 6
def f2(a):
print(a)
print(b)
b = 9
f2(3) # 会报错,因为 b 在函数内部被赋值了,所以被认为是局部变量,但是在赋值之前就被引用了
3
---------------------------------------------------------------------------
UnboundLocalError Traceback (most recent call last)
Cell In[6], line 7
4 print(b)
5 b = 9
----> 7 f2(3)
Cell In[6], line 4, in f2(a)
2 def f2(a):
3 print(a)
----> 4 print(b)
5 b = 9
UnboundLocalError: local variable 'b' referenced before assignment
闭包
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=[]
。可是,调用avg(10)
时,make_averager 函数已经返回了,而它的本地作用域也一去不复返了。
在 averager 函数中,series 是自由变量(free variable)。这是一个技术术语,指未在本地作用域中绑定的变量:
avg.__code__.co_varnames # ('new_value', 'total')
('new_value', 'total')
avg.__code__.co_freevars # ('series',)
('series',)
avg.__closure__ # (<cell at 0x7f3e3c3e3d90: list object at 0x7f3e3c3e3e08>,)
(<cell at 0x00000219F264F760: list object at 0x00000219F46C2100>,)
avg.__closure__[0].cell_contents
[10, 11, 12]
综上,闭包是一种函数,它会保留定义函数时存在的自由变量的绑定,这样调用函数时,虽然定义作用域不可用了,但是仍能使用那些绑定。
nonlocal 声明
前面实现 make_averager 函数的方法效率不高,我们把所有值存储在历史数列中,然后在每次调用 averager 时使用 sum 求和。更好的实现方式是,只存储目前的总值和元素个数,然后使用这两个数计算均值。
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 和 total 是数字,所以会被认为是局部变量
---------------------------------------------------------------------------
UnboundLocalError Traceback (most recent call last)
Cell In[17], line 12
9 return averager
11 avg = make_averager()
---> 12 avg(10)
Cell In[17], line 6, in make_averager.<locals>.averager(new_value)
5 def averager(new_value):
----> 6 count += 1
7 total += new_value
8 return total / count
UnboundLocalError: local variable 'count' referenced before assignment
问题是,当 count 是数字或任何不可变类型时,count+=1
语句的作用其实与count=count+1
一样。因此,我们在 averager 的定义体中为 count 赋值了,这会把 count 变成局部变量。total 变量也受这个问题影响。
- 上一个例子中没遇到这个问题,因为我们没有给 series 赋值,我们只是调用
series.append
,并把它传给 sum 和 len。也就是说,我们利用了列表是可变的对象这一事实。 - 但是对数字、字符串、元组等不可变类型来说,只能读取,不能更新。如果尝试重新绑定,例如
count=count+1
,其实会隐式创建局部变量 count。这样,count 就不是自由变量了,因此不会保存在闭包中。
为了解决这个问题,Python 3 引入了 nonlocal 声明。它的作用是把变量标记为自由变量,即使在函数中为变量赋予新值了,也会变成自由变量。如果为 nonlocal 声明的变量赋予新值,闭包中保存的绑定会更新。
# 下面是使用 nonlocal 修正后的方法
def make_averager():
count = 0
total = 0
def averager(new_value):
nonlocal count, total
count += 1
total += new_value
return total / count
return averager
实现一个简单的装饰器
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 # ➌
# 使用上述装饰器
import time
@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))
**************************************** Calling snooze(.123)
[0.13076400s]snooze(0.123)->None
**************************************** Calling factorial(6)
[0.00000100s]factorial(1)->1
[0.00002310s]factorial(2)->2
[0.00003470s]factorial(3)->6
[0.00004460s]factorial(4)->24
[0.00005490s]factorial(5)->120
[0.00006930s]factorial(6)->720
6! = 720
上述实现的 clock 装饰器有几个缺点:不支持关键字参数,而且遮盖了被装饰函数的__name__
和__doc__
属性。下例使用functools.wraps
装饰器把相关的属性从 func 复制到 clocked 中。此外,这个新版还能正确处理关键字参数。
# clockdeco2.py
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
标准库中的装饰器
Python 标准库中包含了一些常用的装饰器,以下是其中的一部分:
@functools.lru_cache(maxsize=128, typed=False)
:这是一个非常有用的装饰器,它可以实现对函数的结果进行缓存,从而提高程序的运行效率。maxsize
参数用于指定缓存的最大容量,typed
参数则是用于指定是否需要根据参数的类型进行缓存。@functools.wraps(wrapped, assigned=WRAPPER_ASSIGNMENTS, updated=WRAPPER_UPDATES)
:这个装饰器主要是用于在定义装饰器时保留被装饰函数的元信息(如函数的名称、文档字符串等)。@functools.total_ordering
:这个装饰器用于在类中定义了__lt__()
、__le__()
、__gt__()
、__ge__()
中的一个或多个方法后,自动添加其余的比较方法。@functools.singledispatch
:这个装饰器实现了单分派泛函数。对于注册为泛函数的函数,它们的实现可以根据第一个参数的类型进行切换。@property
:这个装饰器用于将一个方法变成属性,使得我们可以像访问属性一样来访问这个方法。@staticmethod
:这个装饰器用于声明静态方法,即不需要实例化也可以被类本身调用的方法。@classmethod
:这个装饰器用于声明类方法,即这些方法将绑定到类上,而不是类的实例上。@abc.abstractmethod
:这个装饰器用于声明抽象方法,这些方法必须在任何直接或间接的子类中进行重写。@contextlib.contextmanager
:这个装饰器用于定义一个上下文管理器,使得我们可以使用with
语句来管理资源。
@functools.singledispatch
是一个装饰器,用于将一个函数转换为单分派泛函数。所谓"单分派",是指根据函数的第一个参数的类型,来决定调用哪个实现。这使得我们可以对同一个函数,针对不同的参数类型,编写不同的实现。
这个装饰器主要用于创建一个简单的、可读性强的、对不同类型进行不同操作的函数。
下面是一个例子:
from functools import singledispatch
@singledispatch
def fun(arg, verbose=False):
if verbose:
print("Let me just say,", end=" ")
print(arg)
@fun.register(int)
def _(arg, verbose=False):
if verbose:
print("Strength in numbers, eh?", end=" ")
print(arg)
@fun.register(list)
def _(arg, verbose=False):
if verbose:
print("Enumerate this:")
for i, elem in enumerate(arg):
print(i, elem)
在这个例子中,fun
函数根据输入参数的类型(整数或列表),执行不同的操作。如果输入的是整数,就调用fun.register(int)
注册的函数;如果输入的是列表,就调用fun.register(list)
注册的函数。如果输入的类型没有被注册,就调用原始的fun
叠放装饰器
下述代码:
@d1
@d2
def f():
print('f')
等同于:
def f():
print('f')
f = d1(d2(f))
参数化装饰器
解析源码中的装饰器时,Python 把被装饰的函数作为第一个参数传给装饰器函数。那怎么让装饰器接受其他参数呢?答案是:创建一个装饰器工厂函数,把参数传给它,返回一个装饰器,然后再把它应用到要装饰的函数上。
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
return decorate
@register(active=False)
def f1():
print('running f1()')
@register()
def f2():
print('running f2()')
def f3():
print('running f3()')
running register(active=False)->decorate(<function f1 at 0x00000219F4517EB0>)
running register(active=True)->decorate(<function f2 at 0x00000219F2A5CEE0>)