深色模式
03 · 装饰器与闭包:不改代码,给函数加功能
学完你能说出:
- 闭包是什么:内层函数「记住」了外层函数的变量。
- 装饰器是什么:接收一个函数、返回一个增强版函数;
@只是语法糖。- 为什么要用
functools.wraps,带参数的装饰器为什么要三层。
先看地图
Python 模块第三篇。做 AI 应用经常要给调用大模型的函数加「失败重试」「计时」「缓存」「打日志」,这些都靠装饰器。FastAPI 的 @app.get("/") 也是装饰器。
本篇讲 4 个点:① 函数可以当参数传 → ② 闭包 → ③ 装饰器 → ④ 带参数的装饰器。
一句话本质
装饰器 = 一个「包装函数的函数」:把原函数包起来,在它执行前后加点事,再把包好的新函数还给你。
生活类比:给手机套手机壳
- 手机(原函数)本身不用改。
- 套上手机壳(装饰器),就多了防摔、支架的功能。
- 套了壳,手机还是那部手机,打电话照样打。
一步步看
1. 前置知识:函数也是一种值
python
def hello():
print("你好")
f = hello # 把函数赋给变量(注意没有括号,不是调用)
f() # → 你好函数能赋值、能当参数传、能当返回值,这是装饰器的基础。
2. 闭包:内层函数记住外层的变量
python
def make_counter():
count = 0 # 外层函数的局部变量
def inc():
nonlocal count # nonlocal = 非本地的:声明要改外层的 count
count += 1
return count
return inc # 返回内层函数本身
c = make_counter()
c() # → 1
c() # → 2 ← make_counter 早就执行完了,count 却还「活着」inc 带着它需要的外层变量 count 一起被返回了,这就叫闭包(Closure):函数 + 它记住的外部变量。
3. 装饰器:给函数计时
python
import time, functools
def timer(func): # 接收原函数
@functools.wraps(func) # wraps = 包装:保留原函数的名字和文档
def wrapper(*args, **kwargs): # wrapper = 包装器;*args, **kwargs = 接收任意参数
start = time.time()
result = func(*args, **kwargs) # 调用原函数
print(f"{func.__name__} 用时 {time.time() - start:.2f} 秒")
return result # 原函数的返回值照样返回
return wrapper # 返回增强版函数
@timer # 等价于:ask_llm = timer(ask_llm)
def ask_llm(question):
...@timer 就是 ask_llm = timer(ask_llm) 的简写(语法糖:写起来更甜、功能不变的写法)。之后调用 ask_llm(...),实际执行的是 wrapper,wrapper 内部再调用原来的函数。
这里的 wrapper 就是一个闭包:它记住了外层传进来的 func。
4. 带参数的装饰器:失败重试 N 次
python
def retry(times): # 第 1 层:接收装饰器自己的参数
def decorator(func): # 第 2 层:接收原函数
@functools.wraps(func)
def wrapper(*args, **kwargs): # 第 3 层:真正执行
for i in range(times):
try:
return func(*args, **kwargs)
except Exception:
if i == times - 1: # 最后一次还失败,就把错误抛出去
raise
return wrapper
return decorator
@retry(times=3) # 先执行 retry(3) 得到 decorator,再用它装饰函数
def call_api():
...为什么要三层? @retry(times=3) 是先调用 retry(3),它的返回值(decorator)才是真正的装饰器。所以比普通装饰器多一层。
为什么这么设计
为什么要用装饰器,而不是直接在函数里加代码?
- 问题:10 个调用 API 的函数都要加「重试 + 计时 + 日志」,每个函数里复制一遍,代码重复;要改重试次数还得改 10 处。
- 方案:把这些通用逻辑写成装饰器,需要的函数上面加一行
@。 - 好处:业务逻辑和通用功能分开;改一处,处处生效;不需要修改原函数。
一句话答法
装饰器把日志、计时、重试、权限等通用逻辑从业务代码里抽出来复用,不改原函数就能增加功能,改一处处处生效。
为什么要加 functools.wraps?
- 问题:装饰后,
ask_llm实际上变成了wrapper,它的名字(__name__)、文档字符串都变成了 wrapper 的。日志里全显示「wrapper」,调试、生成文档都会出错。 - 方案:用
@functools.wraps(func)把原函数的名字、文档等信息复制到 wrapper 上。 - 好处:装饰后的函数看起来仍然是原函数。
一句话答法
不加 wraps,被装饰函数的名字和文档会变成内部 wrapper 的,影响调试和文档;wraps 会把原函数的元信息复制过来。
英文术语拆解
| 术语 | 拆开看 | 一句话意思 |
|---|---|---|
| Decorator | decorate 装饰 + -or | 装饰器:包装函数、增加功能 |
| Closure | close 关闭 + -ure | 闭包:函数 + 它记住的外部变量 |
| wrapper | wrap 包裹 + -er | 包装器:装饰器内部的那个新函数 |
| nonlocal | non 非 + local 本地的 | 声明要修改外层函数的变量 |
*args / **kwargs | arguments 参数 / keyword arguments 关键字参数 | 接收任意数量的位置参数 / 关键字参数 |
| Syntactic Sugar | syntactic 语法的 + sugar 糖 | 语法糖:更方便、但功能不变的写法 |
| functools | function 函数 + tools 工具 | 函数相关的标准库 |
面试怎么问
Q:什么是装饰器?手写一个计时装饰器。
装饰器是接收一个函数并返回新函数的可调用对象,用于在不修改原函数的情况下扩展功能。
@deco等价于f = deco(f)。手写时内层 wrapper 用*args, **kwargs接收任意参数、调用原函数并返回结果,再用functools.wraps保留元信息。
Q:什么是闭包?
内层函数引用了外层函数的变量,外层函数返回后,这些变量仍被内层函数保存,函数加上这些被记住的变量就是闭包。装饰器里的 wrapper 就是闭包,它记住了 func。
Q:带参数的装饰器怎么写?为什么是三层?
最外层接收装饰器参数并返回真正的装饰器,中间层接收被装饰函数,最内层是 wrapper。因为
@retry(3)会先调用retry(3),它的返回值才会被用来装饰函数。
容易混的地方
@timer和@timer()不一样:前者把函数直接交给 timer;后者先调用timer(),再用它的返回值去装饰。用错了会报错。- 装饰器在定义函数时就执行了:
@timer那一行,在def执行时就把函数替换掉了,不是在调用时才执行。 - 改外层变量要用 nonlocal:只读不用写,要修改(比如
count += 1)就得声明,否则会报错。
和其他知识的连接
- ← 来自 迭代器、生成器与 yield:同样利用了「函数能保存状态」这个特性。
- → 影响 async/await 异步编程:异步函数也能被装饰,比如异步重试。
- → 影响 工具调用:很多 Agent 框架用
@tool装饰器,把普通函数注册成模型可调用的工具。
自测 3 问
Q1:@timer 放在 def ask_llm 上面,等价于什么代码?
点开看答案
等价于 ask_llm = timer(ask_llm):把原函数交给 timer,用 timer 返回的新函数(wrapper)替换掉原来的名字。
Q2:什么是闭包?用 make_counter 的例子说明。
点开看答案
内层函数 inc 用到了外层函数的变量 count。make_counter() 执行完、返回 inc 之后,count 并没有消失,而是被 inc 记住了,每次调用都在累加。函数加上它记住的外部变量就是闭包。
Q3:为什么写装饰器要加 functools.wraps?
点开看答案
不加的话,被装饰函数的 __name__、文档字符串都会变成内部 wrapper 的,日志、调试、文档生成都会出错。wraps 会把原函数的这些元信息复制到 wrapper 上。
✅ 验收清单
- 能用「手机壳」类比解释装饰器
- 能不看资料写出 make_counter 闭包,并解释 count 为什么还「活着」
- 能手写一个带
functools.wraps的计时装饰器 - 能手写带参数的重试装饰器,并解释为什么要三层
- 能讲清装饰器和 wraps 各自解决什么问题(问题 → 方案 → 好处)
下一步该懂什么
- 同时调用 100 次大模型 API,怎么写最高效?→ async/await 异步编程