函数对象 · 闭包 · cell · 装饰器 · functools.wraps · 模块命名空间 · monkeypatch
函数是一等公民:闭包和装饰器
Python 的函数是普通对象,能放进字典、当参数传、当返回值。闭包是函数带着它用到的外层变量一起走;装饰器就是 f = deco(f):import 时执行,把名字 f 换成 deco 返回的东西。FastAPI 的 @app.get 靠它在 import 时登记路由,pytest 的 monkeypatch 靠“名字可以重新绑定”换掉函数。不懂这一点的典型错误是把装饰器当成 Java 注解:顺序写反、忘了 functools.wraps,接口就悄悄失效,而且不报错。
全景:一个 FastAPI 请求的一生 · 第 3 站(还有第 10 站)地图:站 01 · 函数是一等公民:闭包和装饰器以 CPython 3.13、FastAPI 0.162.2 为例
①为什么需要:请求来之前,路由表就已经建好了
在全景第 3 站,这一步要解决的是:curl 打来 /gc/summary,FastAPI 怎么知道该调哪个函数?答案在请求之前:uvicorn import main.py 的那一刻,@app.get(...) 已经作为一行普通代码执行过,把函数登记进了路由表。第 10 站是同一件事的反面:测试里要把真的 fetch 换成假的,靠的是“函数就是一个值,名字可以改绑到别的值上”。
@app.get、pytest 的 @pytest.fixture、站 04 给 agent 登记工具的 @tool。只会照抄,换个写法就会踩 ⑥ 里那几个不报错的坑。②没有它会怎样:Java 要靠类、注解和容器
同一件事,“把一个处理函数登记到路径上”,三种写法并排。
router.register("/gc/summary",
new Handler() {
@Override
public Object handle(Req r) {
return gcSummary(r);
}
});
@GetMapping("/gc/summary")
public GcSummary gcSummary(
@RequestParam String service) {
...
}
// 启动时 Spring 用反射扫描所有 Bean,
// 读到注解,才把方法登记进 HandlerMapping
@app.get("/gc/summary")
async def gc_summary(service: str):
...
# 等价于:
# gc_summary = app.get("/gc/summary")(gc_summary)
# import 到这一行就执行,没有容器、没有扫描
app.get(...) 拿到它就能登记。- 没有一等函数,传一段逻辑就要先定义接口、再写类。Java 8 的 lambda 简化了写法,但它仍然是某个函数式接口的实例。
- 注解只是元数据,要有人在运行时用反射去扫描它,这个“人”就是 Spring 容器。所以 Spring 的行为取决于容器怎么处理,比如自调用不走代理。
- Python 的函数本身是对象,装饰器是一次普通的函数调用。精华:
@deco就是f = deco(f)。顺序、返回值、执行时机,全都能从这一句推出来。
把装饰器当成注解来理解,是最常见的误用:以为写在哪一行都一样、以为它只“标记”不“替换”。⑥ 里的坑 1、2、3 全是这么来的。
③相邻方案:给函数加一层行为,有四种做法
比如给第 8 站的解析加上计时,或者给第 7 站的 fetch 加上重试。
手动包一层
gc_summary = timed(gc_summary)
和 @timed 完全等价,只是没用语法糖。
要按条件决定包不包时用,比如只在调试时计时。
装饰器
@timed
async def gc_summary(...): ...
写在定义处,一眼看出这个函数被加了什么。
默认选它:只作用于少数几个函数。
中间件
@app.middleware("http")
async def timing(req, call_next): ...
≈ Servlet Filter,每个请求都经过。
所有接口都要的事:日志、跟踪 ID、统一计时。
可调用的类
class Retry:
def __call__(self, fn): ...
实现了 __call__ 的对象也能当装饰器。
要带很多配置或状态时用,比如限流器。
本页学:函数对象、闭包,以及装饰器的两种用法:登记型(@app.get,原样返回函数)和包装型(@timed,返回一个新函数)。Java 那边对照 Spring AOP 代理原理。
④原理:名字指向对象,闭包带着 cell 走,@ 就是重新绑定名字
四张图,从下往上搭。⑥ 里的 8 个坑都能从这四张图推出来。
- 名字和对象是两回事。
def parse(...)做了两件事:造一个函数对象,再把名字parse贴到它上面。a = b只是多贴一个名字,和上一页讲的列表一样。见 列表存引用。 - 一个模块就是一个字典。
main.py里所有顶层的名字,都存在main.__dict__这个字典里,main.fetch就是查这个字典。 - 查名字的顺序叫 LEGB:先查函数自己的局部变量(Local),再查外层函数(Enclosing),再查模块(Global),最后查内置(Builtins)。每次执行到这个名字时才去查。
1函数是对象:能存、能传、能返回,身上还带着属性 ≈ 不需要接口的 lambda
Function、Runnable……)的实例;Python 的函数不需要接口,任何地方都能直接放。__defaults__ 存在函数对象上、只算一次,这是 ⑥ 坑 5 的根源。2闭包:外层函数返回了,它的变量装进 cell 跟着内层函数走 ≈ lambda 捕获局部变量
Python:捕获变量本身选了它
def counter():
n = 0
def inc():
nonlocal n # 声明要改外层的 n
n += 1
return n
return inc
- 闭包里能改外层变量,写计数器、缓存很顺手。
- 代价:迟绑定(右上图),以及忘了
nonlocal就报UnboundLocalError(⑥ 坑 6)。
Java:只许捕获不变的值没选
int limit = 200;
Predicate<Double> slow = ms -> ms > limit;
limit = 300; // 编译错误:lambda 用到的局部
// 变量必须是 final 或等效 final
- 捕获时复制一份值,没有迟绑定,也没有并发修改的问题。
- 想在 lambda 里计数,只能绕路用
AtomicInteger或长度为 1 的数组。
3装饰器:@deco 就是 f = deco(f),从下往上执行,在 import 时 ≈ Spring AOP,但不用代理
- 1
timed(gc_summary)返回一个新函数wrapper,它的闭包里存着原函数。这是包装型装饰器。 - 2
app.get(...)先执行,返回一个装饰器;再拿wrapper去调用它,记进路由表,原样还回来。这是登记型装饰器。 - 3最后名字
gc_summary指向wrapper。路由表里存的也是wrapper,所以每次请求都会计时。 - 4这些都发生在 import 时。⑤ 里会看到,打印“登记进路由表”比打印任何请求结果都早。
functools.wraps,把签名“透”出去。装饰器实验台:三个选择,八种结果,全部是本机 FastAPI 实测
@functools.wraps(fn)先猜:GET /x?service=order(端点里 await asyncio.sleep(0.1))会怎样?
4monkeypatch:改的是某个模块字典里的一个名字 ≈ 换掉 Spring 容器里的 Bean
from main import fetch 不是“链接到 main.fetch”,而是“把此刻 main 里 fetch 指向的对象,在我这里再贴一个同名标签”。所以 monkeypatch 要改“调用方查名字的那个模块”,这就是 ⑥ 坑 7。5为什么这样设计:Python 改名字,Spring 造代理
Python 装饰器:定义时把名字换掉选了它
- 没有容器、没有反射、没有代理类,一行函数调用就够。
- 名字被换掉了,所以模块内部的调用也走包装:
summary()里调parse(),两个都被记了下来,['summary', 'parse']实测。 - 代价:顺序有讲究,import 有副作用,签名要自己用
wraps透出去。
Spring AOP:容器启动时生成代理没选
- 原来的类一个字都不用改,切面可以按规则批量套。
- 只有“经过代理”的调用才生效:类里
this.parse()自调用绕过代理,@Transactional失效是老坑。 - 离不开容器,
new出来的对象没有切面。
决定胜负的约束:Python 没有一个统一管理对象的容器,但所有名字都存在字典里,而函数又只是字典里的值。在这个前提下,最简单的办法就是“把字典里那个名字换成包装过的函数”。Java 的方法长在类上、不能单独替换,只好在外面再造一个代理对象。详见 Spring AOP 代理原理。
最少的语法:Java 怎么写,Python 怎么写 带参数的装饰器、版本差异放到 ⑥
| Java | Python | 说明 |
|---|---|---|
Predicate<Double> p = ms -> ms > 200; | p = lambda ms: ms > 200 | lambda 只能写一个表达式,复杂的用 def |
list.sort(comparing(this::key)) | sorted(xs, key=key) | 直接把函数传进去 |
Map<String, Handler> | {"gc": gc_summary} | 函数当字典的值 |
| 返回一个 lambda | def over(limit): def check(ms): ...; return check | 闭包 |
AtomicInteger 在 lambda 里计数 | nonlocal n | 改外层变量要声明 |
Object... args | *args / **kwargs | 收任意位置参数 / 任意关键字参数 |
@Around 切面 | def deco(fn): ... return wrapper | 包装型装饰器 |
@GetMapping + 容器扫描 | def deco(fn): registry[k] = fn; return fn | 登记型装饰器 |
| — | @functools.wraps(fn) | 包装型装饰器必加 |
| Mockito 替换 Bean | monkeypatch.setattr(main, "fetch", fake) | pytest 提供,测完自动改回去 |
Function.andThen / 绑定参数 | functools.partial(fetch, client) | 先填好一部分参数 |
⑤亲眼看到:自己写一个迷你 @app.get,再拿真的 FastAPI 验证
还在 gc-helper 项目里做。第一个文件只用标准库,把 ④ 的四张图逐一打印出来;第二个文件装上 FastAPI,验证 wraps 和顺序的后果。
import asyncio, functools, time
print("== 1. 函数是值 ==")
def parse(text: str) -> int:
return text.count("Pause")
handlers = {"parse": parse} # 放进字典
f = handlers["parse"] # 拿出来
print(f is parse, f.__name__, f("Pause Pause"))
print("== 2. 闭包 ==")
def over(limit):
def check(ms):
return ms > limit # 用到外层的 limit
return check
slow = over(200) # over 已经返回,limit 还活着
print(slow(244.05), slow(31.6), slow.__closure__[0].cell_contents)
print("== 3. 登记型装饰器:import 时就执行 ==")
routes = {}
def get(path):
print(f" get({path!r}) 执行")
def deco(fn):
print(f" deco({fn.__name__}) 执行,登记进路由表")
routes[path] = fn
return fn # 原样返回
return deco
print("== 4. 包装型装饰器 ==")
def timed(fn):
@functools.wraps(fn) # 抄名字、文档、签名
async def wrapper(*args, **kwargs):
t = time.perf_counter()
try:
return await fn(*args, **kwargs)
finally:
print(f" {fn.__name__} 用了 {(time.perf_counter() - t) * 1000:.0f} ms")
return wrapper
@get("/gc/summary") # ② 再登记:登记的是 wrapper
@timed # ① 先包
async def gc_summary(service: str, minutes: int = 10):
await asyncio.sleep(0.1)
return {"service": service, "minutes": minutes}
print("routes:", list(routes), "登记的是:", routes["/gc/summary"].__name__,
"原函数:", routes["/gc/summary"].__wrapped__.__name__)
print("== 5. 按路由表调用 ==")
print(asyncio.run(routes["/gc/summary"]("order")))
$ uv run deco_demo.py
== 1. 函数是值 ==
True parse 2
== 2. 闭包 ==
True False 200
== 3. 登记型装饰器:import 时就执行 ==
== 4. 包装型装饰器 ==
get('/gc/summary') 执行
deco(gc_summary) 执行,登记进路由表
routes: ['/gc/summary'] 登记的是: gc_summary 原函数: gc_summary
== 5. 按路由表调用 ==
gc_summary 用了 100 ms
{'service': 'order', 'minutes': 10}
- 看“登记进路由表”出现在第 4 段之后、第 5 段之前:它不是在调用时发生的,而是执行到
async def gc_summary那几行定义时就发生了。 - 看登记的函数名字还是
gc_summary,因为wraps把名字抄了过去;__wrapped__指回真正的原函数。
import asyncio, functools, time
from fastapi import FastAPI
from fastapi.testclient import TestClient
app = FastAPI()
calls = []
def timed_nowraps(fn): # 忘了 wraps
async def wrapper(*args, **kwargs):
t = time.perf_counter(); r = await fn(*args, **kwargs)
calls.append(("nowraps", round((time.perf_counter() - t) * 1000))); return r
return wrapper
def timed(fn):
@functools.wraps(fn)
async def wrapper(*args, **kwargs):
t = time.perf_counter(); r = await fn(*args, **kwargs)
calls.append((fn.__name__, round((time.perf_counter() - t) * 1000))); return r
return wrapper
@app.get("/a")
@timed_nowraps
async def a(service: str, minutes: int = 10):
await asyncio.sleep(0.1); return {"service": service, "minutes": minutes}
@app.get("/b")
@timed
async def b(service: str, minutes: int = 10):
await asyncio.sleep(0.1); return {"service": service, "minutes": minutes}
@timed # 写在 app.get 上面:登记的是没计时的原函数
@app.get("/c")
async def c(service: str):
await asyncio.sleep(0.1); return {"service": service}
async def d(): return 1
print("app.get 返回原函数:", app.get("/d")(d) is d)
cl = TestClient(app)
for p in ("/a?service=order", "/b?service=order", "/c?service=order"):
r = cl.get(p); print(p, r.status_code, r.text[:160])
print("calls", calls)
$ uv run fa_pits.py
app.get 返回原函数: True
/a?service=order 422 {"detail":[{"type":"missing","loc":["query","args"],"msg":"Field required","input":null},{"type":"missing","loc":["query","kwargs"],"msg":"Field required","inpu
/b?service=order 200 {"service":"order","minutes":10}
/c?service=order 200 {"service":"order"}
calls [('b', 100)]
实测 本机 uv 0.13.7、CPython 3.13.18、FastAPI 0.162.2、httpx 0.28.1,输出逐字复制(省略了 TestClient 提示 httpx 用法将来要换的一行警告)。
⑥细节·规范·优化
第一圈的 8 个坑全部在本机复现过 实测。前三个最危险:接口不报 500,只是行为悄悄变了。
注意事项:不知道就会出事
functools.wraps,接口变 422← ④-3 签名透出去
- 现象
GET /a?service=order返回 422:"loc":["query","args"]、["query","kwargs"]缺失。- 根源
- FastAPI 读的是
wrapper(*args, **kwargs)的签名,把 args、kwargs 当成了必填的查询参数。 - 做法
- 包装型装饰器一律加
@functools.wraps(fn)。
- 现象
@timed写在@app.get上面,接口 200,但calls里没有c。- 根源
- 下面的
@app.get先执行,登记的是原函数;上面的@timed只改了模块里的名字,路由表里那个没变。 - 做法
- 登记型(
@app.get、@pytest.fixture、@tool)写在最上面。
def wrapper 包 async def,量到 0.001 ms← ④-3 包装型
- 现象
- 接口照样 200,计时却只有 0.001 ms,实际耗时 101 ms。
- 根源
- 调用
async def函数只是造出一个协程对象,立刻返回;真正的等待发生在外面的await里,wrapper 早就量完了。 - 做法
- 包 async 函数的 wrapper 也写成
async def,里面await fn(...)。
- 现象
[lambda: h for h in HOSTS]调一遍,得到 3 个'order-03'。- 根源
- 闭包捕获的是变量 h,调用时才读,那时循环已经结束。
- 做法
- 用默认参数在定义时取值:
lambda h=h: h;或functools.partial(fn, h)。
[],所有调用共用一份← ④-1 __defaults__
- 现象
def add(host, seen=[]),调两次,第二次返回['order-01', 'order-02']。- 根源
- 默认值在
def那一刻算一次,存在函数对象的__defaults__上,以后每次调用都拿同一个列表。 - 做法
seen=None,函数里if seen is None: seen = []。
n += 1,报 UnboundLocalError← ④-2 LEGB
- 现象
cannot access local variable 'n' where it is not associated with a value。- 根源
- 函数里只要给 n 赋值,n 就被认定为局部变量,不再去外层找;而
n += 1要先读它。 - 做法
- 在内层写
nonlocal n;改模块级变量用global,但尽量别这么做。
main.fetch,别的模块还在调真的← ④-4 各查各的字典
- 现象
main.summary()得到“假的”,report.report()还是“真的去 order-01 拉日志”。- 根源
from main import fetch在 report 里另贴了一个名字,改 main 的那个管不到它。- 做法
- 在“调用方查名字的地方”打补丁:
monkeypatch.setattr(report, "fetch", fake);或者 report 里写import main、用main.fetch(...)。
- 现象
- 在装饰器外层连数据库、读远程配置,结果
pytest收集用例、甚至 IDE 分析代码时都在连库,连不上直接 import 失败。 - 根源
- 装饰器外层代码在定义函数时就执行,
import main等于把它们全跑一遍。 - 做法
- 外层只做登记,IO 放进 wrapper 或 FastAPI 的启动事件里。
使用规范:团队里怎么写
@functools.wraps(fn)不管是不是给 FastAPI 用,日志、调试、文档工具都靠名字和签名。
接口 422、日志里全是 wrapper。
想同一个装饰器两边都能用,先用 inspect.iscoroutinefunction(fn) 分支。
计时、重试、限流统统量错。
从上往下读就是“登记的是包好之后的函数”。
中间那层悄悄不生效。
外层只登记、只读参数;真正的活放进 wrapper。
import 就连库,测试变慢变脆。
FastAPI 的 Depends、把 client 当参数传进去,比 monkeypatch 稳;非 patch 不可时,patch 调用方的模块。
换错地方,测试测的是真网络。
None、数字、字符串、tuple。
调用之间悄悄共享状态。
优化和深处
带参数的装饰器为什么是三层
def retry(times=3): # 第 1 层:收配置
def deco(fn): # 第 2 层:收函数
@functools.wraps(fn)
async def wrapper(*a, **k): # 第 3 层:每次调用
for i in range(times):
try: return await fn(*a, **k)
except httpx.TransportError:
if i == times - 1: raise
return wrapper
return deco
@retry(times=3) # = fetch = retry(times=3)(fetch)
async def fetch(...): ...
@ 后面是一个表达式,先求值。retry(times=3) 返回 deco,才轮到 deco 收函数。@app.get("/x") 也是这个结构。
FastAPI 是怎么读到参数的
inspect.signature(fn) 默认 follow_wrapped=True:遇到 __wrapped__ 就顺着往下找,所以 wraps 一加,FastAPI 看到的就是原函数的 service: str, minutes: int = 10。
坑 3 那种“普通 wrapper 包 async”在 FastAPI 里照样 200:实测下来它仍然拿到了协程并等完了结果。正因为不报错,计时错了才更难发现。
包一层要多花多少时间
本机一个空函数直接调 62 ns,套一层 wraps 加 *args, **kwargs 的 wrapper 后 154 ns,每层约多 90 ns 实测。
对一次要等 1 秒网络的接口毫无影响;在一个跑几百万次的解析循环里层层套装饰器,才需要算这笔账。
版本差异
| 版本 | 变化 |
|---|---|
| 3.0 | nonlocal(PEP 3104) |
| 3.2 | functools.wraps 开始设置 __wrapped__ |
| 3.9 | @ 后面可以写任意表达式(PEP 614),比如 @routes["gc"] |
| 3.10 | ParamSpec(PEP 612),给装饰器写准确的类型标注 |
下一圈
- 方法和
self:函数放进类里为什么就变成了“绑定方法”(描述符协议)。 @functools.lru_cache、@contextmanager、@dataclass:标准库里现成的装饰器。- FastAPI 的
Depends:Python 版的依赖注入,和 Spring DI 对照。 - 给装饰器写类型:
ParamSpec和TypeVar,让 IDE 认得被包过的函数。 - 站 04 的
@tool:用登记型装饰器把函数变成 agent 能调用的工具,签名就是工具说明。
↺回到⑤:拿⑥改进值班助手
1 · 给 fetch 加重试
照第三圈写 @retry(times=3),套在全景代码的 fetch 上。想一想它应该放在 asyncio.timeout 的里面还是外面,各会怎样。
用到:带参数的装饰器、坑 3
2 · 把耗时写进返回值
写一个 @timed,把耗时放进 request.state,第 9 站返回的 elapsed_ms 从它取。故意把顺序写反一次,看会怎样。
用到:坑 1、2,规范“登记型放最上面”
3 · 写一个 monkeypatch 测试
新建 report.py,用 from main import fetch。写 pytest 用例把 fetch 换成假的,先让它失败,再按坑 7 的两种改法各修一次。
用到:④-4、坑 7
1. 用一句话说出这一页的精华。
函数是对象,名字只是标签;闭包带着外层变量的 cell 走;@deco 就是 f = deco(f),在 import 时从下往上执行,把名字换成 deco 返回的东西。
2. 没有一等函数时,Java 怎么做“把处理逻辑登记到路径上”?痛在哪?
定义接口、写匿名类或 lambda 实例,或者打注解、靠 Spring 容器启动时反射扫描。注解本身不做事,行为取决于容器,比如自调用不走代理。
3. 坑 1 的 422 是从 ④ 哪条原理来的?
④-3:装饰后名字指向 wrapper,FastAPI 读的是 wrapper 的签名。没有 wraps 就没有 __wrapped__,inspect.signature 看到的是 *args, **kwargs。
4. 预测:fs = [lambda: ms * 2 for ms in (10, 20, 30)],[f() for f in fs] 是多少?怎么改成 [20, 40, 60]?
[60, 60, 60]。三个 lambda 共享同一个变量 ms,调用时它已经是 30。改成 lambda ms=ms: ms * 2。
从 ④-2 推出:cell 装的是变量,不是当时的值。
5. 预测:@app.get 在上、@timed 在下,timed 没用 wraps,但 wrapper 写成了 async def wrapper(service: str): return await fn(service),只收一个参数。请求 /x?service=order&minutes=5 会怎样?
FastAPI 读到的签名只有 service,minutes 被当成多余的查询参数忽略;原函数用默认值 10。接口 200,但 minutes 永远是 10。
从 ④-3 推出:FastAPI 只认登记进去那个函数的签名。
6. 放回全景:第 3 站前后各发生了什么?如果有人在 @app.get 上面加了一个 @timed,第 9 站的 elapsed_ms 会怎样?
之前是第 2 站:uvicorn 把字节解析成 scope,调用 app(scope, receive, send)。之后是第 4 站:FastAPI 按登记函数的签名校验参数。
顺序反了,路由表里登记的是没计时的原函数,第 3 站查表拿到的就是它,请求一路走到第 9 站都不经过 timed。如果 elapsed_ms 从计时装饰器取值,它会一直是空的或默认值,接口不报错,只有看监控的人会发现数字不对。
见 全景第 3 站:路由表在 import 时就建好。