CHAPTER 9 Decorators and Closures

1、整体介绍

There’s been a number of complaints about the choice of the name “decorator” for this
feature. The major one is that the name is not consistent with its use in the GoF book.1
The name decorator probably owes more to its use in the compiler area—a syntax tree
is walked and annotated

函数装饰器能让我们在源代码中“标记”函数,以某种方式增强其行为。这是非常强大的功能,但掌握它需要理解闭包——当函数捕获其函数体外部定义的变量时,就形成了闭包。

Python中最令人费解的保留关键字是nonlocal,它在Python 3.0中被引入。如果你严格遵循以类为中心的面向对象编程规范,即便从未使用过它,也能成为一名出色的Python程序员。然而,如果你想自己实现函数装饰器,就必须理解闭包,此时nonlocal的必要性就显而易见了。

除了在装饰器中的应用,闭包对于任何使用回调的编程类型,以及在合适场景下采用函数式编程风格而言,也至关重要。

本章的最终目标是详细解释函数装饰器的工作原理,从最简单的装饰器到更为复杂的参数化装饰器。不过,在达成这个目标之前,我们需要涵盖

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

有了这些基础,我们可以进一步探讨装饰器的相关主题:

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

2、Decorators 101(基础)

1. 什么是装饰器?

装饰器本质上是一个可调用对象(通常是函数),它接受另一个函数作为参数,并可能会对这个函数进行处理(添加功能、修改行为等),然后返回一个新的函数或可调用对象。换句话说,装饰器可以用来包装另一个函数,从而在调用这个被包装的函数时实现新的功能。

举个例子,我们定义了一个装饰器 decorate,使用装饰器的两种语法如下所示:

使用 @ 语法的写法(推荐)

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

用普通函数调用装饰的写法

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

两种写法最终效果是一样的。在两种情况下,装饰器 decorate 修改了函数 target,让 target 变成 decorate(target) 的返回值。如果 decorate(target) 返回的是一个新函数,那么 target 将被替换为那个新函数 —— 它可能是原函数,也可能是一个全新的函数。


2. 基本装饰器的示例

示例 1

原文提到的例子展示了最简单的装饰器是如何替换一个函数的:

def deco(func):
    # 定义了一个内部函数 inner
    def inner():
        print('running inner()')
    return inner  # 返回内部函数 inner

在这个装饰器中,deco 将传入的原函数 func 替换成了它自己定义的 inner 函数。

然后,我们通过装饰器装饰函数:

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

此时,target 已经不再是上面定义的简单函数了,而是变成了函数 inner()

验证一下:

target()

运行结果:

running inner()

我们还能通过检查 target 看出,它的实际引用指向新的函数:

print(target)  # 输出类似:<function deco.<locals>.inner at 0x7f9b5d2e0>

可以看到,target 被替换成了 deco 返回的 inner()


3. 装饰器的三大核心特性

要理解装饰器机制,有三点核心知识需要掌握:

1. 装饰器是一个函数或可调用对象

  • 装饰器本质就是函数或任何实现了 __call__() 的对象
  • 它接受一个函数作为参数,并返回一个新的函数或可调用对象。

2. 装饰器可以用来替换被装饰的函数

  • 装饰器可以对被装饰的函数进行包装,也可以完全替换掉它。
  • 一个函数一旦被装饰,实际上它的引用可能已经指向了一个不同的函数。

3. 装饰器在模块加载时立即执行

  • 装饰器是在模块加载时就会执行的,不是函数调用时才执行。
  • 这一特性使得装饰器非常适合用于修改程序的行为。

现在让我们重点关注第三点。

3、When Python Executes Decorators

核心概念:装饰器的执行时刻

关键点

  1. 何时执行装饰器:
    装饰器在被装饰的函数定义后,立即被执行。这通常发生在模块被导入时,也即所谓的导入时间(import time)

  2. 函数调用时间的区别:
    被装饰的函数本身并不会在装饰器定义时被调用,而是只有在运行时(runtime)明确调用它时才会执行。

  3. 装饰器的执行特点:

    • 装饰器本质上是一个接收函数(或类)对象作为参数并返回另一个函数(或类)对象的函数。
    • 它可以对被装饰的函数进行注册、替换或增强。

示例讲解:registration.py 模块分析

以下是我们要分析的代码示例:

代码

# registration.py

# 定义一个全局的注册列表,存放被装饰的函数
registry = []

# 定义装饰器函数
def register(func):
    print(f'running register({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()  # 调用 f1
    f2()  # 调用 f2
    f3()  # 调用 f3

# 如果作为脚本运行,则执行 main 函数
if __name__ == '__main__':
    main()

运行分析

运行代码时,可能有以下两种情况:

  1. 直接运行脚本:python3 registration.py

    • 装饰器在模块加载时立即执行,不依赖于 main() 是否运行。
    • 输出如下:
      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>。这两个地址分别代表 f1 函数对象 和 f2 函数对象 在内存中的位置
      • 被装饰的函数引用(<function f1><function f2>)会被添加到 registry 列表中。
      • 执行 main() 后,明确调用 f1, f2, 和 f3
  2. 作为模块导入:import registration

    • 模块中的语句会在导入时运行,但 main() 不会被调用。

    • 输出如下:

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

      解释:

      • 类似上面的步骤,@register 装饰器仍然被触发,将两个函数加入到 registry
      • 但是 main() 不会运行,因此 f1f2 不会被调用。
    • 如果检查 registry 列表的内容,可以看到:

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

深入理解装饰器的执行顺序和内存分配流程

  1. 装饰器的执行顺序:为什么装饰器在函数定义后立即被执行,而不是等到函数调用时?
  2. 函数内存分配的时间点:为什么函数在这个时候已经有了内存地址?

要解答这个问题,我们需要从Python 的运行机制装饰器的执行顺序入手,然后结合实际代码来分解整个流程,含装饰器的使用。


一、Python 的模块导入与执行流程

在 Python 中,文件(模块)的导入和执行有如下顺序:

  1. 逐行解释执行
    Python 是一门解释型语言。当我们执行一个脚本或导入一个模块时,Python 解释器会从上到下逐行读取并运行代码。

  2. 函数的定义
    遇到 def func_name():,Python 并不会立即执行函数的内容,而是会创建一个函数对象并在内部注册函数名(如 f1)。此时:

    • 函数体(函数内部的代码)还没执行。
    • 函数的名字(如 f1)会注册到当前作用域的符号表中,指向一个函数对象
    • 函数对象被分配内存地址(这就是打印 <function f1 at 0x...> 时看到的值)。
  3. 装饰器的执行
    遇到装饰器 @decorator 时,会立即执行装饰器的代码,并将被装饰的函数对象作为参数传入装饰器。这就是装饰器的执行时机。

  4. 导入时间与运行时间的区分

    • 导入时间(import time):模块被加载时自动执行的部分,比如函数定义、类定义以及装饰器的应用。
    • 运行时间(runtime):只有当脚本作为入口运行(__name__=='__main__'),并且明确调用函数时,函数体的内容才会被执行。

二、装饰器的执行:完整流程分解

接下来结合示例从头到尾分析装饰器的执行顺序以及函数内存分配的时机。

示例代码

我们仍然以以下代码为基础分析:

registry = []

def register(func):
    print(f'running register({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()
执行流程(逐行拆解)
  1. 解析模块:逐行处理代码
    当我们运行 python3 registration.py 时,Python 会从上到下逐行解释执行该脚本的代码。

  2. registry = [] 的定义

    • Python 创建了一个空列表 registry
    • 此时,registry 被注册到全局作用域,位于内存中。
  3. register 装饰器的定义

    • 此时 register 本质上是一个函数对象,其名字被注册到全局作用域。
    • Python 创建了 register 函数对应的对象,并分配了内存地址(通过 id(register) 可查看)。
  4. 第一个被装饰的函数 f1 出现(执行 @register 装饰器)

    • Python 遇到 @register,立即执行 register(f1)
      1. Python 开始解析 f1 的定义。
      2. 创建一个函数对象 f1,并分配内存地址(如:<function f1 at 0x100631bf8>)。
      3. 将函数对象 f1 作为参数传递给 register
      4. register 中输出 running register(<function f1 at 0x100631bf8>)
      5. 函数 f1 被注册到 registry 列表中(registry.append(f1))。
  5. 第二个被装饰的函数 f2 出现(再次执行 @register 装饰器)

    • 同样的处理:
      1. Python 解析 f2,创建一个函数对象 f2,并分配内存地址(如:<function f2 at 0x100631c80>)。
      2. 将函数对象 f2 传递给 register
      3. 输出:running register(<function f2 at 0x100631c80>)
      4. f2 被注册到 registry 列表中。
  6. 普通函数 f3 的定义

    • Python 遇到 def f3,仅定义函数 f3 并分配内存,但没有任何装饰器的执行过程。
  7. 主程序(main())和脚本入口定义

    • Python 遇到 if __name__ == '__main__':,检查条件是否为真。
    • 如果当前文件被直接运行为脚本,则进入 main() 执行。
  8. 运行主程序 main()

    • 输出 running main()
    • 打印 registry ->,此时可以看到 registry 中存储了 f1f2 的引用。
    • 按顺序执行 f1()f2()f3() 的函数体,每次以调用函数的方式触发其内部代码运行。

执行结果回顾

结合上述流程,完整的执行结果为:

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

三、深入解析:为什么函数已经分配了内存?
内存分配点

Python 在函数定义时会立即分配内存,并注册名字与函数对象的映射关系。
这是 Python 的动态类型(Dynamic Typing) 特性的一部分,其原理为:

  1. 函数定义时:动态创建函数对象

    • 每次定义一个函数,Python 会动态创建一个对应的 function 对象,并存储在内存中。
    • 指定的函数名(如 f1)是一个全局变量,它只是指向函数对象的引用
    • 内存地址是由 Python(三种主实现之一,比如 CPython)的内存管理器动态分配的。
  2. 确保函数对象在装饰器中可用

    • 因为装饰器是一种函数调用,它需要接收函数对象作为参数。因此,在应用装饰器之前,函数对象必须先被创建并分配内存
    • 在装饰器执行时,f1f2 的函数对象已经存在,并被传递给 register
  3. 导入和执行的时机

    • 导入时间(import time):装饰器在模块导入时会立即执行。
    • Python 在导入期间完成函数定义、对象分配以及装饰器的应用。

四、通过代码验证内存分配时机

可以使用 id() 函数或者 hex(id()) 观察函数内存分配的时机:

验证函数定义时分配地址
def register(func):
    print(f'{func.__name__} defined at memory address {hex(id(func))}')
    return func

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

@register
def f2():
    print('running f2()')
输出:
f1 defined at memory address 0x7fab49c71ee0
f2 defined at memory address 0x7fab49c71f70

解释:

  1. id(func) 显示函数对象的内存地址。
  2. 这些地址在 f1f2 被传递给 register 时,已经确定。

重点与易错点

  1. 装饰器的执行时机:
    装饰器是在函数被定义时立即执行,而不是在函数调用时执行。

    • 常见误解:很多人可能以为装饰器的逻辑只有在函数运行时才会触发,这是错误的。
  2. 区分导入时间和运行时间:

    • 导入时间(import time):
      模块加载时,Python解释器会依次执行模块中的声明语句,此时会执行装饰器函数。
    • 运行时间(runtime):
      模块导入完毕后,只有明确地调用某个函数时,该函数才会执行。
  3. 返回值的重要性:
    register 函数返回了 func 本身,这是因为装饰器需要返回一个可调用对象。

    • 如果缺少 return func,会导致函数被替换为 None 或其他无效值,后续调用时报错。

补充实例:进一步理解装饰器执行的机制

示例 1:装饰器的执行顺序

多个装饰器的情况:

def decorator1(func):
    print("Executing decorator1")
    return func

def decorator2(func):
    print("Executing decorator2")
    return func

@decorator1
@decorator2
def my_function():
    print("Running my_function")

执行结果:

Executing decorator2
Executing decorator1

解释:
Python 从内向外应用装饰器(即 @decorator2 先被应用)。装饰器在函数定义时就依次被触发调用。


示例 2:动态注册的应用

装饰器在工程中的一个常见用例是函数的注册,比如实现命令行工具的命令管理:

commands = {}

def command(name):
    def decorator(func):
        commands[name] = func  # 按名称注册函数
        return func
    return decorator

@command('start')
def start():
    print("Starting...")

@command('stop')
def stop():
    print("Stopping...")

print(commands)  # {'start': <function start at 0x7ff>, 'stop': <function stop at 0x8aa>}
commands['start']()  # 输出: Starting...
commands['stop']()   # 输出: Stopping...

示例 3:替换函数

装饰器可以返回一个完全不同的函数对象,用于改变行为:

def replace_func(func):
    def new_func():
        print("This is the new function!")
    return new_func

@replace_func
def old_function():
    print("This is the old function.")

old_function()  # 输出: This is the new function!

解释:
这里装饰器用 new_func 替换了 old_function,所以调用 old_function() 的结果完全被重定义。


总结

  1. 装饰器执行时机:
    • 在函数定义时(导入时间)立即执行装饰器代码。
  2. 装饰器功能:
    • 常用于动态注册、函数增强或替换。
  3. 易错点:
    • 不要误认为装饰器是在函数调用时才被执行。
    • 如果装饰器返回值不正确,可能导致程序调用错误。
  4. 最佳实践:
    • 在实际工程中,装饰器常用于日志记录、权限管理、缓存机制等功能实现。

通过这些示例和补充内容,我们不仅理解了装饰器的执行顺序,还从实际开发角度知道如何灵活使用装饰器来增强代码效率与设计能力。

4、Registration Decorators

考虑到装饰器在实际代码中的常见用法,示例9-2在两个方面显得不同寻常:

  • 装饰器函数与被装饰函数定义在同一个模块中。在实际情况中,装饰器通常定义在一个模块,而应用到其他模块的函数上。
  • register装饰器返回的函数与传入的参数函数相同。在实践中,大多数装饰器会定义一个内部函数并返回它。

尽管示例9-2中的register装饰器返回的被装饰函数没有变化,但这种技术并非毫无用处。许多Python框架使用类似的装饰器将函数添加到某个中央注册表中,例如,将URL模式映射到生成HTTP响应的函数的注册表。这类注册装饰器可能会也可能不会改变被装饰函数。

示例:基础 URL 路由注册

# 路由注册表,存储URL路径与对应函数的映射
url_map = {}  

# 路由装饰器函数
def route(url):
    def decorator(func):
        # 在路由注册表中将URL路径绑定到处理函数
        url_map[url] = func
        return func  # 返回原始函数
    return decorator

# 使用装饰器将特定URL路径注册到函数
@route("/home")
def home():
    return "Welcome to the homepage!"

@route("/about")
def about():
    return "Learn more about us."

# 模拟请求,调用URL对应的路由处理函数
print(url_map["/home"]())  # 输出:Welcome to the homepage!
print(url_map["/about"]()) # 输出:Learn more about us.

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

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

5、Variable Scope Rules

在 Python 中,变量作用域是指变量在程序中的可见性和可访问性范围。理解正确的作用域规则对于开发者避免变量冲突、调试错误以及构建复杂逻辑至关重要。本笔记将分析变量作用域规则,结合实例进行说明,包括局部作用域(local scope)、全局作用域(global scope)、及特殊关键字 globalnonlocal 的使用。


1. 基本变量作用域示例

我们从一个简单的函数 f1 开始,来理解局部变量(local variable)和全局变量(global variable)的概念。

示例 1:读取局部变量和未定义变量

def f1(a):
    print(a)  # 局部变量 a
    print(b)  # 尝试读取全局变量 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

解释:

  • 函数 f1 定义了一个参数 a,这是局部变量,因此 print(a) 可以正常打印出传入值 3。
  • 由于变量 b 未定义,尝试调用它会抛出 NameError

示例 2:定义全局变量后使用

接下来我们定义 b 并再次运行函数 f1

b = 6
f1(3)

运行结果:

3
6

解释:

  • 变量 b 定义在函数体外部,属于全局作用域,因此 print(b) 成功打印全局变量的值。

2. 局部作用域中的变量定义导致问题

考虑一个新的函数 f2,它尝试在函数内部重新定义变量 b

示例 3:局部变量和赋值语句影响作用域

b = 6

def f2(a):
    print(a)
    print(b)  # 此时 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

解释:

  • 在函数体内的赋值操作 b = 9告诉 Python 编译器将 b 视为局部变量。这意味着在函数体中,所有对 b 的引用都只会在局部作用域中查找。
  • 然而,在执行 print(b) 时,局部变量 b 尚未初始化,抛出 UnboundLocalError
b = 6

def f2(a):
    print(a)
    print(b)

f2(3)

这样的话,就可以打印出3和6

深入分析:编译阶段的行为

当 Python 编译函数时,它会提前确定变量的作用域。如果在函数内出现对变量的赋值(如 b = 9),Python 会假设变量为局部变量,即使赋值语句出现在后面。

这是 Python 的设计选择,目的是避免无意间修改全局变量,提供更强的代码安全性。


3. 修复:使用 global 声明全局变量

如果我们希望函数既能读取全局变量 b,又能在函数中修改该变量,那么需要使用 global 关键字。

示例 4:显示声明全局变量

b = 6

def f3(a):
    global b  # 显式声明 b 为全局变量
    print(a)
    print(b)
    b = 9     # 修改全局变量

f3(3)
print(b)  # 查看全局变量是否被修改

运行结果:

3
6
9

解释:

  • 使用 global b 明确表明 b 是一个全局变量。
  • 在函数执行过程中,b = 9 修改了全局作用域中的变量值,因此在函数执行后,b 的值变为 9。

4. Python 的两种常用作用域

总结来看,Python 中的变量作用域通常包括两类:

  1. 全局作用域(Global Scope)

    • 由模块(文件)中定义的变量构成。
    • 全局变量可以在整个模块中访问,但在函数中默认是只读的,除非用 global 声明。
  2. 局部作用域(Local Scope)

    • 在函数或代码块(如循环、条件语句)内部定义的变量。
    • 局部变量在函数执行过程中创建,执行结束后销毁。

5. 函数 f1f2 的字节码差异解析

Python 在编译时期会生成字节码,并决定变量的作用域。在这里,我们通过 dis 模块观察 f1f2 的字节码指令,理解 Python 编译器的行为。

from dis import dis

b = 6

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

def f2(a):
    print(a)
    print(b)
    b = 9

dis(f1)
print('-' * 40)
dis(f2)

结果:

  2           0 LOAD_GLOBAL              0 (print)
              3 LOAD_FAST                0 (a)
              6 CALL_FUNCTION            1
              9 POP_TOP
  3          10 LOAD_GLOBAL              0 (print)
             13 LOAD_GLOBAL              1 (b)
             16 CALL_FUNCTION            1
             19 POP_TOP
             20 LOAD_CONST               0 (None)
             23 RETURN_VALUE
----------------------------------------
  2           0 LOAD_GLOBAL              0 (print)
              3 LOAD_FAST                0 (a)
              6 CALL_FUNCTION            1
              9 POP_TOP
  3          10 LOAD_GLOBAL              0 (print)
             13 LOAD_FAST                1 (b)
             16 CALL_FUNCTION            1
             19 POP_TOP
  4          20 LOAD_CONST               1 (9)
             23 STORE_FAST               1 (b)
             26 LOAD_CONST               0 (None)
             29 RETURN_VALUE

字节码分析:

  • f1 中,b 被认为是全局变量,因此通过指令 LOAD_GLOBAL 读取变量。
  • f2 中,因为 b 存在局部赋值操作,编译时决定将其视为局部变量,使用 LOAD_FAST 指令。

这是导致 f2 中报错的根本原因。

6、Closures

1. 什么是闭包?

闭包是 “一个函数”,它拥有一个扩展作用域,可以捕获并保留其父函数中定义的变量,即使父函数的作用域已经结束。这些被捕获的变量叫作自由变量(free variables)

闭包的核心点

  1. 一定是嵌套函数(函数定义在另一个函数的内部)。
  2. 内层函数必须引用外层函数中的变量。
  3. 外层函数返回内层函数,让内层函数的“扩展作用域”继续存在。

2. 闭包和匿名函数的误解

闭包和匿名函数(lambda)经常被混淆,这是因为两者经常在相同的代码场景或学习阶段被引入。其实:

  • 匿名函数:仅仅是一种用简洁语法定义函数的方式,与是否捕获外部变量无关。
  • 闭包:可以捕获并保留外层函数的局部变量,与函数是否匿名无关。

关键点:闭包与函数是否匿名没有关系,关键在于变量捕获!


3. 实现运行平均值的例子

在以下示例中,我们通过两种方式(类实现和高阶函数实现)来计算运行平均值,并深入理解两者的区别,以此更好地理解闭包。

3.1 类实现方式

# 使用类实现运行平均值计算器
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()
print(avg(10))  # 10.0
print(avg(11))  # 10.5
print(avg(12))  # 11.0

运行分析

  • Averager 类通过实例属性 self.series 存储历史数据。
  • 直接调用 Averager 实例的 __call__ 方法(类似函数调用 avg(n))。
  • 属性 self.series 的生命周期和类实例一样长,确保了历史数据的持久性。

3.2 使用高阶函数实现

# 使用闭包实现运行平均值计算器
def make_averager():
    series = []  # 存储历史数据

    def averager(new_value):
        series.append(new_value)  # 添加新值到系列中
        total = sum(series)  # 累加求和
        return total / len(series)  # 返回平均值

    return averager  # 返回内层函数(闭包)

# 测试函数
avg = make_averager()
print(avg(10))  # 10.0
print(avg(11))  # 10.5
print(avg(12))  # 11.0

运行分析

  • series 是外层函数 make_averager 的局部变量。
  • averager 是嵌套在函数 make_averager 内部的函数,并捕获了 series 变量。
  • 返回的 averager 函数形成了一个闭包,存储了 series 的引用。
    在这里插入图片描述

4. 闭包的生命周期和内部机制

在上述闭包实现中,需要理解 为什么闭包中的 series 在外层函数结束后依然存在。这可以通过 Python 的属性查看方法来验证。

查看闭包的变量

avg = make_averager()

# 查看 avg 内部的局部变量和自由变量
print(avg.__code__.co_varnames)  # ('new_value', 'total') - 内部局部变量
print(avg.__code__.co_freevars)  # ('series',) - 自由变量

# 查看自由变量的值
print(avg.__closure__[0].cell_contents)  # [10, 11, 12]

解释

  • avg.__code__.co_freevars 显示闭包捕获的自由变量名称,这里是 'series'
  • avg.__closure__ 显示自由变量的实际绑定值,这里的 [10, 11, 12]series 的当前内容。

核心点

  • 闭包的本质是一个函数,它保留了对其定义时环境中自由变量的引用。这些变量存放在函数的 __closure__ 属性中,保证即使外层函数执行完毕后,变量依然可用。

5. 类实现与闭包实现的对比

特点类实现闭包实现
定义方式通过类定义,初始化时分配存储空间通过函数嵌套实现,返回内部函数
数据存储位置类属性(实例变量,如 self.series函数的自由变量(如 series
可读性和灵活性面向对象风格,适合复杂逻辑简洁,适合局部任务,但复杂逻辑可能不适用
生命周期控制数据与类实例的生命周期一致数据由闭包持久化,生命周期独立于外层函数

额外例子:闭包的应用

例子 1:缓存函数结果

闭包可以用来构建一个带有缓存功能的函数。

def make_cache():
    cache = {}

    def cached_function(n):
        if n in cache:
            print("Fetching from cache...")
            return cache[n]
        print("Computing result...")
        result = n * n
        cache[n] = result
        return result

    return cached_function

# 使用闭包
cached_square = make_cache()
print(cached_square(4))  # Computing result... 16
print(cached_square(4))  # Fetching from cache... 16
例子 2:动态生成函数

可以用闭包生成特定参数的函数。

def multiply_by(n):
    def multiplier(x):
        return x * n
    return multiplier

# 生成特定的倍数函数
double = multiply_by(2)
triple = multiply_by(3)

print(double(5))  # 10
print(triple(5))  # 15
注意:

闭包保存的是自由变量的引用,而非变量值。以下是容易出错的一种情况:

funcs = []
for i in range(3):
    def f():
        return i
    funcs.append(f)

print([fn() for fn in funcs])  # 全部输出 2

这个问题涉及了闭包的概念和 Python 作用域的运行机制。如果熟悉 Python 的闭包,你会发现这里的核心问题是「延迟绑定(late binding)」现象。

背后原因解析
funcs = []  
for i in range(3):  
    def f():  
        return i  
    funcs.append(f)
print([fn() for fn in funcs]) # 全部输出 2

让我们逐步分解这个代码的行为。

1. for 循环动态创建函数
for i in range(3) 中,i 是一个循环变量,它的值每次都会在循环中变化。但由于循环是动态运行的,其作用域只会存储变量的最后值。

2. f 函数捕获了 i 的引用(闭包)
在循环内部定义的函数 f 是一个闭包。闭包是指一个函数可以引用外部作用域的变量,即这里的 i

然而,Python 中的闭包使用的是「引用(reference)」而非「值(value)」。这意味着,被捕获的变量 i 并不是在函数定义时的当前值,而是一个动态绑定到外部作用域的变量。如果这个变量的值发生了变化,那么闭包中的引用也会反映出这个变化。

在循环运行结束后,i 的值最终是 2range(3) 的最后一个值),而所有的闭包 f 其实都共享同一个 i

3. 函数调用
当执行 [fn() for fn in funcs] 时:

  • 每个 fn(即 f)调用时都会动态地去访问外部变量 i
  • 此时,i 的最终值是 2,所以每次函数调用都会返回 2

总结
最终,funcs 中的 3 个函数 f,都共享同一个外部变量 i 的引用,而此引用的值到最后是 2,因此结果是 [2, 2, 2]

和闭包的关系

如上所述,闭包是指函数在定义时捕获了外部作用域的变量。这里的 f 捕获的是循环变量 i。由于 Python 的闭包是基于引用的,函数会动态地绑定到外部作用域中的最新值,即 i 的最终值为 2

如果想避免这种情况,可以通过以下方式解决。


如何解决这个问题?

你可以使用函数的默认参数来避免「动态绑定」的问题。通过将当前 i 的值在每次循环中保存为默认参数(函数参数的值会在定义时被提前确定),可以打破对外部变量的依赖:

funcs = []
for i in range(3):  
    def f(i=i):  # 将当前 i 的值作为默认参数绑定到 f 中
        return i
    funcs.append(f)
print([fn() for fn in funcs])  # 输出 [0, 1, 2]

为什么这能解决问题?
在这种写法中:

  • f 被定义时,默认参数 i=i 会将当前的 i 值复制到函数参数中。
  • 这样每个 f 都独立地存储了一个与循环变量无关的值。

通过这种方式,闭包中的变量引用就不再依赖于外部作用域的 i,而是使用自己定义时绑定的值。

默认参数绑定与存储机制

在 Python 中,默认参数的绑定和存储机制在函数定义时就立即生效,并且存储在函数对象的特殊属性中。以下是一个代码示例及其解释:

funcs = []

for i in range(3):
    def f(i=i):  # 使用当前的 i 作为默认参数
        return i
    funcs.append(f)

绑定过程

在上述代码中,通过循环定义了三个函数,每个函数都使用当前循环变量 i 的值作为默认参数。具体过程如下:

  • 第一次迭代(i=0

    • def f(i=i) 执行时,i=0 立即绑定为默认参数。
    • 该默认参数 i=0 存储在函数 f 的属性中。
    • 函数 f 被添加到列表 funcs 中。
  • 第二次迭代(i=1

    • 新的 def f(i=i) 执行,i=1 立即绑定为新函数的默认参数。
    • 新函数 f 的默认参数 i=1 被存储。
    • 该函数再次被添加到列表 funcs 中。
  • 第三次迭代(i=2

    • i=2 被绑定为当前 f 函数的默认参数。

存储位置

每个函数的默认参数值被存储在函数对象的 __defaults__ 属性中。__defaults__ 是一个元组,存储了所有默认参数的值。可以通过以下代码查看这些默认参数:

# 查看每个函数的默认参数
for f in funcs:
    print(f.__defaults__)

运行结果为:

(0,)
(1,)
(2,)

这表明每个函数的默认参数已经在定义时独立存储,并且彼此之间互不干扰,不受外部变量 i 的影响。这样,每个函数在调用时都能返回其定义时绑定的默认参数值。


另一种方法是使用 functools.partial 或者显式捕获变量的方式,但使用默认参数是最简便的解决方案。

🔑 核心提示

  • 闭包捕获的是变量的引用,而不是值。
  • 循环结束后,外部变量的值会改变,从而影响闭包中的函数输出。
  • 解决方法是通过默认参数等技术,将外部变量的值与函数进行绑定,从根本上避免对外部变量的依赖。

7、The nonlocal Declaration

一、背景介绍

在 Python 编程中,经常会遇到需要嵌套函数、闭包(closure)以及与变量作用域相关的问题。本讲解基于一个经典的例子,解释如何用更高效的方式实现运行时的平均值计算,并深入探讨 nonlocal 声明的作用与变量的作用域解析规则。


二、运行时平均值计算的优化问题

我们设计一个函数工厂 make_averager 来生成一个函数 averager。这个函数返回动态数据流的平均值。初始实现会将所有的历史数据存储起来(即保存历史记录),但是这样会消耗过多内存和计算时间。

优化目标:只存储需要的总和和计数,而不要存储完整的历史数据。


【案例 1】非高效但可行的实现

代码:

def make_averager():
    series = []
    def averager(new_value):
        series.append(new_value)
        return sum(series) / len(series)
    return averager

使用与结果:

avg = make_averager()
print(avg(10))  # 输出:10.0
print(avg(20))  # 输出:15.0
print(avg(30))  # 输出:20.0

问题与不足:

  • 问题:每次调用都保存新数据到 series 列表中,并重新计算 sum(series)len(series)
  • 缺点:数据越多,计算时间和内存占用也越多,显然这种方式对于大型数据流来说效率较差。

【案例 2】优化后的错误实现(代码失效示例)

优化目标是只存储所需的总和 total 和计数器 count,但未正确使用作用域管理机制。

代码:

def make_averager():
    count = 0
    total = 0
    def averager(new_value):
        count += 1      # 错误:试图直接修改外部变量
        total += new_value
        return total / count
    return averager

结果:

avg = make_averager()
print(avg(10))  # 报错 Traceback: UnboundLocalError: local variable 'count' referenced before assignment

错误原因分析:

  • 在 Python 中,程序将 count += 1 解释为 count = count + 1,这是一次对变量的重新赋值。
  • 由于 count 的上一层作用域是外层闭包(即 make_averager 中定义的变量count),而在 averager 内对其赋值,Python 推断该变量为本地变量。
  • Python 会优先寻找函数内部的局部变量,但此时还没有为 counttotal 分配内存(未定义),最终导致了 UnboundLocalError

【案例 3】使用 nonlocal 修复问题

nonlocal 关键字是一种声明方式,**用于表明某个变量不属于当前局部作用域,而是指向最近的嵌套作用域中的变量。**这样,修改这种变量时,可以正确作用于闭包外的绑定变量。

代码:

def make_averager():
    count = 0
    total = 0
    def averager(new_value):
        nonlocal count, total  # 声明 count 和 total 是外部闭包内的变量
        count += 1
        total += new_value
        return total / count
    return averager

使用与结果:

avg = make_averager()
print(avg(10))  # 输出:10.0
print(avg(20))  # 输出:15.0
print(avg(30))  # 输出:20.0

详解:

  • nonlocal 告诉 Python,counttotal 是外层(闭包)作用域的变量。
  • 通过 nonlocal 声明,我们可以对外层作用域的变量重新赋值,而不会导致创建新的本地变量。

三、Python 中的变量查找规则(作用域解析)

Python 按以下规则查找变量,从内到外逐层查找,直到找到目标变量或触发 NameError 异常:

1. 局部作用域 (Local Scope)

  • 如果变量是函数的参数或在函数内赋值,那么它是局部变量。
  • 如果试图赋值一个变量,则变量会被默认看成局部变量,除非声明为 nonlocalglobal

2. 闭包作用域 (Nonlocal Scopes)

  • 如果局部作用域中找不到变量,Python 会去最近的嵌套函数作用域里查找,此即闭包。
  • 注意:此处需要 nonlocal 声明才能修改闭包作用域的变量值。

3. 全局作用域 (Global Scope)

  • 如果变量没有被标记为局部或闭包,Python 会查找模块的全局作用域。
  • 全局变量需要用 global 声明后才可以在函数中修改。

4. 内置作用域 (Built-in Scope)

  • 如果变量不在模块的全局作用域,则会查找 Python 内置模块 builtins 中的变量。
  • 例如:len, type, print 等函数名,实际上是 builtins 中的内置变量。

当搜索完内置作用域后仍然找不到变量时,会抛出 NameError


四、为什么 nonlocal 重要?

nonlocal 是 Python 3 中引入的新特性,它允许闭包内部修改外层非全局变量。配合 closures,nonlocal 实现了许多复杂的函数式编程和工程优化模式。

工程示例 1:计数器函数工厂

代码:

def make_counter():
    count = 0
    def counter():
        nonlocal count
        count += 1
        return count
    return counter

使用与结果:

counter1 = make_counter()
print(counter1())  # 输出:1
print(counter1())  # 输出:2

counter2 = make_counter()
print(counter2())  # 输出:1

工程示例 2:闭包状态记录器

代码:

def make_logger(initial_message):
    message = initial_message
    def logger(new_message=None):
        nonlocal message
        if new_message:
            message = new_message
        return message
    return logger

使用与结果:

logger = make_logger("Start log...")
print(logger())            # 输出:Start log...
print(logger("Updated log!"))  # 更新记录
print(logger())            # 输出:Updated log!

五、补充与总结

1. 易错点

  • 忘记声明 nonlocal 或误用为 global,导致变量作用域解析错误。
  • 不清楚哪些变量是不可变类型(如数字、字符串、元组)。

2. nonlocal and global 区别

关键字作用范围修改目标
nonlocal最近的闭包作用域修改闭包函数中的非局部变量
global模块全局作用域修改全局变量

3. 使用场景

  • 实现运行时的统计函数(如平均值计算器:案例 3)。
  • 创建带状态的闭包(如计数器和日志记录器)。
  • 开发装饰器时处理闭包中的值。

4. 总结

nonlocal 是 Python 支持高阶函数和闭包的核心特性之一。在定义嵌套函数时,明确声明变量作用域无疑是更高效、更安全的编程思想!

8、Implementing a Simple Decorator

什么是装饰器?

装饰器的本质是一个函数,用于扩展或修改另一个函数的行为,而无需直接修改它的代码。通过装饰器,我们可以动态地向函数添加功能,同时保持其可读性和灵活性。

原理

一个简单的装饰器包括以下几个步骤:

  1. 定义装饰器函数:它接受一个函数作为参数(被装饰的函数)。
  2. 定义内部函数:内部函数执行扩展逻辑,并调用原函数。
  3. 返回内部函数:将内部函数替代被装饰的函数。

核心代码示例

以下代码展示了一个简单的装饰器,用于衡量函数运行时间。

示例 9-14: 简单的装饰器
import time

def clock(func):
    def clocked(*args):  # 接受任意数量的位置参数
        t0 = time.perf_counter()  # 记录起始时间
        result = func(*args)  # 调用原函数并保存结果
        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 对两个函数 snoozefactorial 进行装饰,分别演示其效果。

示例 9-15: 使用简单装饰器
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(0.123)')
    snooze(0.123)
    print('*' * 40, 'Calling factorial(6)')
    print('6! =', factorial(6))

输出结果

运行此代码,得到以下输出:

**************************************** Calling snooze(0.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
结果解析:
  1. snooze(0.123):延迟运行了约 0.12363791 秒。
  2. 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)
  • 使用装饰器时,Python 将函数 factorial 传给装饰器 clock,装饰器返回新的函数 clocked,并用它替代原函数。
  • 每次调用 factorial(n) 时,实际执行的是 clocked(n),而非原始的 factorial

为什么原函数的参数可以传递给装饰器函数

  1. 参数绑定:
    当你调用 factorial(3) 时,解释器会将参数 3 转换为位置参数元组。现在,参数信息会被传递给被调用的对象。

    • 原函数(未被装饰时)会直接将 3 绑定到 n,按照函数定义传入;
    • 装饰器替换后,调用的是 clocked,则参数自动绑定给 clocked(*args)args

    换个理解方法,就是当你调用一个对象(如函数或装饰器生成的可调用对象)时,Python 将“捕获调用的参数”,并将其动态地传递给该对象的 __call__ 协议或相关函数定义的参数列表

  2. 动态分发:
    对于 clocked(*args)

    • Python 根据调用的参数个数,将所有的位置参数将被打包成元组,赋值给 args。在本例中,args = (3,)
    • 这一步完全由函数的参数机制处理(即,def 语法自动支持拆解调用时参数集合)
  3. 回调原函数:
    接着,clocked 内会将参数传递给原函数:

    result = func(*args)
    

    此时,func 是原函数 factorial*args 会将元组 args 解包为普通参数,再传递。

    等价于:

    factorial(3)
    

    这是一种 动态参数绑定和分发的机制,Python 保证了在函数调用链中,参数可以完整地从装饰器一路透传到原函数。

  4. 执行返回结果:
    最后,factorial 返回值,装饰器处理后也可以作为结果返回。

  5. 调用原始函数字节码

字节码:

  7           8 LOAD_DEREF               0 (func)
             10 LOAD_FAST                0 (args)
             12 CALL_FUNCTION_EX         0
             14 STORE_FAST               2 (result)

功能说明:

  1. 原始函数加载

    • LOAD_DEREF 0 (func) 加载闭包中捕获的变量 func。此 func 正是 clock(factorial) 装饰后的参数,也就是原始的 factorial 函数。
    • 在这里,func 的值最初绑定了原始的 factorial
  2. 参数捕获与解包

    • LOAD_FAST 0 (args) 加载局部变量 args,它存储所有调用 clocked 时传入的 位置参数,形式为一个元组。例如,如果调用 factorial(6),则 args == (6,)

    • CALL_FUNCTION_EX 0 表示将元组 args 解包成位置参数,并传入函数。在这里,相当于执行了:

      result = factorial(*args)
      

      具体来说:

      • 因为 args = (6,),所以 func(*args) 等价于 factorial(6)
      • 原始 factorial 函数将接收参数 6 并开始执行,其中 6 被绑定到 factorial 的参数 n
  3. 返回值存储

    • STORE_FAST 2 (result)func(*args) 的返回值存储到变量 result 中。这里 result 是计算得到的阶乘结果。

改进装饰器

问题

当前装饰器存在以下不足:

  1. **不支持关键字参数:**现有实现只支持位置参数,不支持 **kwargs
  2. **丢失原函数元数据:**替换后,原函数的信息(如 __name____doc__)被覆盖。

使用 functools.wraps 改进装饰器

functools.wraps 是标准库提供的装饰器,用于保留被装饰函数的元信息(如名称和文档字符串),虽然它对程序逻辑没有直接影响,但对调试、文档生成和代码交互非常有帮助。即使程序在功能上没有直接区别,良好的开发实践仍然建议你在自定义装饰器中使用 @functools.wraps

示例 9-16: 支持关键字参数的改进版装饰器
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

!r 格式说明符用于 format 方法或 f-string,以获取对象的 repr()(表示)形式。repr() 函数返回一个字符串,该字符串在理想情况下是一个有效的 Python 表达式,可以用来重新创建具有相同值的对象。

result!r 的意思是调用函数后的 result 被传递给 repr() 函数,并将产生的字符串包含在输出

测试改进版装饰器

以下使用新装饰器对支持关键字参数的函数进行测试:

@clock
def greet(name, greeting="Hello"):
    time.sleep(0.1)
    return f"{greeting}, {name}!"

if __name__ == '__main__':
    print(greet("Alice"))
    print(greet("Bob", greeting="Hi"))

输出结果

[0.10340850s] greet('Alice') -> 'Hello, Alice!'
Hello, Alice!
[0.10356337s] greet('Bob', greeting='Hi') -> 'Hi, Bob!'
Hi, Bob!

9、Decorators in the Standard Library

1. Memoization with functools.cache + Using lru_cache

1. 概述

Python 提供了一些内建的装饰器,比如 staticmethodclassmethodproperty,这些会被用在不同语境中修饰方法或属性。此外,functools 标准库模块也包含了一些非常实用的功能装饰器,比如:

  • functools.cache:用于实现记忆化(memoization),即在函数执行过一次后缓存其计算结果,避免重复计算。
  • functools.lru_cache:与 functools.cache 类似,但提供了更高级的功能,比如缓存大小(maxsize)限制和缓存的最近最少使用(LRU)策略

functools.cache 是 Python 3.9 中引入的,如需兼容 3.8 及以下版本,我们可以使用 functools.lru_cache


2. Memoization(记忆化)基础概念

记忆化是一种优化技术,通过缓存函数的运算结果,减少对相同输入的重复运算。这非常适合用于解决递归型的、计算开销大的算法问题,比如斐波那契数列。


3. 使用 functools.cache 实现缓存

@functools.cache 是一个简单的装饰器,适用于无需太多配置的短生命周期的脚本。

示例 1:未使用缓存的递归计算斐波那契数

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

运行结果:

[0.00000021s] fibonacci(0) -> 0
[0.00000021s] fibonacci(1) -> 1
[0.00001463s] fibonacci(2) -> 1
[0.00000013s] fibonacci(1) -> 1
[0.00000017s] fibonacci(0) -> 0
[0.00000013s] fibonacci(1) -> 1
[0.00000458s] fibonacci(2) -> 1
[0.00000896s] fibonacci(3) -> 2
[0.00002804s] fibonacci(4) -> 3
[0.00000013s] fibonacci(1) -> 1
[0.00000013s] fibonacci(0) -> 0
[0.00000013s] fibonacci(1) -> 1
[0.00000417s] fibonacci(2) -> 1
[0.00000829s] fibonacci(3) -> 2
[0.00000025s] fibonacci(0) -> 0
[0.00000013s] fibonacci(1) -> 1
[0.00000433s] fibonacci(2) -> 1
[0.00000012s] fibonacci(1) -> 1
[0.00000017s] fibonacci(0) -> 0
[0.00000013s] fibonacci(1) -> 1
[0.00000442s] fibonacci(2) -> 1
[0.00000850s] fibonacci(3) -> 2
[0.00001775s] fibonacci(4) -> 3
[0.00003000s] fibonacci(5) -> 5
[0.00006258s] fibonacci(6) -> 8
8

问题分析

  • fibonacci(1) 被重复调用 8 次,fibonacci(2) 被重复调用 5 次。
  • 这种递归方法导致了大量的无效计算,效率低下。

示例 2:使用 functools.cache 优化缓存递归计算

import functools
from clockdeco import clock

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

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

运行结果:

[0.00000029s] fibonacci(0) -> 0
[0.00000017s] fibonacci(1) -> 1
[0.00001550s] fibonacci(2) -> 1
[0.00000029s] fibonacci(3) -> 2
[0.00002033s] fibonacci(4) -> 3
[0.00000025s] fibonacci(5) -> 5
[0.00002525s] fibonacci(6) -> 8
8

变化分析

  • 每个 n 只被计算一次,再次调用时将直接从缓存中读取结果。
  • 时间复杂度从指数级 (O(2^n)) 降到了线性级 (O(n))。

4. @functools.lru_cache 的高级用法

functools.cache 的表现和 @functools.lru_cache(maxsize=None) 是等价的,前者没有缓存上限,会缓存所有计算结果。这可能占用大量内存,不推荐在长生命周期的程序中使用。

优点@lru_cache 提供了更多的参数选项和灵活性。

  • maxsize:缓存的最大条目数,超过后会删除最久未使用的数据。
  • typed:区分不同类型的输入参数。例如,当 typed=True 时,f(1)f(1.0) 会被视为不同输入参数。

示例 3:自定义 lru_cache 参数
from functools import lru_cache

@lru_cache(maxsize=128, typed=True)
def costly_computation(a, b):
    print(f'Calling costly_computation({a}, {b})')
    return a ** b

# 测试代码
if __name__ == '__main__':
    print(costly_computation(2, 10))  # 首次计算
    print(costly_computation(2, 10))  # 缓存命中
    print(costly_computation(3, 10))  # 新的计算
    print(costly_computation(2, 10.0))  # 缓存失效,typed=True 导致区分 int 和 float

运行结果:

Calling costly_computation(2, 10)
1024
1024
Calling costly_computation(3, 10)
59049
Calling costly_computation(2, 10.0)
1024.0

解释

  • 对于相同参数组合 (a, b),结果被缓存。
  • 设置 typed=True 后,输入参数的类型也会参与缓存的区分。

5. LRU 算法与缓存失效

LRU (Least Recently Used) 算法会在缓存满时清理最近最少使用的条目,避免占用过多内存。

示例 4:观察 maxsize 的影响

from functools import lru_cache

@lru_cache(maxsize=2)  # 最多存储 2 项
def compute(a):
    print(f'Computing {a}')
    return a * 2

# 测试代码
if __name__ == '__main__':
    print(compute(1))  # 计算并缓存
    print(compute(2))  # 计算并缓存
    print(compute(1))  # 命中缓存
    print(compute(3))  # 新计算,导致最早的计算结果 (2) 被丢弃
    print(compute(2))  # 需要重新计算,缓存已失效

运行结果:

Computing 1
2
Computing 2
4
2
Computing 3
6
Computing 2
4

总结

  • 缓存大小由 maxsize 控制,超过容量时会清理最久未使用的条目。
  • 设置合理的缓存大小对于内存管理非常重要。

6. cachelru_cache 的实际应用场景

  1. 递归问题优化

    • 用于优化类似斐波那契数列、动态规划等问题。
    • 示例:解决子问题的结果可以重复利用。
  2. 远程调用减少重复请求

    • 避免在短时间内多次调用代价高或延迟大的 API(比如 REST 或数据库查询)。

    • 示例:

      import requests
      from functools import lru_cache
      
      @lru_cache(maxsize=256)
      def fetch_data(api_url):
          response = requests.get(api_url)
          return response.json()
      
  3. 合适的缓存周期与策略

    • 如果程序是短生命周期脚本,functools.cache 可以直接使用。
    • 如果程序是长生命周期应用,推荐使用 functools.lru_cache 并配置 maxsize

7. 总结

特性functools.cachefunctools.lru_cache
引入版本Python 3.9Python 3.2
参数设置支持 maxsizetyped 参数
缓存策略无限制,缓存所有调用基于 LRU 算法的缓存管理
使用场景短生命周期脚本长生命周期应用,需控制缓存大小

建议:

  • 开发短脚本时优先使用 functools.cache,简单、高效。
  • 如果缓存条目的数量可能很大,或者需要支持 Python 3.8 以下版本,则使用 lru_cache 并设置合理的 maxsize

2. Single Dispatch Generic Functions

什么是 singledispatch

得益于 functools.singledispatch,你可以创建一个泛型函数(generic function),该函数的行为取决于其 第一个参数 的类型。

类似于面向对象方法的多态,但在函数级别实现。对于不同的类型,@singledispatch 会自动调用注册的特殊函数,从而避免了使用长长的 if/elif/elsematch/case 块。

核心功能

  • 单分派:执行是基于函数的第一个参数的类型,而不是方法的签名(这是单分派,区别于多分派)。
  • 灵活扩展:即使是通过其他模块定义的自定义类型,你也可以向泛型函数添加额外的行为。
  • 优雅重用:通过动态注册特化函数,避免臃肿的 if-else巨大类设计

为什么要使用 @singledispatch

在之前的代码设计中,如果希望函数能根据参数类型提供不同的处理,很容易见到如下冗长的代码:

def htmlize(obj):
    if isinstance(obj, str):
        # 针对字符串的特化实现
        ...
    elif isinstance(obj, int):
        # 针对整数的特化实现
        ...
    elif isinstance(obj, bool):
        # 针对布尔值的特例
        ...
    elif isinstance(obj, list):
        # 针对列表的特化实现
        ...
    else:
        # 默认处理
        ...

这样写的问题是:

  1. 函数臃肿:函数变得太长,缺乏简洁性与可读性。
  2. 耦合性高:扩展新类型需要修改原函数,代码维护成本较高。
  3. 不符合开放封闭原则:需要为每种情况显式添加逻辑,缺少模块化扩展能力。

通过 @singledispatch,上述例子可以更优雅地设计:

from functools import singledispatch

@singledispatch
def htmlize(obj):
    # 默认实现
    return f'<pre>{html.escape(repr(obj))}</pre>'

# 针对字符串特化
@htmlize.register
def _(s: str):
    content = html.escape(s).replace('\n', '<br/>\n')
    return f'<p>{content}</p>'

# 针对整数特化
@htmlize.register
def _(n: int):
    return f'<pre>{n} (0x{n:x})</pre>'

通过直接注册特化函数,我们避免了巨大的 if-elif 块,且每种类型的逻辑分开定义,更加直观和清晰。

使用 @singledispatch

定义单分派基函数

使用 @singledispatch 装饰器装饰一个基础实现函数

基函数提供默认处理逻辑,这是其他未注册类型的参数的兜底实现。

示例代码:

from functools import singledispatch
import html

@singledispatch
def htmlize(obj: object) -> str:
    """默认行为:转义对象的 __repr__ 表示,并包裹在 <pre> 标签中"""
    content = html.escape(repr(obj))
    return f'<pre>{content}</pre>'

# 基函数可以直接使用
print(htmlize("Hello <World>"))  # '<pre>' 转义后的字符串 '</pre>'
>> '<pre>\'Hello &lt;World&gt;\'</pre>'

重点

  • 默认函数 必须定义,否则未注册处理逻辑的类型将抛出错误。
  • 函数签名的 第一个参数类型决定具体逻辑

注册特化函数

通过 <base_function>.register 注册特化函数,为不同类型定义专用逻辑。
同一函数可以注册多个类型,这些注册的函数将覆盖基函数对这些类型的处理。

示例代码:

# 针对字符串类型
@htmlize.register
def _(text: str) -> str:
    """HTML 转义字符串,换行符替换为 <br/>"""
    content = html.escape(text).replace('\n', '<br/>\n')
    return f'<p>{content}</p>'

# 针对整数类型
@htmlize.register
def _(n: int) -> str:
    """整数用十进制和十六进制表示"""
    return f'<pre>{n} (0x{n:x})</pre>'

对应执行效果:

print(htmlize("Line1\nLine2")) 
# -> '<p>Line1<br/>\nLine2</p>'

print(htmlize(42)) 
# -> '<pre>42 (0x2a)</pre>'

助记规范和技巧

  1. _ 命名惯例@htmlize.register 的特化函数名称不重要,通常使用 _ 占位标记。
  2. 参数类型支持类型注解
    • 基于类型注解进行区分。
    • 若无注解,则可直接在 .register 调用中指定类型。

特化函数的行为与优先级

  • singledispatch 按照 特化函数的类型匹配优先级 进行选择,对于更具体的类型优先于更抽象的类型。
  • 例外:boolint 的子类型,但在实践中 bool 会优先匹配,以避免布尔值被当作普通整数处理。

示例代码:

from numbers import Integral

@htmlize.register
def _(b: bool) -> str:
    return f'<pre>{b}</pre>'  # 布尔值专属逻辑

@htmlize.register
def _(n: Integral) -> str:
    """处理布尔值、整型等数值类型"""
    return f'<pre>{n} (0x{n:x})</pre>'

运行效果:

# 整数按十进制 + 十六进制:
print(htmlize(42)) 
# -> '<pre>42 (0x2a)</pre>'

# 布尔值单独处理:
print(htmlize(True)) 
# -> '<pre>True</pre>'

完整示例:实现 htmlize

以下是完整代码,与函数的处理逻辑分开,按需求灵活注册类型特化函数。

代码实现:

from collections.abc import Sequence
import fractions
import decimal
import html
from functools import singledispatch

# 基函数:默认处理
@singledispatch
def htmlize(obj: object) -> str:
    """默认行为:转义对象的 __repr__ 表示,并包裹在 <pre> 标签中"""
    content = html.escape(repr(obj))
    return f"<pre>{content}</pre>"

# 特化 1:处理字符串
@htmlize.register
def _(text: str) -> str:
    """针对字符串:HTML 转义 + 换行符替换成 <br/>"""
    content = html.escape(text).replace("\n", "<br/>\n")
    return f"<p>{content}</p>"

# 特化 2:处理序列(如列表、元组)
@htmlize.register
def _(seq: Sequence) -> str:
    """针对序列:生成嵌套的 HTML 列表"""
    inner = "</li>\n<li>".join(htmlize(item) for item in seq)  # 递归调用
    return f"<ul>\n<li>{inner}</li>\n</ul>"

# 特化 3:处理整数类型
@htmlize.register
def _(n: int) -> str:
    """针对整数:同时显示十进制和十六进制"""
    return f"<pre>{n} (0x{n:x})</pre>"

# 特化 4:处理布尔类型(布尔是整型的子类,但特化优先匹配更具体的类型)
@htmlize.register
def _(b: bool) -> str:
    """针对布尔值:只显示 True 或 False"""
    return f"<pre>{b}</pre>"

# 特化 5:处理分数(fractions.Fraction)
@htmlize.register
def _(x: fractions.Fraction) -> str:
    """针对分数:显示为分子/分母形式"""
    return f"<pre>{x.numerator}/{x.denominator}</pre>"

# 特化 6:处理浮点数和 Decimal(允许挂载多个类型)
@htmlize.register(float)
@htmlize.register(decimal.Decimal)
def _(x) -> str:
    """针对浮点数与十进制数:同时显示浮点值和分数近似值"""
    frac = fractions.Fraction(x).limit_denominator()  # 获取约分后的分数
    return f"<pre>{x} ({frac.numerator}/{frac.denominator})</pre>"

# 演示代码
print(htmlize("Heimlich & Co.\n- a game"))
# -> '<p>Heimlich &amp; Co.<br/>\n- a game</p>'

print(htmlize(42))
# -> '<pre>42 (0x2a)</pre>'

print(htmlize(True))
# -> '<pre>True</pre>'

print(htmlize([3.14, "pi", 42]))
# -> 输出:嵌套 HTML 列表结构
# <ul>
#   <li><pre>3.14 (22/7)</pre></li>
#   <li><p>pi</p></li>
#   <li><pre>42 (0x2a)</pre></li>
# </ul>

print(htmlize(fractions.Fraction(2, 3)))
# -> '<pre>2/3</pre>'

print(htmlize(decimal.Decimal("0.02380952")))
# -> '<pre>0.02380952 (1/42)</pre>'

核心要点与注意事项

在学习和使用 @singledispatch 时,你需牢记以下核心要点:

1. 处理顺序:优先级基于类型层级
  • singledispatch 按照类型从具体到抽象进行匹配。
    如:针对 boolint 子类),其特化函数将优先。
    如:使用抽象类(abc.ABC),也优先于更广泛的类型。

示例:

from numbers import Integral

@htmlize.register
def _(x: Integral):
    return f"<pre>{x} is an integer.</pre>"

@htmlize.register
def _(x: bool):
    return f"<pre>{x} is a boolean.</pre>"

print(htmlize(True))    # -> '<pre>True is a boolean.</pre>'
print(htmlize(88))      # -> '<pre>88 is an integer.</pre>'
2. 支持抽象基类(ABCs)

你可以使用 collections.abcnumbers 中的抽象基类(ABC)注册特化函数,这将增强代码的兼容性。

示例:

from collections.abc import MutableSequence

@htmlize.register
def _(seq: MutableSequence):
    return f"<pre>Mutable sequence with {len(seq)} items.</pre>"

print(htmlize([1, 2, 3]))  # list 是 MutableSequence 的子类
# -> '<pre>Mutable sequence with 3 items.</pre>'
3. 多种类型共享实现

通过在 .register 装饰器中显式指定类型,你可以将多个类型绑定到同一个特化函数。

示例:

@htmlize.register(float)
@htmlize.register(decimal.Decimal)
def _(x):
    frac = fractions.Fraction(x).limit_denominator()
    return f"<pre>{x} ({frac.numerator}/{frac.denominator})</pre>"

注意事项:
register 的参数可以是明确的类型或带类型注解的函数参数,二者效果相同。


单分派的优势和工程应用场景

相比传统的 if-elif 逻辑判断,@singledispatch 提供了以下关键优点:

优点:

  1. 模块化扩展:支持为外部模块或第三方库中定义的类型,动态添加特化函数(而不需要修改现有代码)。
  2. 高效且优雅:避免臃肿的分支结构,代码更具可读性。
  3. 面向未来设计:支持基于抽象基类或协议的动态扩展,最大限度利用 Python 的 Duck Typing 和类型系统。
  4. 避免命名冲突:特化函数可以完全分开定义,每个特化函数的名称不重要(通常命名为 _ 占位),减少命名污染。

场景应用举例:

  • 数据序列化工具:根据对象类型,将不同数据类型转换为 JSON、XML 或 HTML 等不同格式。
  • 调试和展示器:类似 htmlize 的例子用于渲染复杂对象为人类可读的 HTML、Markdown 等内容。
  • 插件化架构:例如,第三方库可以向现有的工具库扩展 singledispatch 函数以适配它们的自定义类型。

工程中的扩展模块化示例

想象一个更复杂的代码结构,我们需要为某些第三方模块或用户定义类型提供额外行为:

文件 core.py

from functools import singledispatch

@singledispatch
def process(obj):
    return f"Processing generic object: {obj}"

文件 extensions.py

from core import process

# 插件模块:扩展对第三方库 fractions.Fraction 的支持
import fractions

@process.register(fractions.Fraction)
def _(frac):
    return f"Fraction: {frac.numerator}/{frac.denominator}"

文件 main.py

from core import process
import extensions  # 自动加载扩展功能
import fractions

print(process(fractions.Fraction(3, 4)))
# -> 'Fraction: 3/4'

扩展的节点:你无需更改 core.py,而是通过模块间解耦完成功能增强


总结

本学习笔记介绍了 @singledispatch 的基本用法及实现流程:

  1. 使用基函数定义默认行为。
  2. 通过 .register 动态注册特定类型的处理逻辑。
  3. 注册时可利用抽象基类(ABC)或传入明确类型。
  4. 支持模块化扩展,适合实现动态调度与接口适配。

它是一种灵活而强大的 Python 工具,适用于复杂的多类型处理场景,请使用这份笔记随时温习该工具的用法。

10、Parameterized Decorators

1. A Parameterized Registration Decorator

一、如何让装饰器接受参数?

若要让装饰器接受参数,需要通过“装饰器工厂”实现。装饰器工厂本质上是一个返回装饰器的函数。

这可能显得复杂,我们通过例子分步骤进行展开:

例子 1:无需参数的装饰器回顾

registry = []

def register(func):
    registry.append(func)
    return func

使用方法:直接在函数上加 @register

例子 2:接受参数的装饰器(装饰器工厂)

需求场景:我们希望 @register 可以接受参数 active,只有 active=True 时函数才被注册。

实现

registry = set()  # 用集合而非列表,方便增删函数

def register(active=True):  # 装饰器工厂
    def decorate(func):  # 真正的装饰器函数
        print(f'running register(active={active})->decorate({func})')
        if active:  # 控制是否注册
            registry.add(func)
        else:
            registry.discard(func)
        return func
    return decorate  # 工厂返回装饰器

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

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

# 一个普通函数,无装饰
def f3():
    print("running f3()")

输出与解析

  1. 代码解释

    • register(active=False) 返回一个装饰器 decorate
    • 装饰器 decorate 是最终作用在 f1 等函数上的函数,具体行为会根据 active 参数条件执行逻辑。
    • active=True,将函数加入 registry;若为 False,从 registry 删除。
  2. 直接运行后的输出

    running register(active=False)->decorate(<function f1 at 0x102552a60>)
    running register(active=True)->decorate(<function f2 at 0x102552af0>)
    
    • f1 没有被注册,因为 active=False
    • f2 被成功注册到 registry
  3. 注册集内容

    >>> registration_param.registry
    {<function f2 at 0x...>}
    
    • 仅包含函数 f2

你观察得非常仔细!这里的关键在于 闭包(closure) 的机制。下面我会详细解释为什么在 decorate 函数中可以直接访问外层函数 registeractive 参数,而无需使用 nonlocal 声明。


为什么可以读取到参数

闭包的核心机制

Python 中,闭包函数(内部函数)会“记住”外层函数作用域的变量,即使外层函数已经执行完毕。这种“记忆”是通过隐式捕获外层变量实现的。具体来说:

  • register(active=True/False) 被调用时,参数 active 是外层函数 register 的局部变量。
  • 内部函数 decorate 引用了 active,因此 Python 会自动将 active 的值绑定到闭包中。
  • 无需 nonlocal,因为 decorate 只是读取 active 的值(而非修改它)。

为什么不需要 nonlocal

  1. 读取 vs 修改

    • 如果闭包函数 只读取 外层变量(如这里 decorate 读取 active),Python 会通过闭包自动捕获变量,无需声明。
    • 如果闭包函数 需要修改 外层变量(例如在 decorate 中修改 active),则必须用 nonlocal active 声明,否则 Python 会认为 activedecorate 的局部变量,导致错误。
  2. 此处的行为

    def register(active=True):
        def decorate(func):
            # 这里只是读取 active 的值,无需 nonlocal
            if active:  # 闭包捕获了 active 的值
                registry.add(func)
            else:
                registry.discard(func)
            return func
        return decorate
    
    • active 的值在 register 调用时确定,并被闭包 decorate 捕获。
    • decorate 不修改 active,只是读取它,因此无需 nonlocal

对比需要 nonlocal 的情况

假设我们想让装饰器动态修改 active 的状态(虽然这个例子中没必要,但假设需要):

def register(active=True):
    def decorate(func):
        nonlocal active  # 必须声明,因为要修改外层变量
        if active:
            registry.add(func)
        else:
            registry.discard(func)
        active = not active  # 修改 active 的值
        return func
    return decorate

此时,如果省略 nonlocal active,Python 会认为 activedecorate 的局部变量,但 active = not active 在赋值前被引用,会导致 UnboundLocalError

总结

  • 读取外层变量:闭包自动捕获,无需 nonlocal
  • 修改外层变量:必须用 nonlocal 声明。
  • 在你的代码中,decorate 只是读取 active 的值(不修改它),因此闭包机制直接生效,无需 nonlocal

二、动态调用装饰器

除了使用 @ 语法,也可以直接调用装饰器,尤其在需要动态操作时。

示例

>>> from registration_param import *

>>> registry
{<function f2 at 0x...>}  # 起始注册状态

>>> register()(f3)  # 添加 f3,到 registry
running register(active=True)->decorate(<function f3 at 0x...>)
<function f3 at 0x...>

>>> registry
{<function f2 at 0x...>, <function f3 at 0x...>}

>>> register(active=False)(f2)  # 从 registry 中移除 f2
running register(active=False)->decorate(<function f2 at 0x...>)
<function f2 at 0x...>

>>> registry
{<function f3 at 0x...>}

三、工程中常用补充案例

使用参数化装饰器的实际应用:

1. 函数权限控制

当我们构建一个 Web 应用程序时,可以用参数化装饰器指定只有特定用户角色能够访问某些函数:

def permission_required(role):
    def decorator(func):
        def wrapped(*args, **kwargs):
            user_role = kwargs.get('user_role', 'guest')
            if user_role != role:
                print(f"Permission denied for {user_role}")
                return
            return func(*args, **kwargs)
        return wrapped
    return decorator

@permission_required("admin")
def view_admin_panel(user_role=None):
    print("Welcome to admin panel")

view_admin_panel(user_role="guest")  # Permission denied
view_admin_panel(user_role="admin")  # Welcome to admin panel
2. 缓存功能

为函数添加缓存功能,以避免重复计算:

from functools import lru_cache

def cache_results(max_size=128):
    def decorator(func):
        cached_func = lru_cache(maxsize=max_size)(func)
        return cached_func
    return decorator

@cache_results(max_size=10)
def slow_function(x):
    print(f"Computing {x}...")
    return x * x

print(slow_function(4))  # Computing 4... 16
print(slow_function(4))  # 16 (结果从缓存中获取)

四、易错点与注意点

  1. 理解嵌套函数层级

    • 装饰器工厂: 接收外部参数,返回装饰器函数。
    • 装饰器:接收被装饰函数,返回被修改的函数。
    • 部分初学者易混淆嵌套函数层级。
  2. 不要忘记返回函数

    • 装饰器必须返回某个函数(通常返回原始函数或者经过修改/包装的函数)。
  3. 动态调用装饰器时语法

    • 动态使用:register()(f)register(active=False)(f)

2. The Parameterized Clock Decorator

1. 什么是参数化装饰器?

参数化装饰器允许你通过调用装饰器时传递参数,自定义装饰器的行为。例如,你可以通过格式化字符串,控制输出数据的样式。

代码分析:参数化 clock 装饰器

我们从 clock 装饰器的代码实现开始,逐步解析其概念和使用方法。

示例代码(clockdeco_param.py

import time

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

def clock(fmt=DEFAULT_FMT):  # 参数化的装饰器工厂
    def decorate(func):  # 真正的装饰器
        def clocked(*_args):  # 包装函数
            t0 = time.perf_counter()  # 记录开始时间
            _result = func(*_args)  # 调用被装饰的函数
            elapsed = time.perf_counter() - t0  # 计算运行时间
            name = func.__name__  # 获取函数名称
            args = ', '.join(repr(arg) for arg in _args)  # 格式化参数
            result = repr(_result)  # 格式化返回值
            print(fmt.format(**locals()))  # 打印格式化后的数据
            return _result  # 返回被装饰函数的结果
        return clocked  # 返回包装过的函数
    return decorate  # 返回装饰器

代码解读分步拆解

  1. 整体的结构

    • clock 是一个 装饰器工厂,其接受格式化字符串参数 fmt,返回一个真正的装饰器 decorate
    • decorate 是这个装饰器,是用来包装目标函数的。
    • clocked 是最终被调用的 包装函数,它记录时间、格式化输出,并返回被装饰函数的原始结果。
  2. 关键步骤详解

    • 格式化功能:通过 fmt.format(**locals()),我们可以动态插入局部变量 locals() 中的值,并生成字符串输出。
    • 静态检查工具的限制lint 工具可能会警告未使用的局部变量(如 elapsed, name 等),但为了保持代码简洁,作者选择直接使用 locals()
    • 时间记录和结果包装clocked 通过内置函数 time.perf_counter() 精确计算运行时间,并使用局部变量格式化打印。
  3. 调用逻辑回顾

    • clock -> decorate -> clocked
    • 每层返回的是下一层的函数,最终由 clocked 执行实际逻辑并输出格式化数据。
  4. 默认行为

    • @clock() 被调用时,fmt 参数被省略,此时使用默认的 DEFAULT_FMT

输出效果分析

运行 clockdeco_param.py

if __name__ == '__main__':
    @clock()  # 使用默认格式
    def snooze(seconds):
        time.sleep(seconds)

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

输出结果:

[0.12412500s] snooze(0.123) -> None
[0.12411904s] snooze(0.123) -> None
[0.12410498s] snooze(0.123) -> None
fmt.format(**locals())是什么

fmt.format(**locals()) 是 Python 中字符串格式化的一个高级用法,结合了 locals() 函数和字典解包操作。以下是详细解释:

1. locals() 的作用

locals() 是 Python 的内置函数,它会返回当前作用域的局部变量字典。例如:

def example():
    a = 1
    b = "hello"
    print(locals())  # 输出 {'a': 1, 'b': 'hello'}

example()

clocked 函数中,调用 locals() 会返回类似如下的字典:

{
    '_args': (1, 2),          # 函数参数
    't0': 12345.678,          # 开始时间
    '_result': 3,             # 原函数返回值
    'elapsed': 0.000123,      # 耗时
    'name': 'func_name',      # 被装饰函数名
    'args': '1, 2',           # 格式化后的参数
    'result': '3',            # 格式化后的返回值
    # ... 其他局部变量
}

2. ** 的作用(字典解包)

**locals() 会将 locals() 返回的字典解包为关键字参数。例如:

data = {'name': 'Alice', 'age': 30}
print("{name} is {age} years old".format(**data))
# 输出 "Alice is 30 years old"

3. fmt.format(**locals()) 的完整过程

假设用户调用装饰器时使用默认的 DEFAULT_FMT

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

当代码运行到 fmt.format(**locals()) 时:

  1. locals() 返回当前局部变量字典。

  2. **locals() 将字典解包为 format() 的关键字参数。

  3. format() 方法将 fmt 中的占位符替换为实际值:

    # 实际等效调用
    fmt.format(
        elapsed=0.000123,
        name='func_name',
        args='1, 2',
        result='3'
    )
    
  4. 最终生成的字符串类似:

    [0.00012300s] func_name(1, 2) -> 3
    

4. 动态格式化的优势

通过 fmt 参数,用户可以自定义输出格式:

# 示例 1:简略格式
@clock(fmt="{name} took {elapsed:0.2f}s")
def func(...):
    ...

# 输出:func took 0.00s

# 示例 2:仅显示结果
@clock(fmt="Result: {result}")
def func(...):
    ...

# 输出:Result: 42

5. 注意事项

  • 变量名必须匹配fmt 中的占位符(如 {elapsed})必须与 clocked 中的局部变量名完全一致。
  • 性能:频繁调用 locals() 可能有微小性能开销,但在此场景下可忽略。
  • 安全性:不要将 fmt 用于不可信的输入(可能引发 KeyError 或暴露变量)。

2. 参数化装饰器的实际案例

下面通过两个示例,展示如何传递不同的格式化字符串以调整输出效果。

示例 1:只输出函数名及耗时

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

输出示例:

snooze: 0.12414693832397461s
snooze: 0.1241159439086914s
snooze: 0.12412118911743164s
示例 2:包含函数参数及更精确的时间格式

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

输出示例:

snooze(0.123) dt=0.124s
snooze(0.123) dt=0.124s
snooze(0.123) dt=0.124s
代码优雅性与维护问题
  • 优雅性:通过 clock(fmt=...),用户可以方便地定义输出字符串的格式,而无需修改装饰器实现。

  • 代码维护:使用 locals() 虽然简洁易读,但静态检查工具可能会报错。如果需要更谨慎的实践,可以明确指定变量名:

    print(fmt.format(elapsed=elapsed, name=name, args=args, result=result))
    

3. A Class-Based Clock Decorator

装饰器通常分为两种实现方式:

  1. 基于函数的装饰器 — 定义一个函数,用来返回封装的函数。
  2. 基于类的装饰器 — 定义一个类,并通过 __call__ 方法,让其具备函数调用的行为。

本例子关注基于类的装饰器实现,并支持参数化。

主要代码解释(Example 9-27)

以下是完整代码部分:

import time

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

class clock:
    def __init__(self, fmt=DEFAULT_FMT):  # 初始化阶段,设置格式化字符串
        self.fmt = fmt  # 保存格式化模板到实例变量
    
    def __call__(self, func):  # 定义类变为可调用
        def clocked(*_args):  # 装饰后的函数
            t0 = time.perf_counter()  # 记录起始时间
            _result = func(*_args)  # 执行被装饰的函数,记录其结果
            elapsed = time.perf_counter() - t0  # 计算运行时间
            name = func.__name__  # 获取函数名称
            args = ', '.join(repr(arg) for arg in _args)  # 将函数位置参数序列化为字符串
            result = repr(_result)  # 将结果序列化为字符串
            print(self.fmt.format(**locals()))  # 使用类实例中的 fmt 输出格式化日志
            return _result  # 返回实际调用结果
        return clocked

代码逻辑详解

1. 类的构造函数 __init__

  • 初始化时将一个格式化字符串(默认值为 DEFAULT_FMT)存储在实例变量 self.fmt 中。
  • 允许用户自定义格式模板,例如传入其他日志格式。

2. 类的 __call__ 方法

  • __call__ 是特殊方法,使类的实例可以作为装饰器直接调用。
  • 如果传入一个函数 func__call__ 会将该函数封装在 clocked 内,并返回封装后的函数。

3. 嵌套函数 clocked

  • 保存被装饰函数的行为,同时额外添加了运行时间记录和格式化日志的功能。
  • _args 捕获了被装饰函数的所有位置参数(无关键字参数的部分);随后它们被用 repr(arg) 序列化。
  • 利用 locals() 将局部变量提取出来,便于直接与模板 fmt 匹配,最终简化打印日志的逻辑。

4. 输出日志

  • self.fmt.format(**locals()) 格式化输出日志。输出的日志内容包括:
    • 函数运行耗时 (elapsed);
    • 函数名称 (name);
    • 函数参数 (args);
    • 函数返回值 (result)。

对比基于函数的装饰器(Example 9-24)

原文提到,Example 9-24 实现了基于函数的clock装饰器。让我们简要回顾其实现如下:

import time

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

def clock(fmt=DEFAULT_FMT):  # 参数化装饰器
    def decorate(func):  # 接收被装饰的函数
        def clocked(*_args):  # 装饰后的函数
            t0 = time.perf_counter()
            _result = func(*_args)
            elapsed = time.perf_counter() - t0
            name = func.__name__
            args = ', '.join(repr(arg) for arg in _args)
            result = repr(_result)
            print(fmt.format(**locals()))
            return _result
        return clocked
    return decorate

关键区别:

  1. 实现方式不同

    • 基于函数的装饰器通过闭包实现,clock 返回一个嵌套函数。
    • 基于类的装饰器通过类的 __call__ 方法实现,且用到类成员存储状态。
    • 基于类的方式更适合需要保存装饰器相关配置或共享状态的场景(如多个函数共享某些计数器)。
  2. 参数传递的方式

    • 两者都支持参数化,但类的方式更容易支持复杂的配置(例如传入多个参数)。
  3. 代码结构和可扩展性

    • 基于函数的方式更简洁,直观,只针对单个函数增强。
    • 基于类的方式更具扩展性。例如可以通过类成员变量记录额外信息,甚至重载其他魔法方法实现更复杂的功能。

实际工程扩展(共享状态)

这是一个记录函数调用次数,并实现简单频率限制的装饰器示例:

import time

class RateLimiter:
    def __init__(self, max_calls, period):
        """
        初始化一个速率限制器。
        
        max_calls: 最大允许的调用次数
        period: 限制周期(秒)
        """
        self.max_calls = max_calls
        self.period = period
        self.call_times = []  # 用于记录调用时间戳
    
    def __call__(self, func):
        def wrapped(*args, **kwargs):
            current_time = time.time()
            # 移除超过时间段范围的调用记录
            self.call_times = [t for t in self.call_times if current_time - t < self.period]
            
            if len(self.call_times) >= self.max_calls:
                # 到达限制次数,拒绝调用
                raise RuntimeError(f"Rate limit exceeded: Max {self.max_calls} calls per {self.period} seconds.")
            
            # 记录当前调用时间
            self.call_times.append(current_time)
            
            # 执行被装饰函数
            return func(*args, **kwargs)
        
        return wrapped

测试代码

下面我们用装饰器 RateLimiter 限制 API 调用:

# 限制函数每秒最多调用 3 次
@RateLimiter(max_calls=3, period=1)
def api_call(n):
    print(f"API call {n} was successful!")


def test_rate_limiter():
    for i in range(5):
        try:
            api_call(i)
        except RuntimeError as e:
            print(e)
        time.sleep(0.2)  # 每 0.2 秒调用一次


test_rate_limiter()

输出示例

API call 0 was successful!
API call 1 was successful!
API call 2 was successful!
Rate limit exceeded: Max 3 calls per 1 seconds.
Rate limit exceeded: Max 3 calls per 1 seconds.

让我以你的代码为例,详细讲解如何利用 RateLimiter 装饰器配合共享状态实现功能,并通过执行顺序一步步分析为何能够实现调用次数限制。

我们着重从代码的执行顺序共享状态的作用出发进行讲解。

代码主要部分及其作用

  1. RateLimiter 是一个类装饰器,它通过 __call__ 方法包装目标函数的行为。
  2. call_timesRateLimiter 的实例属性,用于记录每次函数调用的时间戳(共享状态)。
  3. 每次调用被装饰的函数时,都会通过 RateLimiter 装饰器检查过去一段时间(period 秒)内的调用次数,如果超过限制(max_calls 次),就会抛出一个异常,否则允许调用。
  4. 测试函数逐步调用 api_call,以观察速率限制器的表现。

整体流程:执行顺序的拆解

下面是代码完整的执行顺序,以及每一步的实现细节。

1. 装饰器初始化

@RateLimiter(max_calls=3, period=1)
def api_call(n):
    print(f"API call {n} was successful!")

首先执行装饰器的 RateLimiter(max_calls=3, period=1)

对应动作:

  1. 类实例化

    • RateLimiter(max_calls=3, period=1) 调用 __init__ 方法。
    • __init__ 中:
      • self.max_calls = 3: 设置最大调用次数为 3。
      • self.period = 1: 设置限制周期为 1 秒。
      • self.call_times = []: 初始化一个空列表,用于存储调用时间戳(共享状态)。
    • @RateLimiter 在这里是一个 “对象实例”。
  2. 绑定函数

    • 调用 RateLimiter 实例的 __call__ 方法,传入目标函数 api_call 作为参数。
    • __call__ 方法返回内部定义的 wrapped 函数,这个 wrapped 函数取代原始的 api_call,即后续的 api_call 实际上调用的是 wrapped

完成之后,api_call 实际上是:

api_call = RateLimiter实例.__call__(api_call)

2. 测试函数开始运行

调用测试函数:

test_rate_limiter()

进入 test_rate_limiter 函数,每隔 0.2 秒调用一次 api_call(n),共调用 5 次。

总结执行顺序:

api_call(i) 中的每一次调用,都会执行装饰器返回的 wrapped 函数。

3. 第 1 次调用 api_call(0)

执行动作:

  1. 获取当前时间:假设当前时间戳为 t0 = 0.0
  2. 清理过期记录
    • self.call_times = [](初始为空)。
    • 保留近 self.period = 1 秒内的记录,无需清理。
  3. 检查调用次数
    • len(self.call_times) = 0,小于 max_calls = 3,允许调用。
  4. 记录当前时间
    • 把当前时间添加到 self.call_timesself.call_times = [0.0]
  5. 执行目标函数
    • 调用原函数 api_call(0),打印:API call 0 was successful!

4. 第 2 次调用 api_call(1)(0.2 秒后)

执行动作:

  1. 获取当前时间:假设当前时间戳为 t1 = 0.2
  2. 清理过期记录
    • 在当前时间窗口内(1 秒),无调用需要清理。
    • self.call_times = [0.0] 保持不变。
  3. 检查调用次数
    • len(self.call_times) = 1,小于 max_calls = 3,允许调用。
  4. 记录当前时间
    • 把当前时间添加到 self.call_timesself.call_times = [0.0, 0.2]
  5. 执行目标函数
    • 调用 api_call(1),打印:API call 1 was successful!

5. 第 3 次调用 api_call(2)(再过 0.2 秒)

执行动作:

  1. 获取当前时间:假设当前时间戳为 t2 = 0.4
  2. 清理过期记录
    • 在时间窗口内(1 秒),无调用需要清理。
    • self.call_times = [0.0, 0.2] 保持不变。
  3. 检查调用次数
    • len(self.call_times) = 2,小于 max_calls = 3,允许调用。
  4. 记录当前时间
    • 把当前时间添加到 self.call_timesself.call_times = [0.0, 0.2, 0.4]
  5. 执行目标函数
    • 调用 api_call(2),打印:API call 2 was successful!

6. 第 4 次调用 api_call(3)(再过 0.2 秒)

执行动作:

  1. 获取当前时间:假设当前时间戳为 t3 = 0.6
  2. 清理过期记录
    • 在时间窗口内(1 秒),无调用需要清理。
    • self.call_times = [0.0, 0.2, 0.4] 保持不变。
  3. 检查调用次数
    • len(self.call_times) = 3,已经达到 max_calls = 3
    • 超过限制,抛出异常:Rate limit exceeded: Max 3 calls per 1 seconds.
  4. 记录当前时间、执行函数
    • 函数未执行,直接抛出异常。

7. 第 5 次调用 api_call(4)(再过 0.2 秒)

执行动作:

  1. 获取当前时间:假设当前时间戳为 t4 = 0.8
  2. 清理过期记录
    • 清除过期时间戳(超过 1 秒的记录)。
    • 清理规则是保留 current_time - t < self.period 的时间戳:self.call_times = [0.2, 0.4, 0.6]
    • self.call_times 中最早时间 0.0 被移除。
  3. 检查调用次数
    • len(self.call_times) = 2,小于 max_calls = 3,允许调用。
  4. 记录当前时间
    • 把当前时间添加到 self.call_timesself.call_times = [0.2, 0.4, 0.8]
  5. 执行目标函数
    • 调用 api_call(4),打印:API call 4 was successful!

总结:为什么可以统计调用次数?

  • 共享状态 self.call_timescall_timesRateLimiter 实例的属性,记录了每次函数调用的时间戳。
  • 时间窗口动态更新:每次函数调用,call_times 中会清理掉不符合时间窗口(超过 self.period 的时间戳)的记录,确保其始终只包含最近 self.period 秒内的调用记录。
  • 判断调用次数是否超过限制:通过判断 call_times 的长度是否超过 max_calls 实现。

因此,利用共享状态 self.call_times 动态更新和存储调用记录,成功实现了函数调用的计数和频率限制!

重点

这里面调用了5次api_call函数,并不是创建了五次RateLimiter的实例变量,而是1次

下面我详细解释其中的逻辑,以及为什么创建的是一个RateLimiter实例变量而非多个实例变量。

装饰器的执行:绑定函数时只执行一次

当你写:

@RateLimiter(max_calls=3, period=1)
def api_call(n):
    print(f"API call {n} was successful!")

这是在函数定义阶段执行的,并非在函数调用时执行。整个过程如下:

  1. RateLimiter 类实例化

    • @RateLimiter(max_calls=3, period=1) 这一行会立即执行。
    • 会调用 RateLimiter 类的 __init__ 方法,创建 RateLimiter 的一个实例变量(假设为 rate_limiter_instance)。
    rate_limiter_instance = RateLimiter(max_calls=3, period=1)
    

    此时:

    • rate_limiter_instance.max_calls = 3
    • rate_limiter_instance.period = 1
    • rate_limiter_instance.call_times = [](空列表,用于共享调用时间戳)。
  2. 绑定装饰器

    • 之后, RateLimiter__call__ 方法会被调用,用来接收 api_call 函数作为参数。
    api_call = rate_limiter_instance.__call__(api_call)
    
    • __call__ 方法返回新的 wrapped 函数,代替原始的 api_call 函数。
    • 这个 wrapped 函数内部会引用 RateLimiter 的共享状态。

此时,整个装饰器的装配过程就已经完成。


调用的总执行流程(总结)

  1. 装饰器声明阶段

    • 创建了 1 个 RateLimiter 实例,负责装饰 api_call 函数。
  2. 函数调用阶段

    • 每次调用 api_call(i) 都会执行 wrapped 函数。
    • wrapped 函数引用了装饰器的实例变量 call_times,所有调用共享该状态进行计数和判断。
  3. 测试代码结果(5 次调用)

    • 第 1-3 次调用成功,call_times 累积到 3 个时间戳。
    • 第 4 次调用超出限制,抛出 RuntimeError
    • 第 5 次调用成功,因为早期的时间戳已经被清理。

为什么只创建 1 次实例?(深入理解)

这是装饰器的设计特性:

  • 装饰器的作用范围

    • 装饰器作用于函数本身,而不是函数的每次调用。换句话说,装饰器是在函数定义的时候执行,并为函数绑定新的功能。
  • 单实例的状态共享

    • 通过类实例存储共享状态,所有调用都引用这个实例,进而共享调用信息(如调用时间)。因此,我们仅需要 1 个装饰器实例来管理所有函数调用的限制规则。

这就是为什么我们调用 5 次 api_call,但只创建了 1 个 RateLimiter 实例。

4. 关于你可能没有弄懂的参数装饰器(langchain项目用法)

def tool(_func=None, *, param=888):
    def decorator(f):
        print(f"===> 执行 decorator(param={param}),包装函数 {f.__name__}")
        return f
    if _func is None:
        print("tool -> 没拿到函数,只能返回 decorator")
        return decorator
    else:
        print(f"tool -> 直接装饰 { _func.__name__ },马上返回 decorator(_func)")
        return decorator(_func)

@tool
def foo():
    pass

@tool(param=42)
def bar():
    pass


# tool -> 直接装饰 foo,马上返回 decorator(_func)
# ===> 执行 decorator(param=888),包装函数 foo
# tool -> 没拿到函数,只能返回 decorator
# ===> 执行 decorator(param=42),包装函数 bar

假设:

def deco(param):
    def inner(func):
        ...
        return ...
    return inner
  • 当执行 @deco(param=999),实际上首先 deco(param=999) 被调用,返回inner
  • 这时候 @inner 就等价于装饰目标函数,所以target = inner(target)

综上:

  • target = deco(param=999) 只是执行到了装饰器工厂,还没有作用到target函数;
  • target = deco(param=999)(target),即【先得到装饰器,再包裹函数】,才是Python装饰器链的实质过程。

装饰器写成@xxx,就相当于target = xxx(target)
装饰器写成@xxx(params),就相当于target = xxx(params)(target)

后面这个xxx(params)装饰器工厂,返回真正的包装函数。

11、Chapter Summary

本章我们涉足了一些颇具挑战性的领域。我尽力让这段旅程尽可能顺畅,但不可否认,我们已进入了元编程的范畴。

我们从一个没有内部函数的简单@register装饰器开始,最终以涉及两层嵌套函数的参数化@clock()装饰器收尾。
注册装饰器虽然本质简单,但在Python框架中有着实际应用。我们将在第10章的一个策略设计模式实现中应用注册思想。

理解装饰器的工作原理需要涵盖以下内容:

  • 导入时与运行时的区别
  • 变量作用域
  • 闭包
  • 新的nonlocal声明

掌握闭包和nonlocal不仅对构建装饰器至关重要,还能用于编写基于回调的GUI事件驱动程序或异步I/O程序,以及在合适场景下采用函数式编程风格。

参数化装饰器几乎总是涉及至少两层嵌套函数。如果想使用@functools.wraps生成对更高级技术(如示例9-18中的堆叠装饰器)支持更好的装饰器,可能需要更多层嵌套。对于更复杂的装饰器,基于类的实现可能更易读和维护。

作为标准库中参数化装饰器的示例,我们介绍了functools模块中强大的@cache@singledispatch装饰器。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值