此文章的内容来自《流畅的Python(第一版)》的第七章:函数装饰器和闭包。因为自己初学Python,做笔记可以说完全是抄书,到后面越来越没心劲儿了,所以不再抄书(做笔记)了,,从页数p171,节数为7.8.2之后的内容不想也不需要续了。

(无奈~~~)

函数装饰器用于在源码中“标记”函数,以某种方式增强函数的行为。想掌握装饰器,必须理解闭包。除了在装饰器中有用处之外,闭包函数回调式异步编程和函数式编程风格的基础。

这篇文章是为了解释清楚函数装饰器的工作原理,包括最简单的注册装饰器和较复杂的参数化装饰器。

1 装饰器基础知识

装饰器是可调用的对象,其参数是另一个函数(被装饰的函数)。装饰器可能会处理被装饰的函数,然后把它返回,或者将其替换成另一个函数或可调用对象。

假如有个名为decorate的装饰器:

@decorate
def target():
print('running target()')

上述代码的效果与下述写法一样:

def target():
print('running target()')

target = decorate(target)

两种写法的最终结果一样:上述两个代码片段执行完毕后得到的target不一定是原来那个target函数,而是decorate(target)返回的函数。

为了确认被装饰的函数会被替换,请看下面的控制台对话。

# 装饰器通常把函数替换成另一个函数

>>> def deco(func):
... def inner():
... print('running inner()')
... return inner # deco返回inner函数对象
...
>>> @deco
... def target(): # 使用deco装饰target
... print('running target()')
...
>>> target() # 调用被装饰的target其实会运行inner
running inner()
>>> target # 审查对象,发现target现在是inner的引用
<function deco.<locals>.inner at 0x000001FB4640A550>

严格来说,装饰器只是语法糖。如上所示,装饰器可以像常规的可调用对象那样调用,其参数是另一个函数。综上,装饰器的一大特性是,能把被装饰的函数替换成其他函数。第二个特性是,装饰器在加载模块时立即执行。

2 Python何时执行装饰器

装饰器的一个关键特性是,它们在被装饰的函数定义之后立即运行。这通常是在导入时(即Python加载模块时)。下面是示例代码registration.py:

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()

把registration.py当做脚本得到的输出如下:

running register(<function f1 at 0x000002B32DA25430>)
running register(<function f2 at 0x000002B32DA254C0>)
running main()
registry -> [<function f1 at 0x000002B32DA25430>, <function f2 at 0x000002B32DA254C0>]
running f1()
running f2()
running f3()

注意,register在模块中其他函数之前运行(两次)。调用register时,传给它的参数是被装饰的函数,例如​​<function f1 at 0x000002B32DA25430>​​。加载模块后,registry中有两个被装饰函数的引用:f1和f2。f1、f2、f3这三个函数都是只有main明确调用它们时才执行。

如果导入registration.py模块(不作为脚本运行),输出如下:

>>> import registration
running register(<function f1 at 0x00000238FDF2A550>)
running register(<function f2 at 0x00000238FDF2A5E0>)

查看registry的值如下:

>>> registration.registry
[<function f1 at 0x00000238FDF2A550>, <function f2 at 0x00000238FDF2A5E0>]

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

考虑到装饰器在真实代码中的常用方式,registration.py有两个不寻常的地方:

1、装饰器函数与被装饰的函数在同一模块中定义。事实上,装饰器通常在一个模块中定义,然后应用到其他模块中的函数上。

2、register装饰器返回的函数与通过参数传入的函数是相同的。事实上,大多数装饰器会在内部定义一个函数,然后将这个函数返回。

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

使用注册装饰器可以改进《流畅的Python》书中6.1节中的电商促销折扣示例。

示例6-6的主要问题是,定义体中有函数的名称,但是best_promo用来判断那个折扣幅度最大的promos列表中也有函数名称。这种重复是个问题,因为新增策略函数后可能会忘记把他添加到promos列表中,导致best_promo忽略新策略,而且不报错,为系统引入了不易察觉的缺陷。下面的代码使用注册装饰器解决了这个问题:

# promos列表中的值使用promotion装饰器填充

promos = [] # 存放策略的列表

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

@promotion
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): # best_promo无需修改,因为它依赖promos列表
"""选择可用的最佳折扣"""
return max(promo(order) for promo in promos)

上面的代码有几个优点:

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

不过,多数装饰器会修改被装饰的函数。通常装饰器会定义一个内部函数,然后将其返回,替换被装饰的函数。使用内部函数的代码几乎都要靠闭包才能正确运作。为了理解闭包,我们先来了解Python的变量作用域。

4 变量作用域规则

在下面的代码中,定义并测试了一个函数,此函数读取两个变量的值:局部变量a——函数的参数;变量b——这个函数没定义它。

>>> def f1(a):
... print(a)
... print(b)
...
>>> f1(3)
3
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "<stdin>", line 3, in f1
NameError: name 'b' is not defined

出现错误很正常。在代码中,若先给全局变量b赋值,再调用f1,则不会出错:

>>> b = 6
>>> f1(3)
3
6

下面看一个特殊的例子。下面代码中的f2函数的两行代码和上面的f1一样,然后为b赋值,再打印b的值。可是在赋值前,第二个print失败了:

>>> # b是局部变量,因为在函数的定义体中给它赋值了
>>> 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

注意,首先输出了3,这表明print(a)语句执行了。但是第二个语句print(b)执行不了。感觉会打印6,因为有个全局变量b,而且是在print(b)之后为局部变量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)
3
6
>>> b
9
>>> f3(3)
3
9

5 闭包

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

概念难以掌握,下面通过示例理解:假如有个名为avg的函数,它的作用是计算不断增加的序列值的均值;例如,整个历史中某个商品的平均收盘价。每天都会增加新价格,因此平均值要考虑至目前为止所有的价格。

起初,avg是这样用的:

>>> avg(10) # 事实上还没有定义avg函数,但是下面的结果符合预期
10.0
>>> avg(11)
10.5
>>> avg(10)
11

下面是一个【计算运动平均值的】average_oo.py:

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)

Average的实例是可调用对象:

>>> avg = Averager()
>>> avg(10)
10.0
>>> avg(11)
10.5
>>> avg(12)
11.0

下面是函数式实现计算移动平均值,使用高阶函数make_averager。averager.py:

def make_averager(): # # 计算移动平均值的高阶函数
series = []
def averager(new_value):
series.append(new_value)
total = sum(series)
return total/len(series)
return averager

调用make_averager时,返回一个averager函数对象。每次调用averager时,它会把参数添加到系列中,然后计算当前平均值。下面的代码为了测试averager.py:

>>> avg = make_averager()
>>> avg(10)
10.0
>>> avg(11)
10.5
>>> avg(12)
11.0

注意,averager.py与average_oo.py有共通之处:调用Averager()或make_averager()得到一个可调用对象avg,他会更新历史值,再计算当前均值。在average_oo.py中,avg是Averager的实例;在averager.py中,avg是内部函数averager。不管怎样,只需调用avg(n),把n放入序列值中,然后重新计算均值。

Averager类的实例avg在self.series里存储历史值。但是第二个示例中的avg函数在哪里查找series呢?

注意series是make_averager函数的局部变量,因为在函数的定义体中初始化了series:series = []。但是,调用avg(10)时,make_averager函数已经返回了,而它的本地作用域也一去不复返了。

在averager函数中,series是自由变量(free variable),指未在本地作用域中绑定的变量。

审查返回的make_averager对象,发现Python在__code__属性(表示编译后的函数定义体)中保存局部变量和自由变量的名称。下面是【审查make_averager创建的函数】的代码:

>>> avg.__code__.co_varnames
('new_value', 'total')
>>> avg.__code__.co_freevars
('series',)

series的绑定在返回的avg函数的​​__closure__​​​属性中。​​avg.__closure__​​​中的各个元素对应于​​avg.__code__.co_freevars​​中的一个名称。这些元素是cell对象,有个cell_content属性,保存着真正的值。这些属性的值如下:

>>> avg.__code__.co_freevars
('series',)
>>> avg.__closure__
(<cell at 0x000001E467125B80: list object at 0x000001E467141400>,)
>>> avg.__closure__[0].cell_contents
[10, 11, 12]

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

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

6 nonlocal声明

在上一节的averager.py中,程序将所有值存储在历史序列中,然后在每次调用averager时使用sum求和。更好的方式是,只存储目前的总值和元素个数,然后使用这两个数计算均值。

下面的代码有缺陷,只是为了阐明观点:

def make_averager(): # averager1.py 计算移动平均值的高级函数,不保存所有历史值,但是有缺陷
count = 0
total = 0

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

尝试使用上面的函数,会得到如下结果:

>>> avg = make_averager()
>>> avg(10)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "D:\其他\Pycharm学习\averager1.py", line 6, in averager
count += 1
UnboundLocalError: local variable 'count' referenced before assignment

问题是,当count是数字或任何不可变类型时,count += 1语句的作用其实与count = count + 1一样。这样的话,相当于在averager的定义体中为count赋值了,这会把count变为局部变量。total变量也受这个问题影响。、

averager.py没遇到这个问题,因为代码中没有给series赋值,只是调用series.append,并把它传给sum和len。也就是说,代码利用了列表是可变的对象这一事实。

但是对数字、字符串、元组等不可变类型来说,只能读取,不能更新。如果尝试重新绑定,如count = count + 1,其实会隐式创建局部变量count。这样的话,count就不是自由变量了,因此不会保存在闭包中。

Python引入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

7 实现一个简单的装饰器

下面的代码定义了一个装饰器,功能是在每次调用被装饰的函数时计时,然后把经过的时间、传入的参数和调用的结果打印出来(clockdeco.py):

import time

def clock(func):
def clocked(*args): # 定义内部函数clocked,它接受任意个定位参数
t0 = time.perf_counter()
result = fun(*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 # 返回内部函数,取代被装饰的函数

下面的代码演示了clock装饰器的用法(clockdeco_demo.py):

# clockdeco_demo.py 使用clock装饰器

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))

输出结果为:

**************************************** Calling snooze(.123)
[0.12318430s] snooze(0.123) -> None
**************************************** Calling factorial(6)
[0.00000200s] factorial(1) -> 1
[0.00004970s] factorial(2) -> 2
[0.00008730s] factorial(3) -> 6
[0.00011450s] factorial(4) -> 24
[0.00014130s] factorial(5) -> 120
[0.00017300s] factorial(6) -> 720
6! = 720

装饰器的典型行为是:把被装饰的函数替换为新函数,二者接受相同的参数,而且(通常)返回被装饰函数本该返回的值,同时还会做些额外操作。

clockdeco.py实现的装饰器有以下缺点:不支持关键字参数,而且覆盖了被装饰函数的__name__和__doc__属性。下面的代码(clockdeco2.py)使用functools.wraps装饰器把相关的属性从func复制到clocked中,而且代码还可以处理关键字参数:

import time
import functools
def clock(func):
@functools.wraps(func) # 装饰器将相关的属性从func复制到clocked中
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

8 标准库中的装饰器

下面介绍标准库中的两个值得关注的装饰器。

8.1 使用functools.lru_cache做备忘

functools.lru_cache是非常实用的装饰器,它实现了备忘功能。这是一项优化技术,它把耗时的函数的结果保存起来,避免传入相同的参数时重复计算。lru三个字母是“Least Recently Used”的缩写,表明缓存不会无限制增长,一段时间不用的缓存条目会被丢弃。

生成第n个斐波那契数这种慢速递归函数适合使用lru_cache。下面的代码(fibo_demo.py)未使用lru_cache装饰器,递归方式非常耗时。代码如下:

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(6))

运行fibo_demo.py,结果如下,除了最后一行,其余输出都是clock装饰器生成的:

[0.00000040s] fibonacci(0) -> 0
[0.00000050s] fibonacci(1) -> 1
[0.00005350s] fibonacci(2) -> 1
[0.00000030s] fibonacci(1) -> 1
[0.00000030s] fibonacci(0) -> 0
[0.00000020s] fibonacci(1) -> 1
[0.00001230s] fibonacci(2) -> 1
[0.00002400s] fibonacci(3) -> 2
[0.00008970s] fibonacci(4) -> 3
[0.00000020s] fibonacci(1) -> 1
[0.00000020s] fibonacci(0) -> 0
[0.00000020s] fibonacci(1) -> 1
[0.00001140s] fibonacci(2) -> 1
[0.00002280s] fibonacci(3) -> 2
[0.00000020s] fibonacci(0) -> 0
[0.00000030s] fibonacci(1) -> 1
[0.00001150s] fibonacci(2) -> 1
[0.00000020s] fibonacci(1) -> 1
[0.00000030s] fibonacci(0) -> 0
[0.00000030s] fibonacci(1) -> 1
[0.00001210s] fibonacci(2) -> 1
[0.00002340s] fibonacci(3) -> 2
[0.00004610s] fibonacci(4) -> 3
[0.00008010s] fibonacci(5) -> 5
[0.00018170s] fibonacci(6) -> 8
8

上述代码浪费时间的地方很明显:fibonacci(1)调用了8次,fibonacci(2)调用了5次。下面的代码(fibo_demo2.py)使用lru_cache装饰器,性能会显著改善:

import functools
from clockdeco import clock

@functools.lru_cache() # 必须像常规函数那样调用lru_cache。有括号(),这样做使得lru_cache可接受配置参数,稍后会详细说明
@clock # 叠放了装饰器:@lru_cache()应用到@clock返回的函数上
def fibonacci(n):
if n < 2:
return n
return fibonacci(n-2) + fibonacci(n-1)

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

这样一来,执行时间减半了,而且n的每个值只调用一次函数:

[0.00000090s] fibonacci(0) -> 0
[0.00000140s] fibonacci(1) -> 1
[0.00641200s] fibonacci(2) -> 1
[0.00000090s] fibonacci(3) -> 2
[0.00643280s] fibonacci(4) -> 3
[0.00000070s] fibonacci(5) -> 5
[0.00645350s] fibonacci(6) -> 8
8

除优化递归算法外,lru_cache在从web中获取信息的应用中也能发挥巨大作用。

lru_cache可以使用两个可选参数来配置。它的签名(签名是表示调用函数的方式,即定义了函数的输入和输出)是:

functools.lru_cache(maxsize=128, typed=False)

maxsize参数指定存储多少个调用的结果。缓存满了以后,旧的结果会被扔掉,以腾出空间。为得到最佳性能,maxsize应设为2的幂。typed参数如果设为True,就会把不同参数类型得到的结果分开保存,即把通常认为相等的浮点数和整数参数(如1和1.0)区分开。因为lru_cache使用字典存储结果,而且键根据调用时传入的定位参数和关键字参数创建,所以被lru_cache装饰的函数,它的所有参数都必须是可散列(不懂就去百度)的。

下面介绍functools.singledispatch

8.2 单分配泛函数

假设在开发一个调试web应用的工具,我们像生成HTML,显示不同类型的Python对象。我们可能编写这样的函数:

import html

def htmlize(obj):
content = html.escape(repr(obj))
return '<pre>pre</pre>'.format(content)

上面的函数适用于任何Python类型。现在,想做个拓展,让函数使用特别的方式显示某些类型的数据:

  • str:把内部的换行符替换为’<br>\n‘;不使用<pre>,而是使用<p>。
  • int:以十进制和十六进制显示数字。
  • list:输出一个HTML列表,根据各个元素的类型进行格式化。

我们想要的行为(生成HTML的htmlize函数,调整了几种对象的输出)如下:

>>> # 本段所有代码的输出结果都是理想输出,后面会列出真正实现了这些功能的代码
>>> htmlize({1, 2, 3}) # 1
'<pre>{1, 2, 3}</pre>'
>>> htmlize(abs)
'<pre><built-in function abs></pre>'
>>> htmlize('Heimlich & Co.\n- a game') # 2
'<p>Heimlich & Co.<br>\n- a game</p>'
>>> htmlize(42) # 3
'<pre>42 (0x2a)</pre>'
>>> print(htmlize(['alpha', 66, {3, 2, 1}])) # 4
<ul>
<li><p>alpha</p></li>
<li><pre>66 (0x42)</pre></li>
<li><pre>{1, 2, 3}</pre></li>
</ul>

注释:
1.默认情况下,在<pre><\pre>中显示HTML转义后的对象字符串表示形式。
2.为str对象显示的也是HTML转义后的字符串表示形式,不过放在<p></p>中,而且使用<br>表示换行。
3.int显示为十进制和十六进制两种形式,放在<pre></pre>中。
4.各个列表项目根据各自的类型格式化,整个列表则渲染成HTML列表。

此文章的内容来自《流畅的Python(第一版)》的第七章:函数装饰器和闭包。因为自己初学Python,做笔记可以说完全是抄书,到后面越来越没心劲儿了,所以不再抄书(做笔记)了,,从页数p171,节数为7.8.2之后的内容不想也不需要续了。

(无奈~~~)