Python学习之路26-函数装饰器和闭包

《流畅的Python》笔记。

本篇将从最简单的装饰器开始,逐渐深入到闭包的概念,然后实现参数化装饰器,最后介绍标准库中常用的装饰器。

1. 初步认识装饰器

函数装饰器用于在源代码中“标记”函数,以某种方式增强函数的行为。装饰器就是函数,或者说是可调用对象,它以另一个函数为参数,最后返回一个函数,但这个返回的函数并不一定是原函数。

1.1 装饰器基础用法

以下是装饰器最基本的用法:

1
2
3
4
5
6
7
8
# 代码1
#装饰器用法
@decorate
def target(): pass

# 上述代码等价于以下代码
def target(): pass
target = decorate(target)

即,最终的target函数是由decorate(target)返回的函数。下面这个例子说明了这一点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 代码2
def deco(func):
def inner():
print("running inner()")
return inner

@deco
def target():
print("running target()")

target()
print(target)

# 结果
running inner() # 输出的是装饰器内部定义的函数的调用结果
<function deco.<locals>.inner at 0x000001AF32547D90>

从上面可看出,装饰器的一大特性是能把被装饰的函数替换成其他函数。但严格说来,装饰器只是语法糖(语法糖:在编程语言中添加某种语法,但这种语法对语言的功能没有影响,只是更方便程序员使用)。

装饰器还可以叠加。下面是一个说明,具体例子见后面章节:

1
2
3
4
5
6
7
8
# 代码3
@d1
@d2
def f(): pass

#上述代码等价于以下代码:
def f(): pass
f = d1(d2(f))

1.2 Python何时执行装饰器

装饰器的另一个关键特性是,它在被装饰的函数定义后立即运行,这通常是在导入时,即Python加载模块时:

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
# 代码4
registry = []

def register(func):
print("running register(%s)" % func)
registry.append(func)
return func

@register
def f1():
print("running f1()")

def f2():
print("running f2()")

if __name__ == "__main__":
print("running in main")
print("registry ->", registry)
f1()
f2()

# 结果
running register(<function f1 at 0x0000027745397840>)
running in main # 进入到主程序
registry -> [<function f1 at 0x0000027745397840>]
running f1()
running f2()

装饰器register在加载模块时就对f1()进行了注册,所以当运行主程序时,列表registry并不为空。

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

装饰器在真实代码中的使用方式与代码4中有所不同:

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

代码4中的装饰器原封不动地返回了传入的函数。这种装饰器并不是没有用,正如代码4中的装饰器的名字一样,这类装饰器常充当了注册器,很多Web框架就使用了这种方法。下一小节也是该类装饰器的一个例子。

1.3 使用装饰器改进策略模式

上一篇中我们用Python函数改进了传统的策略模式,其中,我们定义了一个promos列表来记录有哪些具体策略,当时的做法是用globals()函数来获取具体的策略函数,现在我们用装饰器来改进这一做法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 代码5,对之前的代码进行了简略
promos = []

def promotion(promo_func): # 只充当了注册器
promos.append(promo_func)
return promo_func

@promotion
def fidelity(order): pass

@promotion
def bulk_item(order): pass

@promotion
def large_order(order): pass

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

该方案相比之前的方案,有以下三个优点:

  • 促销策略函数无需使用特殊名字,即不用再以_promo结尾
  • @promotion装饰器突出了被装饰函数的作用,还便于临时禁用某个促销策略(只需将装饰器注释掉)
  • 促销策略函数在任何地方定义都行,只要加上装饰器即可。

2. 闭包

正如前文所说,多数装饰器会在内部定义函数,并将其返回,已替换掉传入的函数。这个机制的实现就要靠闭包,但在理解闭包之前,先来看看Python中的变量作用域。

2.1 变量作用域规则

通过下述例子来解释局部变量和全局变量:

1
2
3
4
5
6
7
8
9
10
# 代码6
>>> def f1(a):
... print(a)
... print(b)

>>> f1(3)
3
Traceback (most recent call last):
-- snip --
NameError: name 'b' is not defined

当代码运行到print(a)时,Python查找变量a,发现变量a存在于局部作用域中,于是顺利执行;当运行到print(b)时,python查找变量b,发现局部作用域中并没有变量b,便接着查找全局作用域,发现也没有变量b,最终报错。正确的调用方式相信大家也知道,就是在调用f1(3)之前给变量b赋值。

我们再看如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
# 代码7
>>> b = 6
>>> def f2(a):
... print(a)
... print(b)
... b = 9

>>> f2(3)
3
Traceback (most recent call last):
-- snip --
UnboundLocalError: local variable 'b' referenced before assignment

按理说不应该报错,并且b的值应该打印为6,但结果却不是这样。

事实是:变量b本来是全局变量,但由于在f2()中我们为变量b赋了值,于是Python在局部作用域中也注册了一个名为b的变量(全局变量b依然存在,有编程基础的同学应该知道,这叫做“覆盖”)。当Python执行到print(b)语句时,Python先搜索局部作用域,发现其中有变量b,但是b此时还没有被赋值(全局变量b被覆盖,而局部变量b的赋值语句在该句后面),于是Python报错。

如果不想代码7报错,则需要使用global语句,将变量b声明为全局变量:

1
2
3
4
5
# 代码8
>>> b = 6
>>> def f2(a):
... global b
... -- snip --

2.2 闭包的概念

现在开始真正接触闭包。闭包指延伸了作用域的函数,它包含函数定义体中引用,但不在定义体中定义的非全局变量,即这类函数能访问定义体之外的非全局变量。只有涉及嵌套函数时才有闭包问题。

下面用一个例子来说明闭包以及非全局变量。定义一个计算某商品一段时间内均价的函数avg,它的表现如下:

1
2
3
4
5
6
7
# 代码9
>>> avg(10)
10.0
>>> avg(11)
10.5
>>> avg(12)
11.0

假定商品价格每天都在变化,因此需要一个变量来保存这些值。如果用类的思想,我们可以定义一个可调用对象,把这些值存到内部属性中,然后实现__call__方法,让其表现得像函数;但如果按装饰器的思想,可以定义一个如下的嵌套函数:

1
2
3
4
5
6
7
8
9
10
# 代码10
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
# 代码11
>>> avg = make_averager()
>>> avg(10)
10.0
-- snip --

不知道大家刚接触这个内部的averager()函数时有没有疑惑:代码11中,当执行avg(10)时,它是到哪里去找的变量seriesseries是函数make_averager()的局部变量,当make_averager()返回了averager()后,它的局部作用域就消失了,所以按理说series也应该跟着消失,并且上述代码应该报错才对。

事实上,在averager函数中,series自由变量(free variable),即未在局部作用域中绑定的变量。这里,自由变量series和内部函数averager共同组成了闭包,参考下图:

实际上,Python在averager__code__属性中保存了局部变量和自由变量的名称,在__closure__属性中保存了自由变量的值:

1
2
3
4
5
6
7
8
9
10
# 代码12,注意这些变量的单词含义,一目了然
>>> avg.__code__.co_varnames # co_varnames保存局部变量的名称
('new_value', 'total')
>>> avg.__code__.co_freevars # co_freevars保存自由变量的名称
('series',)
>>> avg.__closure__ # 单词closure就是闭包的意思
# __closure__是一个cell对象列表,其中的元素和co_freevars元组一一对应
(<cell at 0x0000024EE023D7F8: list object at 0x0000024EDFE76288>,)
>>> avg.__closure__[0].cell_contents
[10, 11, 12] # cell对象的cell_contents属性才是真正保存自由变量的值的地方

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

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

2.3 nonlocal声明

代码10中的make_averager函数并不高效,因为如果只计算均值的话,其实不用保存每次的价格,我们可按如下方式改写代码10

1
2
3
4
5
6
7
8
9
10
11
# 代码13
def make_averager():
count = 0
total = 0

def averager(new_value):
count += 1
total += new_value
return total / count

return averager

但此时直接运行代码11的话,则会报代码7中的错误:UnboundLocalError

问题在于:由于count是不可变类型,在执行count += 1时,该语句等价于count = count + 1,而这就成了赋值语句,count不再是自由变量,而变成了averager的局部变量。total也是一样的情况。而在之前的代码10中没有这个问题,因为series是个可变类型,我们只是调用series.append,以及把它传给了sumlen,它并没有变为局部变量。

对于不可变类型来说,只能读取,不能更新,否则会隐式创建局部变量。为了解决这个问题,Python3引入了nonlocal声明。它的作用是把变量显式标记为自由变量:

1
2
3
4
5
6
7
8
# 代码14
def make_averager():
count = 0
total = 0

def averager(new_value):
nonlocal count, total
-- snip --

3. 装饰器

了解了闭包后,现在开始正式使用嵌套函数来实现装饰器。首先来认识标准库中三个重要的装饰器。

3.1 标准库中的装饰器

3.1.1 functools.wraps装饰器

来看一个简单的装饰器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 代码15
def deco(func):
def test():
func()
return test

@deco
def Test():
"""This is a test"""
print("This is a test")

print(Test.__name__)
print(Test.__doc__)

# 结果
test
None

我们想让装饰器来自动帮我们做一些额外的操作,但像改变函数属性这样的操作并不一定是我们想要的:从上面可以看出,Test现在指向了内部函数testTest自身的属性被遮盖。如果想保留函数原本的属性,可以使用标准库中的functools.wraps装饰器。下面以一个更复杂的装饰器为例,它会在每次调用被装饰函数时计时,并将经过的时间,传入的参数和调用的结果打印出来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 代码16
# clockdeco.py
import time, 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

它的使用将和下一个装饰器一起展示。

3.1.2 functools.lru_cache装饰器

functools.lru_cache实现了备忘(memoization)功能,这是一项优化技术,他把耗时的函数的结果保存起来,避免传入相同参数时重复计算。以斐波那契函数为例,我们知道以递归形式实现的斐波那契函数会出现很多重复计算,此时,就可以使用这个装饰器。以下代码是没使用该装饰器时的运行情况:

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
33
34
35
36
37
38
39
40
41
42
43
# 代码17
from clockdeco import clock

@clock
def fibonacci(n):
if n < 2:
return n
return fibonacci(n - 2) + fibonacci(n - 1)

if __name__ == "__main__":
print(fibonacci.__name__)
print(fibonacci.__doc__)
print(fibonacci(6))

# 结果:
fibonacci # fibonacci原本的属性得到了保留
None
[0.00000000s] fibonacci(0) -> 0
[0.00000000s] fibonacci(1) -> 1
[0.00000000s] fibonacci(2) -> 1
[0.00000000s] fibonacci(1) -> 1
[0.00000000s] fibonacci(0) -> 0
[0.00000000s] fibonacci(1) -> 1
[0.00000000s] fibonacci(2) -> 1
[0.00000000s] fibonacci(3) -> 2
[0.00000000s] fibonacci(4) -> 3
[0.00000000s] fibonacci(1) -> 1
[0.00000000s] fibonacci(0) -> 0
[0.00000000s] fibonacci(1) -> 1
[0.00000000s] fibonacci(2) -> 1
[0.00000000s] fibonacci(3) -> 2
[0.00000000s] fibonacci(0) -> 0
[0.00000000s] fibonacci(1) -> 1
[0.00000000s] fibonacci(2) -> 1
[0.00000000s] fibonacci(1) -> 1
[0.00000000s] fibonacci(0) -> 0
[0.00000000s] fibonacci(1) -> 1
[0.00049996s] fibonacci(2) -> 1
[0.00049996s] fibonacci(3) -> 2
[0.00049996s] fibonacci(4) -> 3
[0.00049996s] fibonacci(5) -> 5
[0.00049996s] fibonacci(6) -> 8
8

可以看出,fibonacci(1)调用了8次,下面我们用functools.lru_cache来改进上述代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 代码18
import functools
from clockdeco import clock

@functools.lru_cache() # 注意此处有个括号!该装饰器就收参数!不能省!
@clock
def fibonacci(n):
if n < 2:
return n
return fibonacci(n - 2) + fibonacci(n - 1)

if __name__ == "__main__":
print(fibonacci(6))

# 结果:
[0.00000000s] fibonacci(0) -> 0
[0.00000000s] fibonacci(1) -> 1
[0.00000000s] fibonacci(2) -> 1
[0.00000000s] fibonacci(3) -> 2
[0.00000000s] fibonacci(4) -> 3
[0.00000000s] fibonacci(5) -> 5
[0.00000000s] fibonacci(6) -> 8
8

functools.lru_cache装饰器可以接受参数,并且此代码还叠放了装饰器。

lru_cache有两个参数:functools.lru_cache(maxsize=128, typed=False)

  • maxsize指定存储多少个调用的结果,该参数最好是2的幂。当缓存满后,根据LRU算法替换缓存中的内容,这也是为什么这个函数叫lru_cache
  • type如果设置为True,它将把不同参数类型下得到的结果分开保存,即把通常认为相等的浮点数和整数参数分开(比如区分1和1.0)。
  • lru_cache使用字典存储结果,字典的键是传入的参数,所以被lru_cache装饰的函数的所有参数都必须是可散列的!

3.1.3 functools.singledispatch装饰器

我们知道,C++支持函数重载,同名函数可以根据参数类型的不同而调用相应的函数。以Python代码为例,我们希望下面这个函数表现出如下行为:

1
2
3
4
5
6
7
8
9
10
11
# 代码19
def myprint(obj):
return "Hello~~~"

# 以下是我们希望它拥有的行为:
>>> myprint(1)
Hello~~~
>>> myprint([])
Hello~~~
>>> myprint("hello") # 即,当我们传入特定类型的参数时,函数返回特定的结果
This is a str

单凭这一个myprint还无法实现上述要求,因为Python不支持方法或函数的重载。为了实现类似的功能,一种常见的做法是将函数变为一个分派函数,使用一串if/elif/elif来判断参数类型,再调用专门的函数(如myprint_str),但这种方式不利于代码的扩展和维护,还显得没有B格。。。

为解决这个问题,从Python3.4开始,可以使用functools.singledispath装饰器,把整体方案拆分成多个模块,甚至可以为无法修改的类提供专门的函数。被@singledispatch装饰的函数会变成泛函数(generic function),它会根据第一个参数的不同而调用响应的专门函数,具体用法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 代码20
from functools import singledispatch
import numbers

@singledispatch
def myprint(obj):
return "Hello~~~"

# 可以叠放多个register,让同一函数支持不同类型
@myprint.register(str)
# 注册的专门函数最好处理抽象基类,而不是具体实现,这样代码支持的兼容类型更广泛
@myprint.register(numbers.Integral)
def _(text): # 专门函数的名称无所谓,使用 _ 可以避免起名字的麻烦
return "Special types"

对泛函数的补充:根据参数类型的不同,以不同方式执行相同操作的一组函数。如果依据是第一个参数,则是单分派;如果依据是多个参数,则是多分派。

3.2 参数化装饰器

3.2.1 简单版参数化装饰器

从上面诸多例子我们可以看到两大类装饰器:不带参数的装饰器(调用时最后没有括号)和带参数的装饰器(带括号)。Python将被装饰的函数作为第一个参数传给了装饰器函数,那装饰器函数如何接受其他参数呢?做法是:创建一个装饰器工厂函数,在这个工厂函数内部再定义其它函数作为真正的装饰器。工厂函数代为接受参数,这些参数作为自由变量供装饰器使用。然后工厂函数返回装饰器,装饰器再应用到被装饰函数上。

我们把1.2中代码4@register装饰器改为带参数的版本,以active参数来指示装饰器是否注册某函数(虽然这么做有点多余)。这里只给出@register装饰器的实现,其余代码参考代码4

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 代码21
registry = set()

def register(active=True):
def decorate(func): # 变量active对于decorate函数来说是自由变量
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) # 即使不传参数也要作为函数调用@register()
def f():pass

# 上述用法相当于如下代码:
# register(active=False)(f)

3.2.2 多层嵌套版参数化装饰器

参数化装饰器通常会把被装饰函数替换掉,而且结构上需要多一层嵌套。下面以3.1.1中代码16里的@clock装饰器为例,让它按用户要求的格式输出数据。为了简便,不调用functools.wraps装饰器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 代码22
import time

DEFAULT_FMT = "[{elapsed:0.8f}s] {name}({args}) -> {result}"

def clock(fmt=DEFAULT_FMT): # 装饰器工厂,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())) #locals()函数以字典形式返回clocked的局部变量
return _result
return clocked
return decorate

可以得到如下结论:装饰器函数有且只有一个参数,即被装饰器的函数;如果装饰器要接受其他参数,请在原本的装饰器外再套一层函数(工厂函数),由它来接受其余参数;而你最终使用的函数应该定义在装饰器函数中,且它的参数列表应该和被装饰的函数一致。

4. 总结

本篇首先介绍了最简单装饰器如何定义和使用,介绍了装饰器在什么时候被执行,以及用最简单的装饰器改造了上一篇的策略模式;随后更进一步,介绍了与闭包相关的概念,包括变量作用域,闭包和nonlocal声明;最后介绍了更复杂的装饰器,包括标准库中的装饰器的用法,以及如何定义带参数的装饰器。

但上述对装饰器的描述都是基本的, 更复杂、工业级的装饰器还需要更深入的学习。

VPointer wechat
欢迎大家关注我的微信公众号"代码港"~~
您的慷慨将鼓励我继续创作~~