《Fluent Python》学习笔记:第 7 章 函数装饰器和闭包

2022-09-16 14:29:46

本文主要是 Fluent Python 第 7 章的学习笔记。这部分主要是介绍了使用闭包、变量作用域、自由变量、nonlocal、装饰器原理、装饰器的使用等。

装饰器(decorator)可以增强函数的行为,是一项强大的功能。

理解函数装饰器的工作原理,包括最简单的注册装饰器和较复杂的参数化装饰器。

  • Python 如何计算装饰器句法
  • Python 如何判断变量是不是局部的
  • 闭包存在的原因和工作原理
  • nonlocal 能解决什么问题
  • 实现行为良好的装饰器
  • 标准库中有用的装饰器
  • 实现一个参数化装饰器

7.1 装饰器基础知识

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

defdeco(func):definner():print('running inner()')return inner

@decodeftarget():print('running target()')

target()print(target)
running inner()
<function deco.<locals>.inner at 0x0000024A5ABB3048>

注意:这里 @deco 装饰后的 target 函数会被替换,发现 target 变成了 inner 的引用。

@decoratedeftarget():print('running target()')# 上面的代码和下面等效deftarget():print('running target()')

    target= decorate(target)

严格的说,装饰器只是语法糖(syntactic sugar)。在做元编程(metaprogramming)时非常有用。元编程是在运行时改变程序的行为。
装饰器有两大特性:

  1. 能把装饰的函数替换成其他函数。
  2. 装饰器在加载模块时立即执行。

7.2 Python 什么时候执行装饰器

装饰器的一个特性是,它们在被装饰的函数定义之后立即运行。如:

# registration.py 模块
registry=[]# 保存被@register装饰的函数引用defregister(func):# register 的参数是一个函数print(f'running register({func})')# 显示被装饰的函数
    registry.append(func)# 把 func 放入registryreturn func# 返回func,这里返回的函数与通过参数传入的一样

@registerdeff1():print('running f1()')

@registerdeff2():print('running f2()')deff3():print('running f3()')defmain():print('running main()')print('registry ->', registry)
    f1()
    f2()
    f3()if __name__=='__main__':
    main()
running register(<function f1 at 0x0000024A5AB8FA68>)
running register(<function f2 at 0x0000024A5ABB31F8>)
running main()
registry -> [<function f1 at 0x0000024A5AB8FA68>, <function f2 at 0x0000024A5ABB31F8>]
running f1()
running f2()
running f3()

这说明,函数装饰器在导入模块时立即执行,而被装饰的函数只有在明确调用时才运行。
不过这里对于装饰器的用法和实际场景的用法有点区别:

  1. 装饰器函数和被装饰函数在一个模块中定义。实际应用中,装饰器通常在一个模块中定义,然后应用到其他模块中的函数上。
  2. register 装饰器返回的函数与通过参数传入的相同。实际应用中,大多数装饰器会在内部定义一个函数,然后将其返回。

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

# promos 列表中的值使用 promotion 装饰器填充
promos=[]# 列表起初是空的defpromotion(promo_func):# promotion 把 promo_func 添加到 promos 列表,然后原封不动将其返回
    promos.append(promo_func)return promo_func

@promotiondeffidelity(order):# 第一个具体策略"""积分1000或以上顾客5%折扣"""return order.total()*.05if order.customer.fidelity>=1000else0

@promotion# promotion 装饰的函数会被添加到 promos 列表defbulk_item(order):# 第二个策略"""单个商品20个或以上10%折扣"""
    discount=0for itemin order.cart:if item.quantity>=20:
            discount+= item.total()*.1return discount

@promotiondeflarge_order(order):# 第三个具体策略"""订单中不同商品达到10个或以上7%折扣"""

    distinct_items={item.productfor itemin order.cart}iflen(distinct_items)>=10:return order.total()*.07return0defbest_promo(order):"""选择最佳折扣"""returnmax(promo(order)for promoin promos)

使用装饰器与之前的解决方案相比:

  1. 促销策略函数无需使用特殊的名称(即不用以_promo 结尾)。
  2. @promotion 装饰器突出了被装饰的函数的作用,还便于临时禁用某个促销策略:只需把装饰器注释掉。
  3. 促销策略可以在其他模块中定义,在系统任何地方都行,只要使用 @promotion 装饰即可。

7.4 变量作用域

# 示例1deff1(a):print(a)print(b)

f1(3)
3



---------------------------------------------------------------------------

NameError                                 Traceback (most recent call last)

<ipython-input-4-b2d4c06c5adb> in <module>
      4     print(b)
      5
----> 6 f1(3)


<ipython-input-4-b2d4c06c5adb> in f1(a)
      2 def f1(a):
      3     print(a)
----> 4     print(b)
      5
      6 f1(3)


NameError: name 'b' is not defined

这个例子中,因为没有给全局变量 b 赋值,所以报错。如果先给全局变量 b 赋值,再调用 f1() 就不会出错,如下:

b=6

f1(3)
3
6

在看一个神奇的对比。

b=6deff2(a):print(a)print(b)
    b=9# 给局部变量赋值

f2(3)
3



---------------------------------------------------------------------------

UnboundLocalError                         Traceback (most recent call last)

<ipython-input-7-be17880bd5cd> in <module>
      6     b = 9  # 给局部变量赋值
      7
----> 8 f2(3)


<ipython-input-7-be17880bd5cd> in f2(a)
      3 def f2(a):
      4     print(a)
----> 5     print(b)
      6     b = 9  # 给局部变量赋值
      7


UnboundLocalError: local variable 'b' referenced before assignment

可以看到print(a) 被执行了,但是print(b) 执行失败,这是因为 Python 编译函数的定义体是,它判断 b 是局部变量,因为它在函数中给它赋值了。所以 print(b) 在给局部变量 b 赋值之前,所以执行失败。我们可以通过 dis 模块查看字节码来验证这种判断。

from disimport dis

dis(f1)print('-'*50)
dis(f2)
  3           0 LOAD_GLOBAL              0 (print)
              2 LOAD_FAST                0 (a)
              4 CALL_FUNCTION            1
              6 POP_TOP

  4           8 LOAD_GLOBAL              0 (print)
             10 LOAD_GLOBAL              1 (b)
             12 CALL_FUNCTION            1
             14 POP_TOP
             16 LOAD_CONST               0 (None)
             18 RETURN_VALUE
--------------------------------------------------
  4           0 LOAD_GLOBAL              0 (print)
              2 LOAD_FAST                0 (a)
              4 CALL_FUNCTION            1
              6 POP_TOP

  5           8 LOAD_GLOBAL              0 (print)
             10 LOAD_FAST                1 (b)
             12 CALL_FUNCTION            1
             14 POP_TOP

  6          16 LOAD_CONST               1 (9)
             18 STORE_FAST               1 (b)
             20 LOAD_CONST               0 (None)
             22 RETURN_VALUE

f2 中10 LOAD_FAST 1 (b) 说明 Python 把 b 当做了局部变量。

这不是缺陷,而是设计选择:Python 不要求声明变量,但是假定在函数定义体重赋值的变量是局部变量。这个比 JavaScript 的行为要好,JavaScript 也不要求声明变量,但是如果忘记把变量声明为局部变量(使用 var),可能辉仔不知情的情况下获取全局变量。

如果在函数中赋值时,想让解释器把 b 当做全局变量,可以使用 global 声明:

b=6deff3(a):global b# 声明 b 为全局变量print(a)print(b)# 可以顺利执行
    b=9# 给全局变量 b 赋值

f3(3)print(b)# b 变为 9
b=8print(b)
3
6
9
8

7.5 闭包

闭包(closure):闭包指延伸了作用域的函数,在这个函数定义体中引用但不在定义体中定义的非全局变量。
闭包的概念有点难理解,通过例子会直观很多。

假如有一个 avg 函数,它的作用是计算不断增加的系列值的均值;如在整个历史中,某个商品的平均收盘价,每天都会增加新价格,因此平均值要考虑至目前为止所有的价格。

那么我么要考虑 avg 如何实现,如何让 avg 保存历史值?初学者,可能考虑使用类实现:

classAverager(object):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()print(avg(10))print(avg(11))print(avg(12))
10.0
10.5
11.0

还可以考虑用函数式实现,使用高阶函数 make_averager:

defmake_averager():
    series=[]defaverager(new_value):
        series.append(new_value)
        total=sum(series)return total/len(series)return averager

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

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

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

通过审查返回的 averager 对象,发现 Python 在__code__ 属性中(表示编译后的函数定义体)保存局部变量和自由变量的名称。

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

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

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

综上,闭包是一种函数,它会保留定义函数时存在的自由变量的绑定,这样调用函数时,虽然定义作用域不可用了,但是仍能使用那些绑定。
注意:只有嵌套在其他函数中的函数才可能需要处理不在全局作用域的的外部变量。

7.6 nonlocal 声明

前面实现 make_averager 函数的效率并不高,因为所有历史值都存在列表中,然后每次调用 averager 时使用 sum 求和。更好的实现方式是,只存储目前的总值和元素的个数,然后利用这两个数计算均值。

# 计算移动平均值的高阶函数,不保存所有历史值,但有缺陷defmake_averager():
    count=0
    total=0defaverager(new_value):
        count+=1
        total+= new_valuereturn total/ countreturn averager

avg= make_averager()print(avg(10))
---------------------------------------------------------------------------

UnboundLocalError                         Traceback (most recent call last)

<ipython-input-22-e2f79112aa26> in <module>
     12
     13 avg = make_averager()
---> 14 print(avg(10))


<ipython-input-22-e2f79112aa26> in averager(new_value)
      5
      6     def averager(new_value):
----> 7         count += 1
      8         total += new_value
      9         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 修正defmake_averager():
    count=0
    total=0defaverager(new_value):nonlocal count, total
        count+=1
        total+= new_valuereturn total/ countreturn averager

avg= make_averager()print(avg(10))
10.0

7.7 实现一个简单的装饰器

目标:实现一个装饰器,它会在每次调用被装饰的函数时计时,然后把经过的时间、传入的参数和调用结果打印出来。
这很有用,帮助我们测试。

# 一个简单的装饰器,输出函数的运行时间import timedefclock(func):defclocked(*args):# 定义内部函数 clock,它接收任意个位置参数
        t0= time.perf_counter()
        result= func(*args)# 这行代码可用,是因为 clocked 的闭包中包含自由变量 func
        elapsed= time.perf_counter()- t0
        name= func.__name__
        arg_str=', '.join(repr(arg)for argin args)print('[%0.8fs] %s(%s) -> %r'%(elapsed, name, arg_str, result))#         print(f'[{elapsed:0.8f}] {name}({arg_str}) -> {result}')return result# 返回func调用结果return clocked# 返回内部函数,取代被装饰的函数

@clockdefsnooze(seconds):
    time.sleep(seconds)

@clockdeffactorial(n):return1if n<2else n*factorial(n-1)if __name__=='__main__':print('*'*40,'Calling snooze(.123)')
    snooze(.123)print('*'*40,'Calling fatorial(6)')print('6! =', factorial(6))
**************************************** Calling snooze(.123)
[0.12216680s] snooze(0.123) -> None
**************************************** Calling fatorial(6)
[0.00000060s] factorial(1) -> 1
[0.00059040s] factorial(2) -> 2
[0.00113190s] factorial(3) -> 6
[0.00171990s] factorial(4) -> 24
[0.00221420s] factorial(5) -> 120
[0.00279300s] factorial(6) -> 720
6! = 720

工作原理解释:

如下代码:

@clockdeffactorial(n):return1if n<2else n*factorial(n-1)

等价于:

deffactorial(n):return1if n<2else n*factorial(n-1)

factorial= clock(factorial)

在这两个示例中,factorial 会作为 func 参数传给 clock,然后 clock 函数会返回从 clocked 函数,Python 解释器在背后会把 clocked 赋值给 factorial。其实可以通过__name__查看 factorial 的属性,如下:

print(factorial.__name__)
clocked

可以看到,现在 factorial 保存的是 clocked 函数的引用,自此之后,每次调用 factorial(n),执行的都是 clocked(n)。clocked 大致做了以下事情:

  1. 记录初始时间 t0.
  2. 调用原来的 factorial 函数,保存结果。
  3. 计算经过的时间。
  4. 格式化收集的数据,然后打印出来。
  5. 返回第 2 步保存的结果。

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

装饰器模式:动态地给一个对象添加一些额外的职责。

上面这个 clock 装饰器有几个缺点:不支持关键字参数,而且遮盖了被装饰函数的__name____doc__ 属性,我们可以使用functools.wraps 装饰器把相关的属性从 func 复制到 clocked 中。改进后的 clock 还能处理关键字参数。

# 改进后的clock装饰器# clockdeco2.pyimport timeimport functoolsdefclock(func):
    @functools.wraps(func)defclocked(*args,**kwargs):# 定义内部函数 clock,它接收任意个位置参数
        t0= time.perf_counter()
        result= func(*args,**kwargs)# 这行代码可用,是因为 clocked 的闭包中包含自由变量 func
        elapsed= time.perf_counter()- t0
        name= func.__name__
        arg_lst=[]if args:
            arg_lst.append(', '.join(repr(arg)for argin args))if kwargs:
            pairs=['%s=%r'%(k, w)for k, winsorted(kwargs.items())]
            arg_lst.append(', '.join(pairs))
        arg_str=', '.join(arg_lst)print('[%0.8fs] %s(%s) -> %r'%(elapsed, name, arg_str, result))#         print(f'[{elapsed:0.8f}] {name}({arg_str}) -> {result}')return result# 返回func调用结果return clocked# 返回内部函数,取代被装饰的函数

@clockdefsnooze(seconds):
    time.sleep(seconds)

@clockdeffactorial(n):return1if n<2else n*factorial(n-1)if __name__=='__main__':print('*'*40,'Calling snooze(.123)')
    snooze(.123)print('*'*40,'Calling fatorial(6)')print('6! =', factorial(6))print(factorial.__name__)
**************************************** Calling snooze(.123)
[0.12381920s] snooze(0.123) -> None
**************************************** Calling fatorial(6)
[0.00000070s] factorial(1) -> 1
[0.00059530s] factorial(2) -> 2
[0.00115270s] factorial(3) -> 6
[0.00162940s] factorial(4) -> 24
[0.00210760s] factorial(5) -> 120
[0.00260230s] factorial(6) -> 720
6! = 720
factorial

7.8 标准库中的装饰器

Python 内置了三个用于装饰方法的函数:property、classmethod 和 staticmethod。

functools.wraps 的作用是协助构建行为良好的装饰器。

另外标准库中最值得关注的两个装饰器是lru_cache 和全新的singledispatch (Python 3.4 新增)。这两个模块在 functools 模块中定义。

functools.lru_cach 是非常实用的装饰器,它实现了备忘功能(memoization)。这是一项优化技术,它把好事的函数结果保存,避免传入相同的参数时重复计算。LRU 是 Least Recently Used 的缩写,表明缓存不会无限制增长,一段时间不用的缓存条目会被扔掉。

生成第 n 个斐波那契数这种慢速递归函数适合使用 lru_cache,如:

# 生成第 n 个斐波那契数,递归方式非常耗时

@clockdeffibonacci(n):if n<2:return nreturn fibonacci(n-2)+ fibonacci(n-1)if __name__=='__main__':print(fibonacci(6))
[0.00000100s] fibonacci(0) -> 0
[0.00000150s] fibonacci(1) -> 1
[0.00044170s] fibonacci(2) -> 1
[0.00000090s] fibonacci(1) -> 1
[0.00000090s] fibonacci(0) -> 0
[0.00000110s] fibonacci(1) -> 1
[0.00010900s] fibonacci(2) -> 1
[0.00022870s] fibonacci(3) -> 2
[0.00080970s] fibonacci(4) -> 3
[0.00000110s] fibonacci(1) -> 1
[0.00000100s] fibonacci(0) -> 0
[0.00000120s] fibonacci(1) -> 1
[0.00012280s] fibonacci(2) -> 1
[0.00024600s] fibonacci(3) -> 2
[0.00000090s] fibonacci(0) -> 0
[0.00000110s] fibonacci(1) -> 1
[0.00012090s] fibonacci(2) -> 1
[0.00000070s] fibonacci(1) -> 1
[0.00000100s] fibonacci(0) -> 0
[0.00000120s] fibonacci(1) -> 1
[0.00012130s] fibonacci(2) -> 1
[0.00024270s] fibonacci(3) -> 2
[0.00048370s] fibonacci(4) -> 3
[0.00085390s] fibonacci(5) -> 5
[0.00198380s] fibonacci(6) -> 8
8

这里:fibonacci(1)调用了 8 次,fibonacci(2)调用了 5 次…但是,如果增加两行代码,使用 lru_cache,性能会显著改善。

# 使用缓存实现,速度更快import functools

@functools.lru_cache()# 注意,必须向常规函数一样调用lru_cache。因为lru_cache可以接收配置参数
@clockdeffibonacci(n):if n<2:return nreturn fibonacci(n-2)+ fibonacci(n-1)if __name__=='__main__':print(fibonacci(6))
[0.00000170s] fibonacci(0) -> 0
[0.00000140s] fibonacci(1) -> 1
[0.00023940s] fibonacci(2) -> 1
[0.00000250s] fibonacci(3) -> 2
[0.00038560s] fibonacci(4) -> 3
[0.00000260s] fibonacci(5) -> 5
[0.00056900s] fibonacci(6) -> 8
8

可以看到执行时间减半了,而且每个 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 装饰的函数,它的所有参数必须是可散列的。

Python 3.4 新增的 functools.singledispath 装饰器可以把整体方案拆分为多个模块,甚至可以为你无法修改的类提供专门的函数。使用 @singledispatch 装饰的普通函数会变成泛函数(generic function):根据第一个参数的类型,以不同方式执行相同操作的一组函数。

# sigledispatch 创建一个自定义的 htmlize.register 装饰器,把多个函数绑在一起组成一个泛函数from functoolsimport singledispatchfrom collectionsimport abcimport numbersimport html

@singledispatch# 标记处理 object 类型的基函数defhtmlize(obj):
    content= html.escape(repr(obj))return f'<pre>{content}</pre>'

@htmlize.register(str)# 各个专门函数使用 @《base_function》.register(《type》) 装饰def_(text):
    content= html.escape(text).replace('\n','<br>\n')return f'<pre>{content}</pre>'

@htmlize.register(numbers.Integral)def_(n):return'<pre>{0} (0x{0:x})</pre>'.format(n)#     return f'<pre>{n} (0x{n})</pre>'

@htmlize.register(tuple)
@htmlize.register(abc.MutableSequence)def_(seq):
    inner='</li>\n<li>'.join(htmlize(item)for itemin seq)return'<ul>\n<li>'+ inner+'</li>\n</ul>'

只要可能,注册的专门函数应该处理抽象基类。这样代码支持的兼容类型更广泛。
singledispatch 机制的一个相助特征是,你可以在系统的任何地方和任何模块中注册专门的函数。如果后面再新的模块定义了新的类型,可以轻松添加一个新的专门函数来处理那个类型。还可以为不是自己编写或者不能修改的类添加自定义函数。更多用法具体参看 PEP 443.

@singledispatch 的优点是支持模块化扩展:各个模块可以为它支持的各个类型专门注册一个函数。这不是为了把 Java 的方法重载带入 Python。

7.9 叠放装饰器

装饰器可以叠放使用。
把 @d1 和 @d2 按顺序应用到 f 函数上,作用相当于 f = d1(d2(f)))。
即:

@d1
@d2deff():print('f')

等价于

deff():print('f')

f= d1(d2(f))

7.10 参数化装饰器

Python 把被装饰的函数作为第一个参数传给装饰器函数。要实现让装饰器接收其他参数,可以创建一个装饰器工厂函数,把参数传给它,返回一个装饰器,然后再把它应当用到要装饰的函数上。

以最简单的

  • 作者:Jock2018
  • 原文链接:https://blog.csdn.net/qq_27283619/article/details/106407246
    更新时间:2022-09-16 14:29:46