第九章 函数装饰器和闭包

有很多人抱怨,把这个特性命名为“装饰器”不好。其中一个主要原因是名称与GoF书中的用法不一致。装饰器这个名称可能更多地归功于它在编译器领域的使用——因为它会遍历和注解语法树。 

                                                        ------PEP 318 — Decorators for Functions and Methods

函数装饰器让我们在源代码中“标记”函数,以某种方式增强它们的行为。这是一个强大的功能,但掌握它需要理解闭包——当函数捕获定义在其主体之外的变量时,就会发生这种情况。

Python 中最晦涩的保留关键字是 nonlocal,在 Python 3.0 中引入。作为一名python程序员,如果你严格遵守基于以类为中心的面向对象编程方式,即使不知道这个关键字也可以不受影响。但是,如果要实现自己的函数装饰器,则必须了解闭包,然后对 nonlocal 的需求就变得显而易见了。

除了它们在装饰器中的应用之外,闭包对于使用回调的任何类型的编程以及在有意义的情况下以函数式风格进行编码也是必不可少的。

本章的最终目标是准确解释函数装饰器的工作原理,从最简单的注册装饰器到更复杂的参数化装饰器。但是,在我们实现该目标之前,我们需要涵盖:

  • Python 如何执行装饰器语法
  • Python 如何判断一个变量是否是局部变量
  • 为什么需要闭包的存在以及它们是如何工作的
  • nonlocal解决什么问题

有了这个基础,我们可以处理更多的装饰器主题:

  • 实现一个行为良好的装饰器
  • 标准库中强大的装饰器:@cache、@lru_cache 和@singledispatch
  • 实现一个参数化的装饰器

本章的新内容

缓存装饰器 functools.cache(Python 3.9 中的新功能)比传统的 functools.lru_cache 更简单,所以我先介绍它。后者在“Using lru_cache”中有介绍,包括 Python 3.8 中添加的简化形式。

“Single Dispatch Generic Functions”部分已扩展,现在使用类型提示,首选方式是使用 Python 3.7 以引入的 functools.singledispatch 。

“Parameterized Decorators”现在包含一个基于类的示例,如示例 9-27所示。

我将第 10 章 — 使用一等函数的设计模式 — 移至第三部分的末尾,以改进本书的流程。“Decorator-Enhanced Strategy Pattern” 部分现在在该章节中,以及使用可调用对象的策略设计模式的其他变体。

我们从对装饰器的非常易懂的介绍开始,然后继续在开篇章节中列出的其余话题。

装饰器 101

装饰器是一个可调用的,它接受另一个函数作为参数(被装饰的函数)。

装饰器可以对被装饰的函数执行一些处理,并返回它或用另一个函数或可调用对象替换它

换句话说,假设现有的装饰器名为decorate,这段代码:

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

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

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

target = decorate(target)

最终结果是相同的:在这两个片段的末尾,目标名称绑定到decorate(target) 返回的任何函数——它可能是最初命名为target 的函数,也可能是不同的函数。

要确认装饰函数已被替换,请参阅示例 9-1 中的控制台会话。

例 9-1。装饰器通常将函数替换为另一个函数

>>> def deco(func):
...     def inner():
...         print('running inner()')
...     return inner  1
...
>>> @deco
... def target():  2
...     print('running target()')
...
>>> target()  3
running inner()
>>> target  4
<function deco.<locals>.inner at 0x10063b598>
  1. deco返回inner函数对象
  2. 使用deco装饰target
  3. 调用被装饰的target其实会运行inner
  4. 审查显示target是inner的引用

严格来说,装饰器只是语法糖。正如我们刚刚看到的,你总是可以像任何常规调用一样简单地调用装饰器,传递另一个函数作为参数。有时这样做很方便,尤其是在进行元编程时——在运行时改变程序行为。

三个基本事实很好地总结了装饰器:

  1. 装饰器是一个函数或另一个可调用的对象。
  2. 装饰器可以用不同的函数替换被装饰的函数。
  3. 装饰器在加载模块时立即执行。

现在让我们关注第三点。

Python何时执行装饰器

装饰器的一个关键特性是它们在被装饰的函数定义后立即运行。这通常是在导入时(即,当模块被 Python 加载时)。考虑示例 9-2 中的 registration.py。

示例9-2:registration.py模块

registry = []  1

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

@register  6
def f1():
    print('running f1()')

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

def f3():  7
    print('running f3()')

def main():  8
    print('running main()')
    print('registry ->', registry)
    f1()
    f2()
    f3()

if __name__ == '__main__':
    main()  9
  1. registry保存被@register装饰的函数引用
  2. register的参数是一个函数
  3. 为了演示,显示被装饰的函数
  4. 把func存入registry中
  5. 返回func:必须返回函数;这里返回的函数和参数传入的函数一样
  6. f1和f2被@register装饰
  7. f3没有被装饰
  8. main打印registry,然后调用f1,f2,f3
  9. 只有把registration.py当作脚本运行时才调用main()

作为脚本运行 registration.py 的输出如下所示:

$ python3 registration.py
running register(<function f1 at 0x100631bf8>)
running register(<function f2 at 0x100631c80>)
running main()
registry -> [<function f1 at 0x100631bf8>, <function f2 at 0x100631c80>]
running f1()
running f2()
running f3()

请注意, register 在模块中的任何其他函数之前运行(两次)。当 register 被调用时,它接收装饰的函数对象作为参数——例如,<function f1 at 0x100631bf8>。

加载模块后,registry列表保存对两个修饰函数的引用:f1 和 f2。这些函数以及 f3 仅在 main 显式调用时才执行。

如果registration.py 被导入(而不是作为脚本运行),输出是这样的:

>>> import registration
running register(<function f1 at 0x10063b1e0>)
running register(<function f2 at 0x10063b268>)

此时,如果您检查registry,您会看到以下内容:

>>> registration.registry
[<function f1 at 0x10063b1e0>, <function f2 at 0x10063b268>]

示例 9-2 的要点是强调函数装饰器在模块导入后立即执行,但被装饰的函数仅在显式调用时运行。

这突出了 Pythonistas 所说的导入时和运行时之间的区别。

注册装饰器

考虑到实际代码中通常如何使用装饰器,示例 9-2 有两个独特的地方:

  • 装饰器函数与装饰函数定义在相同的模块中。真正的装饰器通常定义在一个模块中并应用于其他模块中的函数。
  • register装饰器返回作为参数传递的相同函数。在实践中,大多数装饰器定义了一个内部函数并返回它。

即使示例 9-2 中的 register 装饰器返回被装饰的函数不变,该技术也不是无用的。许多 Python 框架中都使用了类似的装饰器来向某个中央注册表添加函数——例如,将 URL 模式映射到生成 HTTP 响应的函数的注册表。这样的注册装饰器可能会也可能不会改变被装饰的函数。

我们将看到在“装饰器增强策略模式”(第 10 章)中应用的注册装饰器。

大多数装饰器确实改变了被装饰的函数。他们通常通过定义一个内部函数并返回它来替换装饰函数来实现。使用内部函数的代码几乎总是依赖于闭包才能正确运行。要理解闭包,我们需要退后一步,回顾一下 Python 中变量作用域的工作原理。

变量作用域规则

在示例 9-3 中,我们定义并测试了一个读取两个变量的函数:一个局部变量 a——定义为函数参数——和变量 b 没有在函数的任何地方定义。

例 9-3。读取局部变量和全局变量的函数

>>> 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: global name 'b' is not defined

我们得到的错误并不奇怪。继续示例 9-3,如果我们为全局 变量b 分配一个值,然后调用 f1,就可以正常运行

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

现在,让我们看一个可能会让您大吃一惊的例子。

查看示例 9-4 中的 f2 函数。它的前两行与示例 9-3 中的 f1 相同,然后对 b 进行赋值。但是在赋值之前,它在第二次print时失败了。

例 9-4。变量 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,而对局部变量b的赋值是在print(b)之后进行的。

但事实是,当 Python 编译函数体时,它决定 b 是局部变量,因为它是在函数内赋值的。生成的字节码反映了这一决定,并将尝试从本地范围获取 b。稍后,当调用 f2(3) 时,f2 的主体获取并打印局部变量 a 的值,但在尝试获取局部变量 b 的值时,发现 b 未绑定。

这不是错误,而是设计选择:Python 不要求您声明变量,而是假定在函数体中进行赋值的变量是局部变量。这比 JavaScript 的行为要好得多,JavaScript 也不需要变量声明,但是如果你忘记声明一个变量是局部的(使用 var),你可能会在不知道的情况下破坏一个全局变量。

如果我们希望解释器将 b 视为一个全局变量,并且仍然在函数内为其分配一个新值,我们需要使用全局变量global声明:

>>> b = 6
>>> def f3(a):
...     global b
...     print(a)
...     print(b)
...     b = 9
...
>>> f3(3)
3
6
>>> b
9

在上面的例子中,我们可以看到两个作用域:

  1. 模块全局范围,由在任何类或功能块之外进行赋值的名称组成。
  2. 函数局部作用域,由作为参数或直接在函数体中赋值的名称组成。

变量可以来自另一个作用域,我们称之为 nonlocal 并且是 Closures 的基础;我们稍后会看到。

在仔细研究 Python 中变量作用域的工作原理之后,我们可以在下一节“闭包”中处理闭包问题。如果您对示例 9-3 和 9-4 中的函数之间的字节码差异感到好奇,请参阅以下边栏。


比较字节码:

dis 模块提供了一种简单的方法来反汇编 Python 函数的字节码。阅读示例 9-5 和 9-6 以查看示例 9-3 和 9-4 中 f1 和 f2 的字节码。

例 9-5。从示例 9-3 中反汇编 f1 函数

>>> from dis import dis
>>> dis(f1)
  2           0 LOAD_GLOBAL              0 (print)  1
              3 LOAD_FAST                0 (a)  2
              6 CALL_FUNCTION            1 (1 positional, 0 keyword pair)
              9 POP_TOP

  3          10 LOAD_GLOBAL              0 (print)
             13 LOAD_GLOBAL              1 (b)  3
             16 CALL_FUNCTION            1 (1 positional, 0 keyword pair)
             19 POP_TOP
             20 LOAD_CONST               0 (None)
             23 RETURN_VALUE
  1. 加载全局名称print
  2. 加载本地名称a
  3. 加载全局名称b

将示例 9-5 中 f1 的字节码与示例 9-6 中 f2 的字节码进行对比。

例 9-6。从示例 9-4 中反汇编 f2 函数

>>> dis(f2)
  2           0 LOAD_GLOBAL              0 (print)
              3 LOAD_FAST                0 (a)
              6 CALL_FUNCTION            1 (1 positional, 0 keyword pair)
              9 POP_TOP

  3          10 LOAD_GLOBAL              0 (print)
             13 LOAD_FAST                1 (b)  1
             16 CALL_FUNCTION            1 (1 positional, 0 keyword pair)
             19 POP_TOP

  4          20 LOAD_CONST               1 (9)
             23 STORE_FAST               1 (b)
             26 LOAD_CONST               0 (None)
             29 RETURN_VALUE
  1. 加载本地名称 b.这表明编译器将 b 视为局部变量,即使对 b 的赋值稍后发生;因为变量的性质(不管是否为局部变量)在函数体中无法更改。

运行字节码的 CPython VM 是一个堆栈机器,所以 LOAD 和 POP 操作都是指堆栈。进一步描述 Python 操作码超出了本书的范围,但它们与 dis 模块一起记录在 dis — Disassembler for Python bytecode

闭包

在博客圈中,闭包有时会与匿名函数混淆。许多人因为这些特性的平行历史而混淆它们:在函数内部定义函数并不那么常见或方便,直到开始使用匿名函数才这样做。只有在使用嵌套函数的时候才会涉及到闭包。所以很多人同时学习这两个概念。

实际上,闭包是一个函数——我们称之为 f——它具有一个延伸的作用域,它包含在 f 的主体中引用的变量,这些变量既不是 f 的全局变量也不是 f 的局部变量。这些变量必须来自包含 f 的外部函数的局部作用域中。

函数是否匿名并不重要;重要的是它可以访问在其主体之外定义的非全局变量。

这是一个很难掌握的概念,最好通过一个例子来理解。

考虑一个 avg 函数来计算不断增长的一系列值的平均值;例如,一种商品在其整个历史上的平均收盘价。每天都会添加一个新价格,并在考虑到目前所有价格的情况下计算平均值。

从一个明确的例子开始,这就是 avg 的使用方式:

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

avg 来自哪里,它在哪里保留以前值的历史记录?

首先,示例 9-7 是一个基于类的实现。

例 9-7。 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)

Averager 类创建可调用的实例:

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

现在,示例 9-8 是使用高阶函数 make_averager 的函数式实现。

例 9-8。 average.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时,它都会将传递的参数附加到series中,并计算当前的平均值,如例 9-9 所示。

例 9-9。测试示例 9-8

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

请注意示例的相似之处:我们调用 Averager() 或 make_averager() 来获取一个可调用对象 avg,它将更新历史序列并计算当前平均值。在示例 9-7中,avg 是 Averager 的一个实例,示例 9-8 中它是内部函数averager。无论哪种方式,我们只需调用 avg(n) 将 n 添加到series中并获得更新的平均值。

很明显,Averager 类的 avg 保留了历史记录:self.series 实例属性。但是第二个示例中的 avg 函数在哪里找到series?

请注意, series 是 make_averager 的局部变量,因为赋值 series = [] 发生在该函数的主体中。但是当avg(10)被调用的时候,make_averager已经返回了,它的局部作用域早就没有了。在averager中,series是一个自由变量。这是一个技术术语,意思是不受局部作用域约束的变量。请参见图 9-1。

检查返回的averager对象显示 Python 如何在 __code__ 属性中保留局部变量和自由变量的名称,该属性代表函数的编译体。如示例 9-10所示 。 

例 9-10。检查示例 9-8 中由 make_averager 创建的函数

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

series 的值保存在返回函数 avg 的 __closure__ 属性中。avg.__closure__ 中的每一项都对应于 avg.__code__.co_freevars 中的一个名称。这些项构成了cells,它们有一个名为 cell_contents 的属性,可以在其中找到实际值。示例 9-11 显示了这些属性。

例 9-11。继续示例 9-9

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

总结一下:闭包是一个函数,它保留定义函数时存在的自由变量的绑定,以便稍后在调用函数并且定义范围不再可用时使用它们。

请注意,函数可能需要处理外部的非全局作用域变量的唯一情况是当它嵌套在另一个函数中并且这些变量是外部函数的局部作用域的一部分时。

nonlocal声明

我们之前的 make_averager 实现效率不高。在示例 9-8 中,我们存储了历史序列中的所有值,并在每次调用averager时计算它们的总和。更好的实现将只存储到目前为止的项目总数和数量,并根据这两个数字计算平均值。

示例 9-12 是一个有缺陷的实现,只是为了说明一点。你能看到它在哪里有问题吗?

例 9-12。一个有缺陷的高阶函数,用于计算运行平均值而不保留所有历史记录

def make_averager():
    count = 0
    total = 0

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

    return averager

如果您尝试示例 9-12,您会得到以下结果:

>>> avg = make_averager()
>>> avg(10)
Traceback (most recent call last):
  ...
UnboundLocalError: local variable 'count' referenced before assignment
>>>

问题在于,当 count 是数字或任何不可变类型时,语句 count += 1 实际上与 count = count + 1 的含义相同。所以我们实际上是在averager的主体中赋值给count,这使它成为一个局部变量。同样的问题也会影响total变量。

我们在示例 9-8 中没有这个问题,因为我们从未给series赋值;我们只调用了 series.append 并在其上调用了 sum 和 len。所以我们利用了列表是可变的这一事实。

但是对于像数字、字符串、元组等不可变类型,您所能做的就是读取,永远不要更新。如果您尝试重新绑定它们,例如 count = count + 1,那么您就隐式地创建了一个局部变量 count。它不再是一个自由变量,因此它不会保存在闭包中。

为了解决这个问题,Python 3 中引入了 nonlocal 关键字。它允许您将变量声明为自由变量,即使它是在函数内分配的。如果将新值赋值给nonlocal变量,则存储在闭包中的绑定会更改。我们最新的 make_averager 的正确实现类似于例 9-13。

我们最新的 make_averager 的正确实现类似于例 9-13。

例 9-13。在不保留所有历史记录的情况下计算运行平均值(使用 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

研究了nonlocal的使用之后,总结一下Python的变量查找是如何工作的。

Python 字节码编译器根据以下规则确定定义函数时如何获取出现在其中的变量 x:

  • 如果存在global x 声明,则 x 来自模块并赋值给 x 的全局变量。
  • 如果存在nonlocal x 声明,则 x 来自并赋值给定义 x 的最近的外部函数体中的 x 局部变量。
  • 如果 x 是参数或在函数体中被赋值,则 x 是局部变量。
  • 如果 x 被引用但未赋值且不是参数:
    • x 将在外部函数体的局部作用域(也就是nonlocal域)中查找;
    • 如果在周围作用域中没有找到,则从模块全局作用域中读取;
    • 如果在全局范围内没有找到,将从 __builtins__.__dict__ 中读取。

现在我们已经涵盖了 Python 闭包,我们可以使用嵌套函数有效地实现装饰器。

实现一个简单的装饰器

示例 9-14 是一个装饰器,它记录被装饰函数的每次调用并显示经过的时间、传递的参数和调用的结果。

例 9-14。 clockdeco0.py:显示函数运行时间的简单装饰器

import time


def clock(func):
    def clocked(*args):  1
        t0 = time.perf_counter()
        result = func(*args)  2
        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  3
  1. 定义内部函数clocked,它接受任意个定位参数
  2. 这段代码可用,因为clocked闭包中包含自由变量func
  3. 返回内部函数,取代被装饰的函数

示例 9-15 演示了clock装饰器的使用。

例 9-15。使用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))

运行示例 9-15 的输出如下所示:

$ python3 clockdeco_demo.py
**************************************** Calling snooze(.123)
[0.12363791s] snooze(0.123) -> None
**************************************** Calling factorial(6)
[0.00000095s] factorial(1) -> 1
[0.00002408s] factorial(2) -> 2
[0.00003934s] factorial(3) -> 6
[0.00005221s] factorial(4) -> 24
[0.00006390s] factorial(5) -> 120
[0.00008297s] factorial(6) -> 720
6! = 720

工作原理

记得吗,如下代码:

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

因此,在这两个示例中,clock 都将factorial函数作为其 func 参数(参见示例 9-14)。然后它创建并返回clocked函数,Python 解释器将其分配给factoral(在第一个示例中的幕后)。事实上,如果你导入clockdeco_demo模块并检查factorial的__name__,你会得到这样的结果:

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

所以阶乘现在实际上持有对clocked函数的引用。从现在开始,每次调用 factorial(n) 时,都会执行 clocked(n)。本质上,clocked 执行以下操作:

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

这是装饰器的典型行为:它用接受相同参数的新函数替换装饰函数,并且(通常)返回装饰函数应该返回的任何内容,同时还进行一些额外的处理。

TIP:

在 Gamma 等人的设计模式中,对装饰器模式的简短描述以“动态地为对象附加额外职责”开头.函数装饰器符合该描述。但是在实现级别,Python 装饰器与原始设计模式工作中描述的经典装饰器几乎没有相似之处。 “Soapbox”有更多关于这个主题的内容。

示例 9-14 中实现的clock装饰器有一些缺点:它不支持关键字参数,并且掩盖了装饰函数的 __name__ 和 __doc__。示例 9-16 使用 functools.wraps 装饰器将相关属性从 func 复制到clocked。此外,在这个新版本中,正确处理了关键字参数。

例 9-16。 clockdeco.py:改进的clock装饰器

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 只是标准库中现成的装饰器之一。在下一节中,我们将遇到 functools 提供的最令人印象深刻的装饰器:cache.

标准库中的装饰器

Python 具有三个用于装饰方法的内置函数:property、classmethod 和 staticmethod。我们将在“Using a Property for Attribute Validation” 中讨论属性,并在“classmethod Versus staticmethod”中讨论其他属性。

 在示例 9-16 中,我们看到了另一个重要的装饰器:functools.wraps,它是构建行为良好的装饰器的助手。标准库中一些最有趣的装饰器是 cache、lru_cache 和 singledispatch——它们都来自 functools 模块。我们接下来会介绍它们。

使用 functools.cache 进行记忆

functools.cache 装饰器实现了记忆功能: 一种优化技术,它通过保存先前调用昂贵函数的结果来工作,避免对先前使用的参数进行重复计算。

tip:

functools.cache 是在 Python 3.9 中添加的。如果您需要在 Python 3.8 中运行这些示例,请将 @cache 替换为 @lru_cache。对于以前版本的 Python,您必须调用装饰器,编写 @lru_cache(),如“使用 lru_cache”中所述.

一个很好的演示是将 @cache 应用到令人痛苦的缓慢递归函数以生成斐波那契数列中的第 n 个数字,如示例 9-17 所示。

例 9-17。计算斐波那契数列中第 n 个数字的非常昂贵的递归方法

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装饰器生成:

$ python3 fibo_demo.py
[0.00000042s] fibonacci(0) -> 0
[0.00000049s] fibonacci(1) -> 1
[0.00006115s] fibonacci(2) -> 1
[0.00000031s] fibonacci(1) -> 1
[0.00000035s] fibonacci(0) -> 0
[0.00000030s] fibonacci(1) -> 1
[0.00001084s] fibonacci(2) -> 1
[0.00002074s] fibonacci(3) -> 2
[0.00009189s] fibonacci(4) -> 3
[0.00000029s] fibonacci(1) -> 1
[0.00000027s] fibonacci(0) -> 0
[0.00000029s] fibonacci(1) -> 1
[0.00000959s] fibonacci(2) -> 1
[0.00001905s] fibonacci(3) -> 2
[0.00000026s] fibonacci(0) -> 0
[0.00000029s] fibonacci(1) -> 1
[0.00000997s] fibonacci(2) -> 1
[0.00000028s] fibonacci(1) -> 1
[0.00000030s] fibonacci(0) -> 0
[0.00000031s] fibonacci(1) -> 1
[0.00001019s] fibonacci(2) -> 1
[0.00001967s] fibonacci(3) -> 2
[0.00003876s] fibonacci(4) -> 3
[0.00006670s] fibonacci(5) -> 5
[0.00016852s] fibonacci(6) -> 8
8

浪费是显而易见的:fibonacci(1) 被调用了八次,fibonacci(2) 被调用了五次,等等。但是只添加了两行来使用缓存,性能得到了很大的提升。请参见示例 9-18。

例 9-18。使用缓存更快的实现

import functools

from clockdeco import clock


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


if __name__ == '__main__':
    print(fibonacci(6))
  1. 此行适用于 Python 3.9 或更高版本。有关支持 Python 早期版本的替代方法,请参阅“使用 lru_cache”。
  2. 这是堆叠装饰器的示例:@cache 应用于@clock 返回的函数。


堆叠装饰器

为了理解堆叠的装饰器,回想一下 @ 是将装饰器函数应用到它下面的函数的语法糖。如果有多个装饰器,它们的行为就像嵌套函数调用。像这样:

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

其实等价于

my_fn = alpha(beta(my_my))

换句话说,首先应用 beta 装饰器,然后将它返回的函数传递给 alpha。


使用示例 9-18 中的缓存,对于 n 的每个值仅调用一次 fibonacci 函数:

$ python3 fibo_demo_lru.py
[0.00000043s] fibonacci(0) -> 0
[0.00000054s] fibonacci(1) -> 1
[0.00006179s] fibonacci(2) -> 1
[0.00000070s] fibonacci(3) -> 2
[0.00007366s] fibonacci(4) -> 3
[0.00000057s] fibonacci(5) -> 5
[0.00008479s] fibonacci(6) -> 8
8

在另一个测试中,为了计算 fibonacci(30),示例 9-18 在 0.00017 秒内进行了所需的 31 次调用——总时间——而未缓存的示例 9-17 在英特尔酷睿 i7 笔记本电脑上花费了 12.09 秒,因为它调用了 fibonacci(1) 832,040 次,总共调用了 2,692,537 次。

被装饰函数的所有参数都必须是可散列的,因为底层的 lru_cache 使用一个 dict 来存储结果,并且键是由调用所使用的位置参数和关键字参数构成的。

除了使愚蠢的递归算法效率提升之外,@cache 在需要从远程 API 获取信息的应用程序中确实大放异彩。

Warning

如果有大量缓存条目,functools.cache 可以消耗所有可用内存。我认为它更适合在短生命周期命令行脚本中使用。在长时间运行的进程中,我建议使用 functools.lru_cache 和合适的 maxsize 参数,如下一节所述。

使用 lru_cache

functools.cache 装饰器实际上是对旧的 functools.lru_cache 函数的一个简单包装器,后者更灵活并且与 Python 3.8 及更早版本兼容。

@lru_cache 的主要优点是它的内存使用受到 maxsize 参数的限制,该参数有一个相当保守的默认值 128——这意味着缓存在任何时候最多可以保存 128 个条目。

首字母缩略词 LRU 代表最近最少使用,这意味着一段时间未读取的旧条目将被丢弃,以便为新条目腾出空间。

由于 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 的示例:

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

现在让我们研究另一个强大的装饰器:functools.singledispatch。

单分派泛函数(Single Dispatch Generic Functions)

想象一下,我们正在创建一个工具来调试 Web 应用程序。我们想要为不同类型的 Python 对象生成 HTML 显示。

我们可以从这样的函数开始:

import html

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

 这适用于任何 Python 类型,但现在我们想要扩展它以生成某些类型的自定义显示。一些例子:

  • str:用 '<br/>\n' 替换内部的换行符,并使用 <p> 标签代替 <pre>。
  • int:以十进制和十六进制显示数字(bool 的特殊情况)。
  • list:输出一个 HTML 列表,根据其类型对每个项目进行格式化。
  • float 和 Decimal:像往常一样输出值,也以分数的形式输出(为什么不呢?)。

我们想要的函数的行为如示例 9-19 所示。

例 9-19。 htmlize() 生成适合不同对象类型的 HTML

>>> htmlize({1, 2, 3})  1
'<pre>{1, 2, 3}</pre>'
>>> htmlize(abs)
'<pre>&lt;built-in function abs&gt;</pre>'
>>> htmlize('Heimlich & Co.\n- a game')  2
'<p>Heimlich &amp; 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>
>>> htmlize(True)  5
'<pre>True</pre>'
>>> htmlize(fractions.Fraction(2, 3))  6
'<pre>2/3</pre>'
>>> htmlize(2/3)   7
'<pre>0.6666666666666666 (2/3)</pre>'
>>> htmlize(decimal.Decimal('0.02380952'))
'<pre>0.02380952 (1/42)</pre>'
  1. 原始函数的函数参数注册为object,因此它可以处理与其他实现不匹配的参数类型。
  2. str 对象也是 HTML 转义的,但包含在 <p></p> 中,并在每个 '\n' 之前插入 <br/> 换行符。
  3. int 以十进制和十六进制显示,位于 <pre></pre> 内。
  4. 每个列表项都根据其类型进行格式化,并将整个序列呈现为 HTML 列表。
  5. 尽管 bool 是 int 子类型,但它得到了特殊处理。
  6. 将Fraction显示为分数
  7. 将float和decimal的显示加上分数的等价值。

函数singledispatch

因为在 Python 中没有 Java 风格的方法重载,所以我们不能简单地为我们想要以不同方式处理的每种数据类型创建具有不同签名的 htmlize 变体。Python 中一个可能的解决方案是将 htmlize 转换为一个分派函数,使用 if/elif/... 或 match/case/... 调用链调用专门的函数,如 htmlize_str、htmlize_int 等。这对于使用模块的用户来说是不可扩展的,而且很笨重:随着时间的推移,htmlize 分派程序会变得非常冗长,并且它与特定的方法之间的耦合会非常紧密。

functools.singledispatch 装饰器允许多个模块来实现整体解决方案,并允许为无法修改的第三方包的类型提供专门的功能。如果用@singledispatch 装饰一个普通函数,它就会成为泛函数的入口点:一组函数以不同的方式执行相同的操作,这取决于第一个参数的类型。这就是术语单次调度的含义。如果使用更多参数来选择特定函数,我们就使用多分派。示例 9-20 展示了如何操作。

Warning:

functools.singledispatch 自 Python 3.4 起就存在,但它自 Python 3.7 起仅支持类型提示。示例 9-20 中的最后两个函数说明了自 3.4 以来适用于所有 Python 版本的语法。

例 9-20。 @singledispatch 创建一个自定义的 @htmlize.register 将几个函数绑定成一个泛函数

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

@singledispatch  1
def htmlize(obj: object) -> str:
    content = html.escape(repr(obj))
    return f'<pre>{content}</pre>'

@htmlize.register  2
def _(text: str) -> str:  3
    content = html.escape(text).replace('\n', '<br/>\n')
    return f'<p>{content}</p>'

@htmlize.register  4
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  5
def _(n: numbers.Integral) -> str:
    return f'<pre>{n} (0x{n:x})</pre>'

@htmlize.register  6
def _(n: bool) -> str:
    return f'<pre>{n}</pre>'

@htmlize.register(fractions.Fraction)  7
def _(x) -> str:
    frac = fractions.Fraction(x)
    return f'<pre>{frac.numerator}/{frac.denominator}</pre>'

@htmlize.register(decimal.Decimal)  8
@htmlize.register(float)
def _(x) -> str:
    frac = fractions.Fraction(x).limit_denominator()
    return f'<pre>{x} ({frac.numerator}/{frac.denominator})</pre>'
  1. @singledispatch 标记了处理object类型的base函数。
  2. 每个专门的函数都用@«base».register 修饰

  3. 在运行时给出的第一个参数的类型决定了何时使用这个特定的函数定义。函数的名称无关紧要; 使用_ 命名这个特定函数可以明确这一点。

  4. 对于每个要获得特殊处理的额外的类型,注册一个第一个参数匹配类型提示的新函数。

  5. numbers ABC 可用于 singledispatch

  6. bool 是 numbers.Integral 的子类型,但singledispatch逻辑会寻找具有最具体匹配类型的实现,而不管它们在代码中出现的顺序。

  7. 如果您不想或不能向装饰函数添加类型提示,您可以将类型传递给 @«base».register 装饰器。此语法适用于 Python 3.4 或更高版本。

  8. @«base».register 装饰器返回未装饰的函数,因此可以将它们堆叠起来以在同一实现上注册两个或多个类型。

如果可能,注册特定函数来处理 ABC(抽象类),例如 numbers.Integral 和 abc.MutableSequence,而不是像 int 和 list 这样的具体实现。这允许您的代码支持更多种类的兼容类型。例如,Python 扩展可以提供具有固定位长的 int 类型的替代方案作为numbers.Integral的子类。

TIP

使用带有@singledispatch的 ABCs 或 typing.Protocol   允许您的代码支持现有或未来的类,这些类是这些 ABCs 的实际或虚拟子类,或者实现这些协议。ABC 的使用和虚拟子类的概念是第 13 章的主题。

singledispatch 机制的一个显着特点是您可以在系统的任何位置、任何模块中注册特定的函数。如果稍后添加具有新的用户定义类型的模块,则可以轻松提供新的自定义函数来处理该类型。并且您可以为您没有编写且无法更改的类编写自定义函数。

singledispatch 是标准库的一个经过深思熟虑的补充,它提供的功能比我在这里描述的要多。PEP 443 — Single-dispatch generic functions 是不错的参考--但它没有提到类型提示的使用,类型提示是后来添加的。functools 模块文档在singledispatch条目中通过几个示例进行了改进和更新。

Note

@singledispatch 并非旨在为 Python 带来 Java 风格的方法重载。具有一个方法的许多重载变体的单个类比具有冗长的 if/elif/elif/elif 块的单个函数要好。但是这两种解决方案都有缺陷,因为它们将过多的责任集中在一个单一的代码单元——类或函数中。@singledispatch 的优点是支持模块化扩展:每个模块可以为它支持的每种类型注册一个专门的函数。在实际的使用中,您不会像示例 9-20 那样在同一个模块中拥有泛型函数的所有实现。

我们已经看到一些带参数的装饰器,例如,@singledispatch 在示例 9-20 中创建的 @lru_cache() 和 htmlize.register(float)。下一节将展示如何构建接受参数的装饰器。

参数化装饰器

 当解析源代码中的装饰器时,Python 接受被装饰的函数并将其作为第一个参数传递给装饰器函数。那么如何让装饰器接受其他参数呢?答案是:创建一个装饰器工厂,它接受这些参数并返回一个装饰器,然后将其应用于要装饰的函数。令人困惑?当然。当然。让我们从一个基于我们见过的最简单装饰器的示例开始:在示例 9-21 中的register。

例 9-21。示例 9-2 中删节的 registration.py 模块,为方便起见在此在展示一次

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

参数化注册装饰器

为了方便启用或禁用 register 执行的函数注册,我们将使其接受一个可选的 active 参数,如果为 False,则跳过注册装饰函数。示例 9-22 展示了如何做。从概念上讲,新的 register 函数不是装饰器,而是装饰器工厂。调用时,它返回将应用于目标函数的实际装饰器。

例 9-22。要接受参数,必须将新的register装饰器作为函数调用

registry = set()  1

def register(active=True):  2
    def decorate(func):  3
        print('running register'
              f'(active={active})->decorate({func})')
        if active:   4
            registry.add(func)
        else:
            registry.discard(func)  5

        return func  6
    return decorate  7

@register(active=False)  8
def f1():
    print('running f1()')

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

def f3():
    print('running f3()')
  1. registry现在是一个集合,因此添加和删除功能更快。
  2. register 接受一个可选的关键字参数。
  3. 装饰内部函数是实际的装饰器;请注意它如何将方法作为参数。
  4. 仅当active参数(从闭包中检索)为 True 时才注册 func。
  5. 如果func in registry且not active,将其删除。
  6. 因为decorate 是一个装饰器,它必须返回一个函数。
  7. register 是我们的装饰工厂,所以它返回decorate
  8. @register 工厂必须作为函数调用,并带有所需的参数。
  9. 如果没有传递参数, register 仍然必须作为函数调用——@register()——即返回实际的装饰器,decorate。

重点是 register() 返回decorate,然后将其应用于装饰的函数。

示例 9-22 中的代码位于 registration_param.py 模块中。如果我们导入它,这就是我们得到的:

>>> import registration_param
running register(active=False)->decorate(<function f1 at 0x10063c1e0>)
running register(active=True)->decorate(<function f2 at 0x10063c268>)
>>> registration_param.registry
[<function f2 at 0x10063c268>]

 请注意注册表中如何仅显示 f2 函数; f1 没有出现是因为 active=False 被传递给了 register 装饰器工厂,所以应用于 f1 的装饰没有将它添加到registry中。

如果我们不使用 @ 语法,而是使用 register 作为常规函数,那么修饰函数 f 所需的语法将是 register()(f) 将 f 添加到注册表中,或者 register(active=False)(f)不添加(或删除)。

有关向注册表添加和删除函数的演示,请参见示例 9-23。

例 9-23。使用例 9-22 中列出的 registration_param 模块

>>> from registration_param import *
running register(active=False)->decorate(<function f1 at 0x10073c1e0>)
running register(active=True)->decorate(<function f2 at 0x10073c268>)
>>> registry  1
{<function f2 at 0x10073c268>}
>>> register()(f3)  2
running register(active=True)->decorate(<function f3 at 0x10073c158>)
<function f3 at 0x10073c158>
>>> registry  3
{<function f3 at 0x10073c158>, <function f2 at 0x10073c268>}
>>> register(active=False)(f2)  4
running register(active=False)->decorate(<function f2 at 0x10073c268>)
<function f2 at 0x10073c268>
>>> registry  5
{<function f3 at 0x10073c158>}
  1.  导入模块时,f2 位于registry中。
  2. register() 表达式返回decorate,然后将其应用于f3。
  3. 上一行将 f3 添加到registry中。
  4. 此调用从registry中删除 f2。
  5. 确认registry中只有f3

参数化装饰器的工作相当复杂,我们刚刚讨论的那个比大多数都简单。参数化装饰器通常会取代被装饰的函数,它们的构造需要另一层嵌套。现在我们将探索一个这样的函数金字塔的架构。

参数化clock装饰器

在本节中,我们将重新审视clock装饰器,添加一个功能:用户可以传递一个格式字符串来控制clocked函数报告的输出。请参见示例 9-24。

例 9-24。模块clockdeco_param.py:参数化clock装饰器

import time

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

def clock(fmt=DEFAULT_FMT):  1
    def decorate(func):      2
        def clocked(*_args): 3
            t0 = time.perf_counter()
            _result = func(*_args)  4
            elapsed = time.perf_counter() - t0
            name = func.__name__
            args = ', '.join(repr(arg) for arg in _args)  5
            result = repr(_result)  6
            print(fmt.format(**locals()))  7
            return _result  8
        return clocked  9
    return decorate  10

if __name__ == '__main__':

    @clock()  11
    def snooze(seconds):
        time.sleep(seconds)

    for i in range(3):
        snooze(.123)
  1. clock 是我们的参数化装饰器工厂。
  2. decorate是实际的装饰器。
  3. clocked 包装了被装饰函数
  4. _result 是被装饰函数的实际结果。
  5. _args 保存了clocked 的实际参数,而args 是用于显示的str字符串。

  6. result 是 _result 的 str 表示,用于显示。

  7. 在这里使用 **locals() 允许在 fmt 中引用 clocked 的任何局部变量

  8. clocked 将替换装饰函数,因此它应该返回该函数返回的任何内容。

  9. decorate 返回clocked.

  10. clock 返回 decorate.

  11. 在这个自测中,clock() 是不带参数调用的,因此应用的装饰器将使用默认格式 str

如果您从 shell 运行示例 9-24,您将得到以下结果:

$ python3 clockdeco_param.py
[0.12412500s] snooze(0.123) -> None
[0.12411904s] snooze(0.123) -> None
[0.12410498s] snooze(0.123) -> None

为了练习新功能,让我们看一下示例 9-25 和 9-26,这两个模块是使用 clockdeco_param 的另外两个模块,以及它们生成的输出。

示例 9-25. clockdeco_param_demo1.py

import time
from clockdeco_param import clock

@clock('{name}: {elapsed}s')
def snooze(seconds):
    time.sleep(seconds)

for i in range(3):
    snooze(.123)

示例 9-25 的输出:

$ python3 clockdeco_param_demo1.py
snooze: 0.12414693832397461s
snooze: 0.1241159439086914s
snooze: 0.12412118911743164s

示例 9-26. clockdeco_param_demo2.py

import time
from clockdeco_param import clock

@clock('{name}({args}) dt={elapsed:0.3f}s')
def snooze(seconds):
    time.sleep(seconds)

for i in range(3):
    snooze(.123)

示例9-26的输出:

$ python3 clockdeco_param_demo2.py
snooze(0.123) dt=0.124s
snooze(0.123) dt=0.124s
snooze(0.123) dt=0.124s

NOTE

Graham Dumpleton 和 Lennart Regebro(第一版的技术审阅者)认为装饰器最好编码为实现 __call__ 的类,而不是像本章中的示例那样的函数。我同意这种方法对于非平凡的装饰器更好,但是为了解释这个语言特性的基本思想,函数更容易理解。请参阅“Further Reading”,,特别是 Graham Dumpleton 的博客和 wrapt 模块,了解建筑装饰器时的工业强度技术。

 下一部分显示了 Dumpleton 和 Regebro 推荐的样式的示例。

基于类的clock装饰器

作为最后一个示例,示例 9-27 列出了作为带有 __call__ 的类实现的参数化时钟装饰器的实现。将示例 9-24 与示例 9-27 进行对比。你更倾向哪个?

例 9-27。模块clockdeco_cls.py:作为类实现的参数化clock装饰器

import time

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

class clock:  1

    def __init__(self, fmt=DEFAULT_FMT):  2
        self.fmt = fmt

    def __call__(self, func):  3
        def clocked(*_args):
            t0 = time.perf_counter()
            _result = func(*_args)  4
            elapsed = time.perf_counter() - t0
            name = func.__name__
            args = ', '.join(repr(arg) for arg in _args)
            result = repr(_result)
            print(self.fmt.format(**locals()))
            return _result
        return clocked
  1. clock类不是clock外部函数,而是我们的参数化装饰器工厂。我用小写的 c 来命名它,以表明这个实现是对示例 9-24 中的实现的直接替代。
  2. 在clock(my_format) 中传递的参数在这里分配给fmt 参数。类构造函数返回一个clock 实例,my_format 存储在self.fmt 中。
  3. __call__ 使clock实例可调用。调用时,该实例用clocked 替换装饰函数
  4. clocked 包装了被装饰的函数  
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值