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
核心概念:装饰器的执行时刻
关键点
-
何时执行装饰器:
装饰器在被装饰的函数定义后,立即被执行。这通常发生在模块被导入时,也即所谓的导入时间(import time)。 -
函数调用时间的区别:
被装饰的函数本身并不会在装饰器定义时被调用,而是只有在运行时(runtime)明确调用它时才会执行。 -
装饰器的执行特点:
- 装饰器本质上是一个接收函数(或类)对象作为参数并返回另一个函数(或类)对象的函数。
- 它可以对被装饰的函数进行注册、替换或增强。
示例讲解: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()
运行分析
运行代码时,可能有以下两种情况:
-
直接运行脚本:
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
。
- 装饰器在模块加载时立即执行,不依赖于
-
作为模块导入:
import registration
-
模块中的语句会在导入时运行,但
main()
不会被调用。 -
输出如下:
running register(<function f1 at 0x10063b1e0>) running register(<function f2 at 0x10063b268>)
解释:
- 类似上面的步骤,
@register
装饰器仍然被触发,将两个函数加入到registry
。 - 但是
main()
不会运行,因此f1
和f2
不会被调用。
- 类似上面的步骤,
-
如果检查
registry
列表的内容,可以看到:>>> import registration >>> registration.registry [<function f1 at 0x10063b1e0>, <function f2 at 0x10063b268>]
-
深入理解装饰器的执行顺序和内存分配流程
- 装饰器的执行顺序:为什么装饰器在函数定义后立即被执行,而不是等到函数调用时?
- 函数内存分配的时间点:为什么函数在这个时候已经有了内存地址?
要解答这个问题,我们需要从Python 的运行机制和装饰器的执行顺序入手,然后结合实际代码来分解整个流程,含装饰器的使用。
一、Python 的模块导入与执行流程
在 Python 中,文件(模块)的导入和执行有如下顺序:
-
逐行解释执行:
Python 是一门解释型语言。当我们执行一个脚本或导入一个模块时,Python 解释器会从上到下逐行读取并运行代码。 -
函数的定义:
遇到def func_name():
,Python 并不会立即执行函数的内容,而是会创建一个函数对象并在内部注册函数名(如f1
)。此时:- 函数体(函数内部的代码)还没执行。
- 函数的名字(如
f1
)会注册到当前作用域的符号表中,指向一个函数对象。 - 函数对象被分配内存地址(这就是打印
<function f1 at 0x...>
时看到的值)。
-
装饰器的执行:
遇到装饰器@decorator
时,会立即执行装饰器的代码,并将被装饰的函数对象作为参数传入装饰器。这就是装饰器的执行时机。 -
导入时间与运行时间的区分:
- 导入时间(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()
执行流程(逐行拆解)
-
解析模块:逐行处理代码
当我们运行python3 registration.py
时,Python 会从上到下逐行解释执行该脚本的代码。 -
registry = []
的定义- Python 创建了一个空列表
registry
。 - 此时,
registry
被注册到全局作用域,位于内存中。
- Python 创建了一个空列表
-
register
装饰器的定义- 此时
register
本质上是一个函数对象,其名字被注册到全局作用域。 - Python 创建了
register
函数对应的对象,并分配了内存地址(通过id(register)
可查看)。
- 此时
-
第一个被装饰的函数
f1
出现(执行@register
装饰器)- Python 遇到
@register
,立即执行register(f1)
:- Python 开始解析
f1
的定义。 - 创建一个函数对象
f1
,并分配内存地址(如:<function f1 at 0x100631bf8>
)。 - 将函数对象
f1
作为参数传递给register
。 register
中输出running register(<function f1 at 0x100631bf8>)
。- 函数
f1
被注册到registry
列表中(registry.append(f1)
)。
- Python 开始解析
- Python 遇到
-
第二个被装饰的函数
f2
出现(再次执行@register
装饰器)- 同样的处理:
- Python 解析
f2
,创建一个函数对象f2
,并分配内存地址(如:<function f2 at 0x100631c80>
)。 - 将函数对象
f2
传递给register
。 - 输出:
running register(<function f2 at 0x100631c80>)
。 f2
被注册到registry
列表中。
- Python 解析
- 同样的处理:
-
普通函数
f3
的定义- Python 遇到
def f3
,仅定义函数f3
并分配内存,但没有任何装饰器的执行过程。
- Python 遇到
-
主程序(
main()
)和脚本入口定义- Python 遇到
if __name__ == '__main__':
,检查条件是否为真。 - 如果当前文件被直接运行为脚本,则进入
main()
执行。
- Python 遇到
-
运行主程序
main()
- 输出
running main()
。 - 打印
registry ->
,此时可以看到registry
中存储了f1
和f2
的引用。 - 按顺序执行
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) 特性的一部分,其原理为:
-
函数定义时:动态创建函数对象
- 每次定义一个函数,Python 会动态创建一个对应的
function
对象,并存储在内存中。 - 指定的函数名(如
f1
)是一个全局变量,它只是指向函数对象的引用。 - 内存地址是由 Python(三种主实现之一,比如 CPython)的内存管理器动态分配的。
- 每次定义一个函数,Python 会动态创建一个对应的
-
确保函数对象在装饰器中可用
- 因为装饰器是一种函数调用,它需要接收函数对象作为参数。因此,在应用装饰器之前,函数对象必须先被创建并分配内存。
- 在装饰器执行时,
f1
和f2
的函数对象已经存在,并被传递给register
。
-
导入和执行的时机
- 导入时间(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
解释:
id(func)
显示函数对象的内存地址。- 这些地址在
f1
和f2
被传递给register
时,已经确定。
重点与易错点
-
装饰器的执行时机:
装饰器是在函数被定义时立即执行,而不是在函数调用时执行。- 常见误解:很多人可能以为装饰器的逻辑只有在函数运行时才会触发,这是错误的。
-
区分导入时间和运行时间:
- 导入时间(import time):
模块加载时,Python解释器会依次执行模块中的声明语句,此时会执行装饰器函数。 - 运行时间(runtime):
模块导入完毕后,只有明确地调用某个函数时,该函数才会执行。
- 导入时间(import time):
-
返回值的重要性:
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()
的结果完全被重定义。
总结
- 装饰器执行时机:
- 在函数定义时(导入时间)立即执行装饰器代码。
- 装饰器功能:
- 常用于动态注册、函数增强或替换。
- 易错点:
- 不要误认为装饰器是在函数调用时才被执行。
- 如果装饰器返回值不正确,可能导致程序调用错误。
- 最佳实践:
- 在实际工程中,装饰器常用于日志记录、权限管理、缓存机制等功能实现。
通过这些示例和补充内容,我们不仅理解了装饰器的执行顺序,还从实际开发角度知道如何灵活使用装饰器来增强代码效率与设计能力。
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)、及特殊关键字 global
和 nonlocal
的使用。
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 中的变量作用域通常包括两类:
-
全局作用域(Global Scope)
- 由模块(文件)中定义的变量构成。
- 全局变量可以在整个模块中访问,但在函数中默认是只读的,除非用
global
声明。
-
局部作用域(Local Scope)
- 在函数或代码块(如循环、条件语句)内部定义的变量。
- 局部变量在函数执行过程中创建,执行结束后销毁。
5. 函数 f1
与 f2
的字节码差异解析
Python 在编译时期会生成字节码,并决定变量的作用域。在这里,我们通过 dis
模块观察 f1
和 f2
的字节码指令,理解 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)。
闭包的核心点:
- 一定是嵌套函数(函数定义在另一个函数的内部)。
- 内层函数必须引用外层函数中的变量。
- 外层函数返回内层函数,让内层函数的“扩展作用域”继续存在。
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
的值最终是 2
(range(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 会优先寻找函数内部的局部变量,但此时还没有为
count
和total
分配内存(未定义),最终导致了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,count
和total
是外层(闭包)作用域的变量。- 通过
nonlocal
声明,我们可以对外层作用域的变量重新赋值,而不会导致创建新的本地变量。
三、Python 中的变量查找规则(作用域解析)
Python 按以下规则查找变量,从内到外逐层查找,直到找到目标变量或触发 NameError
异常:
1. 局部作用域 (Local Scope)
- 如果变量是函数的参数或在函数内赋值,那么它是局部变量。
- 如果试图赋值一个变量,则变量会被默认看成局部变量,除非声明为
nonlocal
或global
。
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
什么是装饰器?
装饰器的本质是一个函数,用于扩展或修改另一个函数的行为,而无需直接修改它的代码。通过装饰器,我们可以动态地向函数添加功能,同时保持其可读性和灵活性。
原理
一个简单的装饰器包括以下几个步骤:
- 定义装饰器函数:它接受一个函数作为参数(被装饰的函数)。
- 定义内部函数:内部函数执行扩展逻辑,并调用原函数。
- 返回内部函数:将内部函数替代被装饰的函数。
核心代码示例
以下代码展示了一个简单的装饰器,用于衡量函数运行时间。
示例 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
对两个函数 snooze
和 factorial
进行装饰,分别演示其效果。
示例 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
结果解析:
snooze(0.123)
:延迟运行了约0.12363791
秒。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
。
为什么原函数的参数可以传递给装饰器函数
-
参数绑定:
当你调用factorial(3)
时,解释器会将参数3
转换为位置参数元组。现在,参数信息会被传递给被调用的对象。- 原函数(未被装饰时)会直接将
3
绑定到n
,按照函数定义传入; - 装饰器替换后,调用的是
clocked
,则参数自动绑定给clocked(*args)
的args
。
换个理解方法,就是当你调用一个对象(如函数或装饰器生成的可调用对象)时,Python 将“捕获调用的参数”,并将其动态地传递给该对象的
__call__
协议或相关函数定义的参数列表。 - 原函数(未被装饰时)会直接将
-
动态分发:
对于clocked(*args)
:- Python 根据调用的参数个数,将所有的位置参数将被打包成元组,赋值给
args
。在本例中,args = (3,)
。 - 这一步完全由函数的参数机制处理(即,
def
语法自动支持拆解调用时参数集合)。
- Python 根据调用的参数个数,将所有的位置参数将被打包成元组,赋值给
-
回调原函数:
接着,clocked
内会将参数传递给原函数:result = func(*args)
此时,
func
是原函数factorial
,*args
会将元组args
解包为普通参数,再传递。等价于:
factorial(3)
这是一种 动态参数绑定和分发的机制,Python 保证了在函数调用链中,参数可以完整地从装饰器一路透传到原函数。
-
执行返回结果:
最后,factorial
返回值,装饰器处理后也可以作为结果返回。 -
调用原始函数字节码
字节码:
7 8 LOAD_DEREF 0 (func)
10 LOAD_FAST 0 (args)
12 CALL_FUNCTION_EX 0
14 STORE_FAST 2 (result)
功能说明:
-
原始函数加载:
LOAD_DEREF 0 (func)
加载闭包中捕获的变量func
。此func
正是clock(factorial)
装饰后的参数,也就是原始的factorial
函数。- 在这里,
func
的值最初绑定了原始的factorial
。
-
参数捕获与解包:
-
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
。
- 因为
-
-
返回值存储:
STORE_FAST 2 (result)
将func(*args)
的返回值存储到变量result
中。这里result
是计算得到的阶乘结果。
改进装饰器
问题
当前装饰器存在以下不足:
- **不支持关键字参数:**现有实现只支持位置参数,不支持
**kwargs
。 - **丢失原函数元数据:**替换后,原函数的信息(如
__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 提供了一些内建的装饰器,比如 staticmethod
、classmethod
和 property
,这些会被用在不同语境中修饰方法或属性。此外,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. cache
和 lru_cache
的实际应用场景
-
递归问题优化:
- 用于优化类似斐波那契数列、动态规划等问题。
- 示例:解决子问题的结果可以重复利用。
-
远程调用减少重复请求:
-
避免在短时间内多次调用代价高或延迟大的 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()
-
-
合适的缓存周期与策略:
- 如果程序是短生命周期脚本,
functools.cache
可以直接使用。 - 如果程序是长生命周期应用,推荐使用
functools.lru_cache
并配置maxsize
。
- 如果程序是短生命周期脚本,
7. 总结
特性 | functools.cache | functools.lru_cache |
---|---|---|
引入版本 | Python 3.9 | Python 3.2 |
参数设置 | 无 | 支持 maxsize 和 typed 参数 |
缓存策略 | 无限制,缓存所有调用 | 基于 LRU 算法的缓存管理 |
使用场景 | 短生命周期脚本 | 长生命周期应用,需控制缓存大小 |
建议:
- 开发短脚本时优先使用
functools.cache
,简单、高效。 - 如果缓存条目的数量可能很大,或者需要支持 Python 3.8 以下版本,则使用
lru_cache
并设置合理的maxsize
。
2. Single Dispatch Generic Functions
什么是 singledispatch
?
得益于 functools.singledispatch
,你可以创建一个泛型函数(generic function),该函数的行为取决于其 第一个参数 的类型。
类似于面向对象方法的多态,但在函数级别实现。对于不同的类型,@singledispatch
会自动调用注册的特殊函数,从而避免了使用长长的 if/elif/else
或 match/case
块。
核心功能
- 单分派:执行是基于函数的第一个参数的类型,而不是方法的签名(这是单分派,区别于多分派)。
- 灵活扩展:即使是通过其他模块定义的自定义类型,你也可以向泛型函数添加额外的行为。
- 优雅重用:通过动态注册特化函数,避免臃肿的
if-else
或巨大类设计
。
为什么要使用 @singledispatch
?
在之前的代码设计中,如果希望函数能根据参数类型提供不同的处理,很容易见到如下冗长的代码:
def htmlize(obj):
if isinstance(obj, str):
# 针对字符串的特化实现
...
elif isinstance(obj, int):
# 针对整数的特化实现
...
elif isinstance(obj, bool):
# 针对布尔值的特例
...
elif isinstance(obj, list):
# 针对列表的特化实现
...
else:
# 默认处理
...
这样写的问题是:
- 函数臃肿:函数变得太长,缺乏简洁性与可读性。
- 耦合性高:扩展新类型需要修改原函数,代码维护成本较高。
- 不符合开放封闭原则:需要为每种情况显式添加逻辑,缺少模块化扩展能力。
通过 @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 <World>\'</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>'
助记规范和技巧
_
命名惯例:@htmlize.register
的特化函数名称不重要,通常使用_
占位标记。- 参数类型支持类型注解:
- 基于类型注解进行区分。
- 若无注解,则可直接在
.register
调用中指定类型。
特化函数的行为与优先级
singledispatch
按照 特化函数的类型匹配优先级 进行选择,对于更具体的类型优先于更抽象的类型。- 例外:
bool
是int
的子类型,但在实践中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 & 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
按照类型从具体到抽象进行匹配。
如:针对bool
(int
子类),其特化函数将优先。
如:使用抽象类(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.abc
或 numbers
中的抽象基类(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
提供了以下关键优点:
优点:
- 模块化扩展:支持为外部模块或第三方库中定义的类型,动态添加特化函数(而不需要修改现有代码)。
- 高效且优雅:避免臃肿的分支结构,代码更具可读性。
- 面向未来设计:支持基于抽象基类或协议的动态扩展,最大限度利用 Python 的 Duck Typing 和类型系统。
- 避免命名冲突:特化函数可以完全分开定义,每个特化函数的名称不重要(通常命名为
_
占位),减少命名污染。
场景应用举例:
- 数据序列化工具:根据对象类型,将不同数据类型转换为 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
的基本用法及实现流程:
- 使用基函数定义默认行为。
- 通过
.register
动态注册特定类型的处理逻辑。 - 注册时可利用抽象基类(ABC)或传入明确类型。
- 支持模块化扩展,适合实现动态调度与接口适配。
它是一种灵活而强大的 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()")
输出与解析
-
代码解释:
register(active=False)
返回一个装饰器decorate
。- 装饰器
decorate
是最终作用在f1
等函数上的函数,具体行为会根据active
参数条件执行逻辑。 - 若
active=True
,将函数加入registry
;若为False
,从registry
删除。
-
直接运行后的输出:
running register(active=False)->decorate(<function f1 at 0x102552a60>) running register(active=True)->decorate(<function f2 at 0x102552af0>)
f1
没有被注册,因为active=False
。f2
被成功注册到registry
。
-
注册集内容:
>>> registration_param.registry {<function f2 at 0x...>}
- 仅包含函数
f2
。
- 仅包含函数
你观察得非常仔细!这里的关键在于 闭包(closure) 的机制。下面我会详细解释为什么在 decorate
函数中可以直接访问外层函数 register
的 active
参数,而无需使用 nonlocal
声明。
为什么可以读取到参数
闭包的核心机制
Python 中,闭包函数(内部函数)会“记住”外层函数作用域的变量,即使外层函数已经执行完毕。这种“记忆”是通过隐式捕获外层变量实现的。具体来说:
- 当
register(active=True/False)
被调用时,参数active
是外层函数register
的局部变量。 - 内部函数
decorate
引用了active
,因此 Python 会自动将active
的值绑定到闭包中。 - 无需
nonlocal
,因为decorate
只是读取active
的值(而非修改它)。
为什么不需要 nonlocal
?
-
读取 vs 修改:
- 如果闭包函数 只读取 外层变量(如这里
decorate
读取active
),Python 会通过闭包自动捕获变量,无需声明。 - 如果闭包函数 需要修改 外层变量(例如在
decorate
中修改active
),则必须用nonlocal active
声明,否则 Python 会认为active
是decorate
的局部变量,导致错误。
- 如果闭包函数 只读取 外层变量(如这里
-
此处的行为:
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 会认为 active
是 decorate
的局部变量,但 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 (结果从缓存中获取)
四、易错点与注意点
-
理解嵌套函数层级:
- 装饰器工厂: 接收外部参数,返回装饰器函数。
- 装饰器:接收被装饰函数,返回被修改的函数。
- 部分初学者易混淆嵌套函数层级。
-
不要忘记返回函数:
- 装饰器必须返回某个函数(通常返回原始函数或者经过修改/包装的函数)。
-
动态调用装饰器时语法:
- 动态使用:
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 # 返回装饰器
代码解读分步拆解
-
整体的结构:
clock
是一个 装饰器工厂,其接受格式化字符串参数fmt
,返回一个真正的装饰器decorate
。decorate
是这个装饰器,是用来包装目标函数的。clocked
是最终被调用的 包装函数,它记录时间、格式化输出,并返回被装饰函数的原始结果。
-
关键步骤详解:
- 格式化功能:通过
fmt.format(**locals())
,我们可以动态插入局部变量locals()
中的值,并生成字符串输出。 - 静态检查工具的限制:
lint
工具可能会警告未使用的局部变量(如elapsed
,name
等),但为了保持代码简洁,作者选择直接使用locals()
。 - 时间记录和结果包装:
clocked
通过内置函数time.perf_counter()
精确计算运行时间,并使用局部变量格式化打印。
- 格式化功能:通过
-
调用逻辑回顾:
clock
->decorate
->clocked
- 每层返回的是下一层的函数,最终由
clocked
执行实际逻辑并输出格式化数据。
-
默认行为:
- 当
@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())
时:
-
locals()
返回当前局部变量字典。 -
**locals()
将字典解包为format()
的关键字参数。 -
format()
方法将fmt
中的占位符替换为实际值:# 实际等效调用 fmt.format( elapsed=0.000123, name='func_name', args='1, 2', result='3' )
-
最终生成的字符串类似:
[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
装饰器通常分为两种实现方式:
- 基于函数的装饰器 — 定义一个函数,用来返回封装的函数。
- 基于类的装饰器 — 定义一个类,并通过
__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
关键区别:
-
实现方式不同
- 基于函数的装饰器通过闭包实现,
clock
返回一个嵌套函数。 - 基于类的装饰器通过类的
__call__
方法实现,且用到类成员存储状态。 - 基于类的方式更适合需要保存装饰器相关配置或共享状态的场景(如多个函数共享某些计数器)。
- 基于函数的装饰器通过闭包实现,
-
参数传递的方式
- 两者都支持参数化,但类的方式更容易支持复杂的配置(例如传入多个参数)。
-
代码结构和可扩展性
- 基于函数的方式更简洁,直观,只针对单个函数增强。
- 基于类的方式更具扩展性。例如可以通过类成员变量记录额外信息,甚至重载其他魔法方法实现更复杂的功能。
实际工程扩展(共享状态)
这是一个记录函数调用次数,并实现简单频率限制的装饰器示例:
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
装饰器配合共享状态实现功能,并通过执行顺序一步步分析为何能够实现调用次数限制。
我们着重从代码的执行顺序和共享状态的作用出发进行讲解。
代码主要部分及其作用
RateLimiter
是一个类装饰器,它通过__call__
方法包装目标函数的行为。call_times
是RateLimiter
的实例属性,用于记录每次函数调用的时间戳(共享状态)。- 每次调用被装饰的函数时,都会通过
RateLimiter
装饰器检查过去一段时间(period
秒)内的调用次数,如果超过限制(max_calls
次),就会抛出一个异常,否则允许调用。 - 测试函数逐步调用
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)
。
对应动作:
-
类实例化:
RateLimiter(max_calls=3, period=1)
调用__init__
方法。- 在
__init__
中:self.max_calls = 3
: 设置最大调用次数为 3。self.period = 1
: 设置限制周期为 1 秒。self.call_times = []
: 初始化一个空列表,用于存储调用时间戳(共享状态)。
@RateLimiter
在这里是一个 “对象实例”。
-
绑定函数:
- 调用
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)
执行动作:
- 获取当前时间:假设当前时间戳为
t0 = 0.0
。 - 清理过期记录:
self.call_times = []
(初始为空)。- 保留近
self.period = 1
秒内的记录,无需清理。
- 检查调用次数:
len(self.call_times) = 0
,小于max_calls = 3
,允许调用。
- 记录当前时间:
- 把当前时间添加到
self.call_times
:self.call_times = [0.0]
。
- 把当前时间添加到
- 执行目标函数:
- 调用原函数
api_call(0)
,打印:API call 0 was successful!
- 调用原函数
4. 第 2 次调用 api_call(1)
(0.2 秒后)
执行动作:
- 获取当前时间:假设当前时间戳为
t1 = 0.2
。 - 清理过期记录:
- 在当前时间窗口内(1 秒),无调用需要清理。
self.call_times = [0.0]
保持不变。
- 检查调用次数:
len(self.call_times) = 1
,小于max_calls = 3
,允许调用。
- 记录当前时间:
- 把当前时间添加到
self.call_times
:self.call_times = [0.0, 0.2]
。
- 把当前时间添加到
- 执行目标函数:
- 调用
api_call(1)
,打印:API call 1 was successful!
- 调用
5. 第 3 次调用 api_call(2)
(再过 0.2 秒)
执行动作:
- 获取当前时间:假设当前时间戳为
t2 = 0.4
。 - 清理过期记录:
- 在时间窗口内(1 秒),无调用需要清理。
self.call_times = [0.0, 0.2]
保持不变。
- 检查调用次数:
len(self.call_times) = 2
,小于max_calls = 3
,允许调用。
- 记录当前时间:
- 把当前时间添加到
self.call_times
:self.call_times = [0.0, 0.2, 0.4]
。
- 把当前时间添加到
- 执行目标函数:
- 调用
api_call(2)
,打印:API call 2 was successful!
- 调用
6. 第 4 次调用 api_call(3)
(再过 0.2 秒)
执行动作:
- 获取当前时间:假设当前时间戳为
t3 = 0.6
。 - 清理过期记录:
- 在时间窗口内(1 秒),无调用需要清理。
self.call_times = [0.0, 0.2, 0.4]
保持不变。
- 检查调用次数:
len(self.call_times) = 3
,已经达到max_calls = 3
。- 超过限制,抛出异常:
Rate limit exceeded: Max 3 calls per 1 seconds.
- 记录当前时间、执行函数:
- 函数未执行,直接抛出异常。
7. 第 5 次调用 api_call(4)
(再过 0.2 秒)
执行动作:
- 获取当前时间:假设当前时间戳为
t4 = 0.8
。 - 清理过期记录:
- 清除过期时间戳(超过 1 秒的记录)。
- 清理规则是保留
current_time - t < self.period
的时间戳:self.call_times = [0.2, 0.4, 0.6]
。 self.call_times
中最早时间0.0
被移除。
- 检查调用次数:
len(self.call_times) = 2
,小于max_calls = 3
,允许调用。
- 记录当前时间:
- 把当前时间添加到
self.call_times
:self.call_times = [0.2, 0.4, 0.8]
。
- 把当前时间添加到
- 执行目标函数:
- 调用
api_call(4)
,打印:API call 4 was successful!
- 调用
总结:为什么可以统计调用次数?
- 共享状态
self.call_times
:call_times
是RateLimiter
实例的属性,记录了每次函数调用的时间戳。 - 时间窗口动态更新:每次函数调用,
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!")
这是在函数定义阶段执行的,并非在函数调用时执行。整个过程如下:
-
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 = []
(空列表,用于共享调用时间戳)。
-
绑定装饰器:
- 之后,
RateLimiter
的__call__
方法会被调用,用来接收api_call
函数作为参数。
api_call = rate_limiter_instance.__call__(api_call)
__call__
方法返回新的wrapped
函数,代替原始的api_call
函数。- 这个
wrapped
函数内部会引用RateLimiter
的共享状态。
- 之后,
此时,整个装饰器的装配过程就已经完成。
调用的总执行流程(总结)
-
装饰器声明阶段:
- 创建了 1 个
RateLimiter
实例,负责装饰api_call
函数。
- 创建了 1 个
-
函数调用阶段:
- 每次调用
api_call(i)
都会执行wrapped
函数。 wrapped
函数引用了装饰器的实例变量call_times
,所有调用共享该状态进行计数和判断。
- 每次调用
-
测试代码结果(5 次调用):
- 第 1-3 次调用成功,
call_times
累积到 3 个时间戳。 - 第 4 次调用超出限制,抛出
RuntimeError
。 - 第 5 次调用成功,因为早期的时间戳已经被清理。
- 第 1-3 次调用成功,
为什么只创建 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
装饰器。