Python深入-9-装饰器与闭包

函数装饰器允许在源码中“标记”函数,以某种方式增强函数的行为。这是一个强大的功能,但是如果想掌握,则必须理解闭包,即捕获函数主体外部定义的变量。
Python 3.0 引入的保留关键字nonlocal鲜为人知。作为 Python 程序员,如果严格遵守基于类的面向对象编程方式,那么即便不知道它的存在也不受影响。然而,如果想自己实现函数装饰器,则必须了解闭包的方方面面,因此也就需要掌握nonlocal
除了在装饰器中有用,闭包还是回调式编程和函数式编程风格的重要基础

一、装饰器基础知识

装饰器是一种可调用对象,其参数是另一个函数(被装饰的函数)。
装饰器可能会对被装饰的函数做些处理,然后返回函数,或者把函数替换成另一个函数或可调用对象。
也就是说,假如有一个名为 decorate 的装饰器:

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

那么上述代码的效果与下述写法一样。

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

target = decorate(target)

两种写法的最终结果一样:上述两个代码片段执行完毕后,target 名称都会绑定 decorate(target) 返回的函数——可能是原来那个名为 target 的函数,也可能是另一个函数。
为了确认被装饰的函数被替换了,请看如下代码

# 定义一个函数 deco 返回内部的函数对象 inner。
def deco(func):
    def inner():
        print('running inner()')

    return inner


#  使用 deco 装饰 target
@deco
def target():
    print('running target()')


# ❸ 调用被装饰的 target,运行的其实是 inner。
target()  # running inner()
# 查看对象,发现 target 现在是 inner 的引用
print(target)  # <function deco.<locals>.inner at 0x0000020ED853A840>

严格来说,装饰器只是语法糖。如前所述,装饰器可以像常规的可调用对象那样调用,传入另一个函数。有时,这样做其实更方便,尤其是做元编程(在运行时改变程序的行为)时。
综上所述,装饰器有以下 3 个基本性质。

  • 装饰器是一个函数或其他可调用对象。

  • 装饰器可以把被装饰的函数替换成别的函数。

  • 装饰器在加载模块时立即执行。

二、**Python **何时执行装饰器

装饰器的一个关键性质是,它们在被装饰的函数定义之后立即运行。这通常是在导入时(例如,当 Python加载模块时)。

# registry 保存被 @register 装饰的函数引用
registry = []


# register 的参数是一个函数。
def register(func):
    print(f'running register({func})')  # 为了演示,显示被装饰的函数。
    registry.append(func)  # 把 func 存入 registry。
    return func  # 返回 func:必须返回函数,这里返回的函数与通过参数传入的函数一样。


# 使用 @register 装饰 f1 和 f2。
@register
def f1():
    print('running f1()')


@register
def f2():
    print('running f2()')


# 没有装饰 f3。
def f3():
    print('running f3()')


# main 首先显示 registry,然后调用 f1()、f2() 和 f3()
def main():
    print('running main()')
    print('registry ->', registry)
    f1()
    f2()
    f3()


# 只有把 registration.py 当作脚本运行时才调用 main()
if __name__ == '__main__':
    main()

注意,register 在模块中其他函数之前运行(两次)。调用 register 时,传给它的参数是被装饰的函数,例如
加载模块后,registry 中有两个被装饰函数(f1 和 f2)的引用。这两个函数,以及 f3,只在 main 显式调用它们时才执行。

三、注册装饰器

装饰器函数与被装饰的函数在同一个模块中定义。实际情况是,装饰器通常在一个模块中定义,然后再应用到其他模块中的函数上。
register 装饰器返回的函数与通过参数传入的函数相同。实际上,大多数装饰器会在内部定义一个函数,然后将其返回。
不过,大多数装饰器会更改被装饰的函数。通常的做法是,返回在装饰器内部定义的函数,取代被装饰的函数。涉及内部函数的代码基本上离不开闭包。为了理解闭包,需要后退一步,先研究 Python 中的变量作用域规则。

四、变量作用域规则

下例定义并测试了一个函数,该函数会读取两个变量的值:一个是通过函数的参数传入的局部变量 a,另一个是函数没有定义的变量 b。

def f1(a):
    print(a)
    print(b)

# 会出现错误
print(f1(3))

出现错误并不奇怪。接着下例,如果先给全局变量 b 赋值,然后再调用 f1,则不会出错。

def f1(a):
    print(a)  # 3
    print(b)  # 6


b = 6
# 会出现错误
print(f1(3))  # None

五、闭包

 人们有时会把闭包和匿名函数弄混。这是有历史原因的:在函数内部定义函数以前并不常见, 也不容易,直到出现匿名函数。而且,**只有涉及嵌套函数时才有闭包问题**。因此,很多人是同时知道这两 个概念的。 

其实,闭包就是延伸了作用域的函数,包括函数(姑且叫 f 吧)主体中引用的非全局变量和局部变量。这 些变量必须来自包含 f 的外部函数的局部作用域。 函数是不是匿名的没有关系,关键是它能访问主体之外定义的非全局变量。  
假如有个名为avg的函数,它的作用是计算不断增加的系列值的平均值,例如,计算整个历史中某个商品 的平均收盘价。新价格每天都在增加,因此计算平均值时要考虑到目前为止的所有价格。 先来看 avg 函数的用法。

>>> avg(10) # 10.0
>>> avg(11) # 10.5

avg 从何而来,它又在哪里保存历史值呢?  
基于类的实现

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)

avg = Averager()
avg(10)

** 基于函数式实现  **

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 都会把参数添加到系列值中,然后计算当前平均值,

avg = make_averager()
avg(10)

注意,这两个示例有相似之处:调用 Averager() 或 make_averager() 得到一个可调用对象 avg,它 会更新历史值,然后计算当前平均值。在基于类的实现,avg 是 Averager 类的实例;在基于函数式实现中,avg 是内部函数 averager。
不管怎样,只需调用 avg(n),把 n 放入系列值中,然后重新计算平均值即可。 作为 Averager 类的实例,avg 在哪里存储历史值很明显:实例属性 self.series。
但是,第二个示例 中的 avg 函数在哪里寻找 series 呢? 注意,series 是 make_averager 函数的局部变量,因为赋值语句 series = [] 在 make_averager 函数的主体中。但是,调用 avg(10) 时,make_averager 函数已经返回,局部作用域早就“烟消云 散”了。  
    在 averager 函数中,series 是自由变量(free variable)。自由变量是一个术语,指未 在局部作用域中绑定的变量  

Python深入-9-装饰器与闭包
image.png


查看返回的 averager 对象,我们发现 Python 在 __code__ 属性(表示编译后的函数主体)中保存局部 变量和自由变量的名称


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

series 的值在返回的 avg 函数的 __closure__ 属性中。avg.__closure__中的各项对应 avg.__code__.co_freevars 中的一个名称。这些项是 cell 对象,有一个名为cell_contents 的属性,保存着真正的值。这些属性的值如下代码所示。

>>> avg.__code__.co_freevars
('series',)
>>> avg.__closure__
(<cell at 0x107a44f78: list object at 0x107a91a48>,)
>>> avg.__closure__[0].cell_contents
[101112]

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

六、 nonlocal 声明

一个计算累计平均值的高阶函数,不保存所有历史值,但有缺陷

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)
Traceback (most recent call last):
...
UnboundLocalError: local variable 'count' referenced before assignment
>>>

问题是,对于数值或任何不可变类型,count += 1 语句的作用其实与 count = count + 1 一样。因 此,实际上我们在 averager 的主体中为 count 赋值了,这会把 count 变成局部变量。total 变量也受这个问题影响。  
    但是,数值、字符串、元组等不可变类型只能读取,不能更新。如果像 count = count + 1 这样尝试重 新绑定,则会隐式创建局部变量 count。如此一来,count 就不是自由变量了,因此不会保存到闭包中。
为了解决这个问题,Python 3 引入了nonlocal 关键字。它的作用是把变量标记为自由变量即便在函数 中为变量赋予了新值如果为 nonlocal 声明的变量赋予新值,那么闭包中保存的绑定也会随之更新。最新版 make_averager 的正确实现如示例 9-13 所示。

def make_averager():
    count = 0
    total = 0

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

    return averager

学会使用 nonlocal 之后,接下来让我们总结一下 Python 查找变量的方式  
** 变量查找逻辑  **
Python 字节码编译器根据以下规则获取函数主体中出现的变量 x

  • 如果是global x声明,则 x 来自模块全局作用域,并赋予那个作用域中 x 的值。

  • 如果是nonlocal x声明,则 x 来自最近一个定义它的外层函数,并赋予那个函数中局部变量 x 的 值。

  • 如果 x 是参数,或者在函数主体中赋了值,那么 x 就是局部变量。

  • 如果引用了 x,但是没有赋值也不是参数,则遵循以下规则。

  • 在外层函数主体的局部作用域(非局部作用域)内查找 x。

  • 如果在外层作用域内未找到,则从模块全局作用域内读取。

  • 如果在模块全局作用域内未找到,则从builtins__.__dict中读取

七、 实现一个简单的装饰器

 定义了一个装饰器,该装饰器会在每次调用被装饰的函数时计时,把运行时间、传入的参数和调 用的结果打印出来  
import time

def clock(func):
    def clocked(*args): # 定义内部函数 clocked,它接受任意个位置参数。
        t0 = time.perf_counter()
        result = func(*args) # 这行代码行之有效,因为 clocked 的闭包中包含自由变量 func。
        elapsed = time.perf_counter() - t0
        name = func.__name__
        arg_str = ', '.join(repr(arg) for arg in args)
        print(f'[{elapsed:0.8f}s] {name}({arg_str}) -> {result!r}')
        return result
    return clocked # 返回内部函数,取代被装饰的函数。

使用 clock 装饰器

import time
from clockdeco0 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
def factorial(n):
    return 1 if n < 2 else n*factorial(n-1)

其实等价于以下内容。

def factorial(n):
    return 1 if n < 2 else n*factorial(n-1)

factorial = clock(factorial)

也就是说,在这两种情况下,factorial函数都作为func参数传给clock函数,clock 函数返回 clocked 函数,然后 Python 解释器把clocked赋值给factorial(前一种情况 是在背后赋值)。导入 clockdeco_demo 模块,查看 factorial__name__ 属性,会看到如下结果。

>>> import clockdeco_demo
>>> clockdeco_demo.factorial.__name__
'clocked'
>>>

可见,现在factorial保存的其实是clocked函数的引用。自此之后,每次调用factorial(n)执行的都是clocked(n)。clocked 大致做了下面几件事。

  • 1. 记录初始时间 t0。

  • 2. 调用原来的 factorial 函数,保存结果。

  • 3. 计算运行时间。

  • 4. 格式化收集的数据,然后打印出来。

  • 5. 返回第 2 步保存的结果。

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

上例子中的 clock 装饰器有几个缺点:不支持关键字参数,而且遮盖了被装饰函数的__name__属性和 __doc__ 属性。示例 9-16 使用functools.wraps装饰器把相关的属性从func身上复制到了clocked中。此外,这个新版还能正确处理关键字参数。

import time
import functools

def clock(func):
    @functools.wraps(func)
    def clocked(*args, **kwargs):
        t0 = time.perf_counter()
        result = func(*args, **kwargs)
        elapsed = time.perf_counter() - t0
        name = func.__name__
        arg_lst = [repr(arg) for arg in args]
        arg_lst.extend(f'{k}={v!r}' for k, v in kwargs.items())
        arg_str = ', '.join(arg_lst)
        print(f'[{elapsed:0.8f}s] {name}({arg_str}) -> {result!r}')
        return result
    return clocked

functools.wraps 只是标准库中开箱即用的装饰器之一

八、 标准库中的装饰器

Python 内置了 3 个用于装饰方法的函数:propertyclassmethodstaticmethod  
标准库中最吸引人的几个装饰器,即 cachelru_cachesingledispatch,均来自functools模 块。

8.1  使用 functools.cache 做备忘

functools.cache 装饰器实现了备忘(memoization)。 这是一项优化技术,能把耗时的函数得到的结 果保存起来,避免传入相同的参数时重复计算。

functools.cache 是 Python 3.9 新增的  
如果想使用 Python 3.8 运行本节的示例,请把 @cache 换成 @lru_cache
对于更早的 Python 版本,必须调用装饰器,写成 @lru_cache()

import functools
from clockdeco import clock

@functools.cache ❶
@clock #这里叠放了装饰器:@cache 应用到 @clock 返回的函数上
def fibonacci(n):
    if n < 2:
        return n
    return fibonacci(n - 2) + fibonacci(n - 1)

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

** 叠放装饰器  **
    如果想理解叠放装饰器,那么需要记住一点:@ 是一种语法糖,其作用是把装饰器函数应用到下方的 函数上。多个装饰器的行为就像调用嵌套函数一样。以下内容:

@alpha
@beta
def my_fn():
    ...

等同于以下内容

my_fn = alpha(beta(my_fn))

也就是说,首先应用 beta 装饰器,然后再把返回的函数传给 alpha。  
被装饰的函数所接受的参数必须可哈希,因为底层 lru_cache 使用 dict 存储结果,字典的键取自传入的 位置参数和关键字参数。

8.2  使用 lru_cache

functools.cache 装饰器只是对较旧的functools.lru_cache 函数的简单包装。其实,functools.lru_cache 更灵活,而且兼容 Python 3.8 及之前的版本。
@lru_cache 的主要优势是可以通过 maxsize 参数限制内存用量上限。maxsize 参数的默认值相当保 守,只有 128,即缓存最多只能有 128 条。
LRU 是“Least Recently Used”的首字母缩写,表示一段时间不用的缓存条目会被丢弃,为新条目腾出空间。 从 Python 3.8 开始,lru_cache 有两种使用方式。下面是最简单的方式。

@lru_cache
def costly_function(a, b):
    ...

另一种方式是从 Python 3.2 开始支持的加上 () 作为函数调用。

@lru_cache()
def costly_function(a, b):
    ...

两种用法都采用以下默认参数。
maxsize=128 设定最多可以存储多少条目。缓存满了之后,最不常用的条目会被丢弃,为新条目腾出空间。为了得 到最佳性能,应将 maxsize 设为 2 的次方。如果传入 maxsize=None,则 LRU 逻辑将被彻底禁用,因此 缓存速度更快,但是条目永远不会被丢弃,这可能会消耗过多内存。@functools.cache 就是如此。
typed=False 决定是否把不同参数类型得到的结果分开保存。例如,在默认设置下,被认为是值相等的浮点数参数 和整数参数只存储一次,即 f(1) 调用和 f(1.0) 调用只对应一个缓存条目。如果设为 typed=True,则 在不同的条目中存储可能不一样的结果。

@lru_cache(maxsize=2**20, typed=True)
def costly_function(a, b):
    ...

8.3  单分派泛化函数singledispatch

因为 Python 不支持 Java 那种方法重载。
functools.singledispatch 装饰器可以把整体方案拆分成多个模块,甚至可以为第三方包中无法编辑 的类型提供专门的函数。使用 @singledispatch 装饰的普通函数变成了泛化函数(generic function,指 根据第一个参数的类型,以不同方式执行相同操作的一组函数)的入口。这才称得上是单分派。如果根据 多个参数选择专门的函数,那就是多分派。

from functools import singledispatch
from collections import abc
import fractions
import decimal
import html
import numbers


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

@htmlize.register # 各个专门函数使用 @«base».register 装饰
    def _(text: str) -> str: # 运行时传入的第一个参数的类型决定何时使用这个函数。专门函数的名称无关紧要,_ 是一个不错的选择,简单明了
    content = html.escape(text).replace('n''<br/>n')
    return f'<p>{content}</p>'

@htmlize.register # 为每个需要特殊处理的类型注册一个函数,把第一个参数的类型提示设为相应的类型
    def _(seq: abc.Sequence) -> str:
    inner = '</li>n<li>'.join(htmlize(item) for item in seq)
    return '<ul>n<li>' + inner + '</li>n</ul>'

@htmlize.register # singledispatch 支持使用 numbers 包中的抽象基类
    def _(n: numbers.Integral) -> str:
    return f'<pre>{n} (0x{n:x})</pre>'

@htmlize.register # bool 是 numbers.Integral 的子类型,但是 singledispatch 逻辑会寻找与指定类型最匹配的实现,与实现在代码中出现的顺序无关
    def _(n: bool) -> str:
    return f'<pre>{n}</pre>'

@htmlize.register(fractions.Fraction) # 如果不想或者不能为被装饰的类型添加类型提示,则可以把类型传给 @«base».register 装饰器 Python 3.4 或以上版本支持这种句法
    def _(x) -> str:
    frac = fractions.Fraction(x)
    return f'<pre>{frac.numerator}/{frac.denominator}</pre>'

@htmlize.register(decimal.Decimal) # @«base».register 装饰器会返回装饰之前的函数,因此可以叠放多个 register 装饰器,让同一个实现支持两个或更多类型
@htmlize.register(float)
def _(x) -> str:
    frac = fractions.Fraction(x).limit_denominator()
    return f'<pre>{x} ({frac.numerator}/{frac.denominator})</pre>'

九、 参数化装饰器

 解析源码中的装饰器时,Python 会把被装饰的函数作为第一个参数传给装饰器函数。那么,如何让装饰器 接受其他参数呢?答案是创建一个装饰器工厂函数来接收那些参数,然后再返回一个装饰器,应用到被装 饰的函数上。是不是有点儿迷惑?肯定的。下面以我们目前见到的最简单的装饰器 register 为例说明  
registry = []

def register(func):
    print(f'running register({func})')
    registry.append(func)
    return func

@register
def f1():
    print('running f1()')
    print('running main()')
    print('registry ->', registry)
f1()

9.1  一个参数化注册装饰器

 为了便于启用或禁用 register 执行的函数注册功能,为它提供一个可选的 active 参数,当设为 False 时,不注册被装饰的函数。  
 从概念上看,这个新的 register 函数不是装饰 器,而是装饰器工厂函数。调用 register 函数才能返回应用到目标函数上的装饰器  
registry = set() # registry 现在是一个 set 对象,这样添加和删除函数的速度更快

def register(active=True): # register 接受一个可选的关键字参数
    def decorate(func): # 内部函数 decorate 是真正的装饰器。注意,它的参数是一个函数
        print('running register'
            f'(active={active})->decorate({func})')
        if active: # 只有 active 参数的值(从闭包中获取)是 True 时才注册 func
            registry.add(func)
        else:
            registry.discard(func) # 如果 active 不为 True,而且 func 在 registry 中,那就把它删除。
        return func # 因为 decorate 是装饰器,所以必须返回一个函数
    return decorate # ❼ register 是装饰器工厂函数,因此返回 decorate

@register(active=False) # @register 工厂函数必须作为函数调用,并且传入所需的参数
def f1():
    print('running f1()')

@register() # 即使不传入参数,register 也必须作为函数调用(@register()),返回真正的装饰器decorate
def f2():
    print('running f2()')

def f3():
print('running f3()')
 关键是,`register()`要返回 `decorate`。应用到被装饰的函数上的是`decorate`。  


原文始发于微信公众号(Python之家):Python深入-9-装饰器与闭包

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/198388.html

(0)
小半的头像小半

相关推荐

发表回复

登录后才能评论
极客之音——专业性很强的中文编程技术网站,欢迎收藏到浏览器,订阅我们!