📑 本页目录(点开跳转)
- 🎯 一句话
- 🧩 一、@deco 就是 f = deco(f),而且它在定义那一刻就跑完了
- ⚠️ 二、不写 functools.wraps:函数的身份被整个吃掉
- 🧩 三、带参数的装饰器为什么要三层
- 🚦 四、叠几个装饰器:从下往上应用,从上往下执行
- 💀 五、叠在类方法上:从类上调能跑,从实例上调就废
- 🧩 六、with 背后就两个方法
- ⭐ 七、with 就是 try/finally,外加它多干的三件事
- 💀 八、__exit__ 返回 True 会把异常吞掉
- 🧯 九、contextlib.contextmanager:yield 把函数劈成两半
- ⚠️ 十、contextmanager 的三个坑,和一个好消息
- 💀 十一、站内那两行
- 📋 十二、一张排查表
- 🔗 这一章连到哪里
- ✅ 检查点
- 🛑 可以停在这里
03 · 装饰器与上下文管理器
⏱ 138 分钟 | ⭐ @deco 就是 f = deco(f),with x: 就是 try/finally —— 两句话,剩下全是它们咬人的地方
🎯 一句话
装饰器是「在定义那一刻把函数换掉」,上下文管理器是「把一对必须成双的操作绑死」。
上一章讲的是「你以为拿到数据、其实拿到一次性动作」;这一章讲的是你以为那两行代码是配置,其实它们在 import 的时候就已经跑完了——以及 with 那个能把异常整个吃掉的开关。
🧩 一、@deco 就是 f = deco(f),而且它在定义那一刻就跑完了
装饰器没有任何特殊语义。@deco 写在 def f 上面,等价于 def f 之后紧跟一行 f = deco(f)。
⭐ 关键推论:deco 是在【定义函数】那一刻执行的,不是在【调用函数】那一刻。
# 装饰器就是 f = deco(f):它在【定义那一刻】就执行了
def deco(fn):
print(" [deco 被执行了] 拿到的是:", fn.__name__)
def wrapper(*args, **kwargs):
print(" [wrapper 被执行了]")
return fn(*args, **kwargs)
return wrapper
print("--- 开始定义函数 ---")
@deco
def add(a, b):
return a + b
print("--- 定义完了,还没调用过 ---")
print("调用结果:", add(1, 2))
# 完全等价的手写形式
def sub(a, b):
return a - b
sub = deco(sub) # ⭐ @deco 就是这一行的语法糖
print("手写等价:", sub(5, 3))
print("add 现在指向的是:", add.__name__)
对照
--- 开始定义函数 ---
[deco 被执行了] 拿到的是: add
--- 定义完了,还没调用过 ---
[wrapper 被执行了]
调用结果: 3
[deco 被执行了] 拿到的是: sub
[wrapper 被执行了]
手写等价: 2
add 现在指向的是: wrapper
⭐ 看 [deco 被执行了] 的位置:它夹在两条分隔线中间,add(1, 2) 还没发生。这跟上一章的生成器正好相反 —— 生成器是「调用了也不执行」,装饰器是「没调用就已经执行了」。
| 你写的 | 什么时候跑 |
|---|---|
deco 的函数体(print、注册、读配置) |
⭐ 定义 add 的那一刻,也就是模块被 import 的时候 |
wrapper 的函数体 |
每次调用 add(...) 的时候 |
原函数 add 的函数体 |
wrapper 决定调它的时候(可能一次、多次、或者零次) |
「定义时执行」不是缺陷,它就是装饰器最大的用途 —— 注册表:
# 「定义时执行」的用处和代价:注册表模式
REGISTRY = {}
def register(name):
def decorator(fn):
REGISTRY[name] = fn # ⭐ 这一句在【定义那一刻】就跑了
return fn # 原样返回,不包一层
return decorator
@register("csv")
def load_csv(p):
return f"csv:{p}"
@register("json")
def load_json(p):
return f"json:{p}"
print("一次都没调用过,注册表已经满了:", list(REGISTRY))
print("按名字取:", REGISTRY["json"]("a.json"))
print("load_json 还是原函数吗:", load_json.__name__, "| 包了一层吗:", load_json is REGISTRY["json"])
要点
一次都没调用过,注册表已经满了: ['csv', 'json']
按名字取: json:a.json
load_json 还是原函数吗: load_json | 包了一层吗: True
⭐ 这个形状你在站内见过:智能体工程教程 18 · 手搓 Agent 框架 里的 tool 装饰器就是这样 —— 往 fn._schema 上挂一份 schema,然后 return fn 原样交还,不包 wrapper。同理 AI全栈 01 的 @app.post("/api/chat"):路由表是在 import 那个模块时填好的,不是等第一个请求来了才填。
⚠️ 代价也在这里:注册表是被 import 副作用填满的。模块没被 import → 装饰器没跑 → 注册表里没有它 → 你会得到一个「函数明明写了、框架说找不到」的 404 / KeyError,而且报错的地方离真正的原因很远。08 章第一节把 import X 拆成三步,第三步「执行模块体」就是这里说的副作用发生的时刻。
⚠️ 二、不写 functools.wraps:函数的身份被整个吃掉
wrapper 是个新函数。你把 add 这个名字指向了它,于是原函数的名字、文档、签名全都不见了。
# 不写 functools.wraps:函数的身份被整个吃掉
import functools
import inspect
def naked(fn): # ⚠️ 没有 wraps
def wrapper(*args, **kwargs):
return fn(*args, **kwargs)
return wrapper
def wrapped(fn): # ⭐ 有 wraps
@functools.wraps(fn)
def wrapper(*args, **kwargs):
return fn(*args, **kwargs)
return wrapper
def original(a, b=10, *, scale=1.0):
"""把 a 和 b 加起来再乘以 scale。"""
return (a + b) * scale
A = naked(original)
B = wrapped(original)
for tag, f in [("原函数", original), ("naked", A), ("wraps", B)]:
print(f"[{tag}]")
print(" __name__ =", f.__name__)
print(" __doc__ =", repr(f.__doc__))
print(" 签名 =", inspect.signature(f))
print(" 参数名 =", list(inspect.signature(f).parameters))
print("__qualname__ : naked ->", A.__qualname__, "| wraps ->", B.__qualname__)
print("__wrapped__ : naked 有吗", hasattr(A, "__wrapped__"),
"| wraps 指回原函数", B.__wrapped__ is original)
关键信息
💀 事故的完整形状:
| 项 | 内容 |
|---|---|
| 症状 | 装饰过的函数跑起来完全正常,add(1, 2) 还是 3。坏的是「关于这个函数的元信息」 |
| 谁在读这些元信息 | ⭐ 日志(fn.__name__ 全变成 wrapper)、@tool 生成的 schema(name 和 description 全错)、FastAPI 的参数校验(签名读成 (*args, **kwargs) → 校验形同虚设)、pytest 的用例名、help()、Sphinx 文档 |
| 为什么没被发现 | ⚠️ 单元测试测的是返回值,返回值是对的。元信息坏掉不会让任何断言失败 |
| 该补什么 | 装饰器内层函数上加一行 @functools.wraps(fn)。没有例外——即使这个装饰器现在只是打个日志 |
⭐ 最要命的那一格是签名。06 章 里那个 30 行的 @tool 实现,name 取自 fn.__name__、description 取自 fn.__doc__、参数取自 inspect.signature(fn) —— 上表 naked 那三行全是坏的:名字变成 wrapper、文档变成 None、参数名变成 ['args', 'kwargs']。模型收到的工具描述会是「一个叫 wrapper、没有说明、参数叫 args 和 kwargs 的工具」。
⭐ wraps 还额外挂了一个 __wrapped__ 指回原函数,inspect.signature 就是顺着它找回真签名的。想拿到没被包装的那一层(比如测试里想跳过缓存),用 f.__wrapped__。
🛑 读到这里可以停 —— 已经读了约 21 分钟。 后面还有(约 32 分钟):带参数的装饰器为什么要三层 · 叠几个装饰器:从下往上应用,从上往下执行 · 叠在类方法上:从类上调能跑,从实例上调就废 回来的时候不用重读,直接从下一节接着看就行。
🧩 三、带参数的装饰器为什么要三层
@retry(3) 里的 retry(3) 先被调用,它的返回值才是真正的装饰器。所以一共发生两次调用,需要三层函数各接一批参数。
# 带参数的装饰器:三层分别对应三次调用
import functools
def retry(times): # 第 1 层:收【装饰器的参数】
print(" [1] retry(times) 执行,times =", times)
def decorator(fn): # 第 2 层:收【被装饰的函数】
print(" [2] decorator(fn) 执行,fn =", fn.__name__)
@functools.wraps(fn)
def wrapper(*args, **kwargs): # 第 3 层:收【调用时的实参】
for i in range(1, times + 1):
try:
return fn(*args, **kwargs)
except ValueError as e:
print(f" [3] 第 {i} 次失败: {e}")
raise RuntimeError(f"{times} 次全失败")
return wrapper
return decorator
print("--- 定义 flaky ---")
calls = {"n": 0}
@retry(3)
def flaky():
calls["n"] += 1
if calls["n"] < 3:
raise ValueError(f"第 {calls['n']} 次不行")
return f"第 {calls['n']} 次成功"
print("--- 定义完了 ---")
print("结果:", flaky())
# ⭐ @retry(3) 展开就是这两步
def flaky2():
return "ok"
flaky2 = retry(3)(flaky2) # 先调 retry(3) 拿到 decorator,再调 decorator(fn)
print("手写等价:", flaky2())
执行记录:按先后顺序读
- --- 定义 flaky ---
- [1] retry(times) 执行,times = 3
- [2] decorator(fn) 执行,fn = flaky
- --- 定义完了 ---
- [3] 第 1 次失败: 第 1 次不行
- [3] 第 2 次失败: 第 2 次不行
- 结果: 第 3 次成功
- [1] retry(times) 执行,times = 3
- [2] decorator(fn) 执行,fn = flaky2
- 手写等价: ok
⭐ 三层对应三批参数,一句话记住:
| 层 | 收什么 | 什么时候执行 |
|---|---|---|
retry(times) |
装饰器自己的配置 | 定义被装饰函数时,一次 |
decorator(fn) |
被装饰的函数 | 定义被装饰函数时,一次 |
wrapper(*args) |
每次调用的实参 | ⭐ 每次调用 |
⚠️ 少写一对括号,装饰那一步不会报错,报错的地方在很后面:
# ⚠️ 少写一对括号:@retry 和 @retry(3) 差一层调用
import functools
def retry(times):
def decorator(fn):
@functools.wraps(fn)
def wrapper(*args, **kwargs):
for _ in range(times):
return fn(*args, **kwargs)
return wrapper
return decorator
@retry # ⚠️ 忘了 (3),times 变成了函数本身
def ping():
return "pong"
print("装饰这一步没报错,ping 现在是:", type(ping).__name__)
try:
ping()
except TypeError as e:
print("调用才炸:", type(e).__name__, ":", e)
# 想让两种写法都能用:判断第一个参数是不是可调用对象
def retry2(fn=None, *, times=3):
if fn is None: # ⭐ 走 @retry2(times=5) 这条路
return lambda f: retry2(f, times=times)
@functools.wraps(fn)
def wrapper(*args, **kwargs):
for _ in range(times):
return fn(*args, **kwargs)
return wrapper
@retry2
def a(): return "a 裸用"
@retry2(times=5)
def b(): return "b 带参数"
print(a(), "|", b())
要点
装饰这一步没报错,ping 现在是: function
调用才炸: TypeError : retry.<locals>.decorator() missing 1 required positional argument: 'fn'
a 裸用 | b 带参数
⚠️ 注意那条报错信息的形状:decorator() missing 1 required positional argument: 'fn'。你在源码里根本没写过 decorator(...) 这个调用,traceback 指着的是你的 ping()。⭐ 判据:错误信息里出现了装饰器内部的函数名(decorator / wrapper),八成是括号写漏了或多了。
⭐ 想让 @retry2 和 @retry2(times=5) 都能用,就把「第一个位置参数是不是函数」当成分支条件,并把配置项做成 keyword-only(* 后面的参数)—— 这样不会有人误传位置参数进来。
🚦 四、叠几个装饰器:从下往上应用,从上往下执行
# 叠几个装饰器:从下往上【应用】,从上往下【执行】
import functools
def mark(tag):
def decorator(fn):
print(f" 应用 {tag},它拿到的是 {fn.__name__}")
@functools.wraps(fn)
def wrapper(*a, **k):
print(f" 进入 {tag}")
r = fn(*a, **k)
print(f" 离开 {tag}")
return r
return wrapper
return decorator
@mark("外层 A")
@mark("中层 B")
@mark("内层 C")
def core():
print(" ★ 真正的函数体")
return 42
print("--- 调用 ---")
print("返回:", core())
对照
应用 内层 C,它拿到的是 core
应用 中层 B,它拿到的是 core
应用 外层 A,它拿到的是 core
--- 调用 ---
进入 外层 A
进入 中层 B
进入 内层 C
★ 真正的函数体
离开 内层 C
离开 中层 B
离开 外层 A
返回: 42
⭐ 两个方向是反的:应用(贴上去那一步)从最靠近 def 的一层开始往上;执行(调用时)从最上面那层开始往下,像洋葱。
⭐ 顺带一个小证据:三行「应用 X,它拿到的是 core」全都写着 core —— 因为每一层都写了 functools.wraps,包装函数对外报的名字始终是原名。不写 wraps 的话这里会是 core / wrapper / wrapper,你就再也分不清自己在包第几层了。
📋 由此得到一条排序规矩:「谁的语义应该最先生效,谁就写在最上面。」 比如鉴权应该在计时之外(未授权的请求不该被算进耗时),限流应该在重试之外(重试不该绕开限流)。
💀 五、叠在类方法上:从类上调能跑,从实例上调就废
@staticmethod 和 @classmethod 不是普通装饰器,它们返回的是描述符对象而不是函数。所以叠放顺序不是风格问题,是对错问题。
# 装饰器叠在类方法上:顺序错了会怎样
import functools
def log(fn):
@functools.wraps(fn)
def wrapper(*args, **kwargs):
print(" [log]", getattr(fn, "__name__", type(fn).__name__), "args=", args)
return fn(*args, **kwargs)
return wrapper
class C:
@staticmethod # ⭐ 正确:staticmethod 在【最外层】
@log
def ok_static(x):
return x * 2
@log # ⚠️ 错误:log 拿到的是 staticmethod 对象
@staticmethod
def bad_static(x):
return x * 2
@classmethod # ⭐ 正确
@log
def ok_class(cls, x):
return f"{cls.__name__}:{x}"
@log # ⚠️ 错误
@classmethod
def bad_class(cls, x):
return f"{cls.__name__}:{x}"
c = C()
print("ok_static :", C.ok_static(3))
print("ok_class :", C.ok_class(3))
for name in ("bad_static", "bad_class"):
try:
print(f"{name}:", getattr(C, name)(3))
except TypeError as e:
print(f"{name}: TypeError :", e)
print("类字典里存的是什么类型:")
for name in ("ok_static", "bad_static", "ok_class", "bad_class"):
print(" ", name, "->", type(C.__dict__[name]).__name__)
关键信息
⭐ 最后四行是全部证据:正确顺序下类字典里存的是 staticmethod / classmethod;顺序反了之后变成了普通 function —— 它已经不是静态方法了,只是一个恰好放在类里的函数。
💀 bad_class 当场炸了,反而是好事。真正贵的是 bad_static:它在上面那段输出里「跑通了」。
# bad_static 「从类上调能跑、从实例上调就废」
import functools
import sys
def log(fn):
@functools.wraps(fn)
def wrapper(*args, **kwargs):
return fn(*args, **kwargs)
return wrapper
class C:
@log
@staticmethod # ⚠️ 顺序反了
def bad_static(x):
return x * 2
print("Python", sys.version.split()[0])
print("从类上调 C.bad_static(3) :", C.bad_static(3))
try:
print("从实例上调 c.bad_static(3):", C().bad_static(3))
except TypeError as e:
print("从实例上调 c.bad_static(3): TypeError :", e)
要点
Python 3.13.14
从类上调 C.bad_static(3) : 6
从实例上调 c.bad_static(3): TypeError : C.bad_static() takes 1 positional argument but 2 were given
💀 事故的完整形状:
| 项 | 内容 |
|---|---|
| 症状 | C.bad_static(3) 返回 6,一切正常;C().bad_static(3) 抛 takes 1 positional argument but 2 were given |
| 为什么 | 类字典里存的是普通 function,通过实例访问会走描述符协议把 self 塞进第一个参数 —— 于是 x 收到了实例,3 变成了多出来的第二个参数 |
| 为什么没被发现 | ⚠️ 工具函数通常都是 C.helper(...) 这么调的,测试和大部分调用点都从类上走。直到某天有人写了 self.helper(...) |
| 报错信息误导性极强 | 它说「给了 2 个参数」,而你源码里明明只写了 1 个。多出来的那个是 self,但错误信息不会告诉你 |
| 该补什么 | ⭐ @staticmethod / @classmethod 永远写在最外层(最上面)。检查一行代码:type(C.__dict__["名字"]),应该是 staticmethod 而不是 function |
🗓️ 这条还带版本差异:bad_static 能从类上调通是因为 Python 3.10 之后 staticmethod 对象自己变成可调用的了。在 3.9 及更早,同样的代码会直接抛 'staticmethod' object is not callable。⚠️ 所以这段代码在新版本上更危险 —— 老版本当场炸,新版本让你带着它上线。
🛑 读到这里可以停 —— 装饰器那半章讲完了(约 30 分钟):
f = deco(f)、定义时执行、functools.wraps、三层嵌套、叠放顺序。 后半章是上下文管理器:with背后的两个方法 · 它和try/finally的关系 · ⭐__exit__返回True会吞掉异常 ·@contextmanager的写法和三个坑 · 站内那两行 回来的时候不用重读,直接从下一节接着看就行。
🧩 六、with 背后就两个方法
with obj as x: 展开之后只有两步:调 obj.__enter__(),把返回值绑给 x;块结束时无论怎么结束,调 obj.__exit__(异常类型, 异常对象, traceback)。
# with 背后就两个方法:__enter__ 和 __exit__
class Trace:
def __init__(self, tag):
self.tag = tag
def __enter__(self):
print(f" [{self.tag}] __enter__ 跑了")
return "⭐ as 绑的是 __enter__ 的【返回值】" # ⚠️ 不是 self
def __exit__(self, exc_type, exc_value, tb):
print(f" [{self.tag}] __exit__ 收到 exc_type =", exc_type)
return False # 不吞异常
with Trace("正常") as x:
print(" body:", x)
print("---")
try:
with Trace("出错") as x:
raise ValueError("炸了")
except ValueError as e:
print("异常照常传出来:", type(e).__name__, ":", e)
print("---")
def f():
with Trace("提前 return"):
return "返回值" # ⭐ __exit__ 照样跑
print("f() =", f())
print("---")
for i in range(3):
with Trace(f"break-{i}"):
if i == 1:
break # ⭐ __exit__ 照样跑
对照
[正常] __enter__ 跑了
body: ⭐ as 绑的是 __enter__ 的【返回值】
[正常] __exit__ 收到 exc_type = None
---
[出错] __enter__ 跑了
[出错] __exit__ 收到 exc_type = <class 'ValueError'>
异常照常传出来: ValueError : 炸了
---
[提前 return] __enter__ 跑了
[提前 return] __exit__ 收到 exc_type = None
f() = 返回值
---
[break-0] __enter__ 跑了
[break-0] __exit__ 收到 exc_type = None
[break-1] __enter__ 跑了
[break-1] __exit__ 收到 exc_type = None
⚠️ as x 绑的是 __enter__ 的返回值,不是那个对象本身。 上面 x 是一个字符串,不是 Trace 实例。
多数库返回 self(所以你以为它们是一回事),但也有反例:open() 返回文件对象本身,而 contextlib.suppress() 的 __enter__ 返回 None。
⭐ 写自己的上下文管理器时,__enter__ 里忘了 return self 会让 as x 拿到 None,而这不报错 —— 直到你用 x.something 才炸出一个 AttributeError: 'NoneType' object has no attribute ...。
⭐ __exit__ 的执行是无条件的:正常结束、return、break、continue、抛异常,五条路都会走到它。这正是它比手写清理可靠的全部原因。
⭐ 七、with 就是 try/finally,外加它多干的三件事
# with 就是 try/finally 的封装版;多个资源时 with 比手写可靠
from contextlib import ExitStack
class Res:
def __init__(self, name, fail=False):
self.name, self.fail = name, fail
def __enter__(self):
if self.fail:
raise OSError(f"{self.name} 打不开")
print(" 开", self.name)
return self
def __exit__(self, *exc):
print(" 关", self.name)
return False
print("=== 手写 try/finally(等价物)===")
r = Res("A")
x = r.__enter__() # ⭐ with 语句展开后就是这三段
try:
print(" 用", x.name)
finally:
r.__exit__(None, None, None)
print("=== 一行开两个:B 成功、C 失败 ===")
try:
with Res("B") as b, Res("C", fail=True) as c:
print(" 到不了这里")
except OSError as e:
print(" 捕获:", e, "—— ⭐ 注意 B 已经被关掉了")
print("=== 数量不定:ExitStack ===")
names = ["D", "E", "F"]
with ExitStack() as stack:
opened = [stack.enter_context(Res(n)) for n in names]
print(" 一共开了", len(opened), "个")
print(" ⭐ 退出时按【相反顺序】全部关掉")
对照
=== 手写 try/finally(等价物)===
开 A
用 A
关 A
=== 一行开两个:B 成功、C 失败 ===
开 B
关 B
捕获: C 打不开 —— ⭐ 注意 B 已经被关掉了
=== 数量不定:ExitStack ===
开 D
开 E
开 F
一共开了 3 个
关 F
关 E
关 D
⭐ 退出时按【相反顺序】全部关掉
⚠️ 上面第一段只是近似展开 —— 真正的 with 还会把异常三元组传给 __exit__,并看它的返回值决定要不要继续抛(那正是下一节的坑)。
⭐ 既然是 try/finally,为什么还要 with?三件它多干的事:
| 多干的 | 说明 |
|---|---|
| 清理逻辑有名字、能复用 | finally 里的清理散落在每个调用点,改一次要改 N 处;__exit__ 只有一处 |
| ⭐ 多资源时前面开好的一定会关 | with A() as a, B() as b: 里 B 打不开,A 已经开好的那个照样被关掉(上面输出里 开 B → 关 B → 才抛出来)。手写嵌套 try/finally 很容易漏这条 |
数量不定时有 ExitStack |
要打开 N 个文件(N 运行时才知道)没法写 N 层 with。ExitStack.enter_context 逐个登记,退出时按相反顺序全部关掉 |
📋 判据:一对操作只要「必须成双」(开/关、加锁/解锁、进事务/提交、改全局状态/还原),就写成上下文管理器,不要靠人记得写 finally。已经是上下文管理器的常见对象:文件、threading.Lock(04 章 那些锁都能直接 with)、数据库连接与游标、torch.no_grad()、tempfile.TemporaryDirectory()。
💀 八、__exit__ 返回 True 会把异常吞掉
这是本章一号杀手。__exit__ 的返回值有一个你可能不知道自己在用的含义:返回真值 = 「这个异常我处理掉了,别往外抛」。
# 💀 __exit__ 返回 True 会把异常吞掉,而且不留任何痕迹
import contextlib
class Swallow:
def __enter__(self):
return self
def __exit__(self, exc_type, exc_value, tb):
return True # ⚠️ 真值 =「这个异常我处理掉了」
class Propagate:
def __enter__(self):
return self
def __exit__(self, exc_type, exc_value, tb):
return None # ⭐ None / False = 让异常继续往外走
def save(cm, tag):
written = []
try:
with cm:
written.append("第一条")
raise OSError("磁盘满了")
written.append("第二条") # 永远到不了
print(f"{tag}: ⚠️ with 块【正常结束】了,代码继续往下跑")
except OSError as e:
print(f"{tag}: 外面捕获到 {type(e).__name__}: {e}")
print(f"{tag}: 实际写进去 {len(written)} 条 ->", written)
save(Swallow(), "Swallow ")
print("---")
save(Propagate(), "Propagate")
print("--- 真想吞,用 contextlib.suppress,写明吞哪一种 ---")
with contextlib.suppress(FileNotFoundError):
open("绝对不存在的文件.txt")
print("FileNotFoundError 被吞了,程序还在跑")
try:
with contextlib.suppress(FileNotFoundError):
raise PermissionError("别的异常照样往外走")
except PermissionError as e:
print("PermissionError 没被吞:", e)
操作步骤
⭐ 两行输出的差别就是全部:两边都只写进去 1 条(数据同样是坏的),但 Swallow 那边程序认为 with 块正常结束了,接着往下执行 —— 你会拿着一份只写了一半的数据继续算、继续提交、继续返回 200。
💀 事故的完整形状:
| 项 | 内容 |
|---|---|
| 症状 | 数据静默地少一半。没有 traceback,没有日志,try/except 也捕不到 —— 因为异常在到达你的 except 之前就被吃掉了 |
| 怎么写出来的 | 最常见的是在 __exit__ 末尾顺手写了 return True,以为它的意思是「清理成功」。它的意思是「异常已被处理」 |
| 第二种写法 | def __exit__(self, *a): return self.log_error(a) —— 只要 log_error 恰好返回了非空字符串 / 非零数字,异常就被吞了 |
| 该补什么 | ⭐ __exit__ 一律不写 return(隐式返回 None,等价于 False)。真要吞异常,用 contextlib.suppress(具体的异常类型),它在源码里写明了吞哪一种,别的照样往外走 |
⚠️ 这和 07 章 讲的 except Exception: pass 是同一类问题的两副面孔,但 __exit__ 这一副更隐蔽:except: pass 至少在源码里显眼地写着,而 return True 藏在一个叫「上下文管理器」的、看起来只负责收尾的方法里。
🧯 九、contextlib.contextmanager:yield 把函数劈成两半
写一个类、两个 dunder 方法,只为了「前面做点事、后面做点事」太重了。@contextmanager 让你用一个生成器表达同一件事:yield 之前是 __enter__,之后是 __exit__。
⭐ 这就是 02 章 的直接应用:生成器能被「暂停在半路」,with 块的整个执行过程就发生在那个暂停里。
# contextlib.contextmanager:yield 把函数劈成「进入前」和「退出后」
from contextlib import contextmanager
@contextmanager
def naive(tag):
print(f" [{tag}] 进入前")
yield tag # ⚠️ 没有 try/finally
print(f" [{tag}] 退出后 —— 只有【没出异常】时才跑得到")
@contextmanager
def robust(tag):
print(f" [{tag}] 进入前")
try:
yield tag # ⭐ body 里的异常是从这一行【抛回来】的
except ValueError as e:
print(f" [{tag}] 在 yield 处接住了 {type(e).__name__}: {e}")
raise # 不 raise 就等于吞掉,和 __exit__ 返回 True 一样
finally:
print(f" [{tag}] finally 清理 —— 出不出异常都跑")
print("=== naive,正常路径 ===")
with naive("naive-ok") as t:
print(" body:", t)
print("=== naive,body 抛异常 ===")
try:
with naive("naive-bad"):
raise ValueError("炸了")
except ValueError:
print(" 外面接住了 —— ⚠️ 但「退出后」那行【从来没跑过】")
print("=== robust,body 抛异常 ===")
try:
with robust("robust"):
raise ValueError("炸了")
except ValueError:
print(" 外面接住了")
对照
=== naive,正常路径 ===
[naive-ok] 进入前
body: naive-ok
[naive-ok] 退出后 —— 只有【没出异常】时才跑得到
=== naive,body 抛异常 ===
[naive-bad] 进入前
外面接住了 —— ⚠️ 但「退出后」那行【从来没跑过】
=== robust,body 抛异常 ===
[robust] 进入前
[robust] 在 yield 处接住了 ValueError: 炸了
[robust] finally 清理 —— 出不出异常都跑
外面接住了
⚠️⚠️ naive 的正常路径完全正确,这就是它的危险之处。 你测一遍,输出漂亮;上线之后只有出异常的那条路会漏掉清理 —— 而那正是最需要清理的时候(连接没关、锁没放、临时文件没删、全局状态没还原)。
⭐ yield 那一行是双向的:它把值交出去(给 as x),也把 body 里的异常接回来。所以「body 里出的错」在生成器视角看就是「yield 这一行抛了异常」,你可以 except 它。
📋 规矩,没有例外:@contextmanager 里的 yield 必须包在 try 里,清理写在 finally 里。 只有当你确实只想在成功路径上做事时才省略,而那种需求少到你应该在旁边写一行注释说明。
⚠️ 承接上一节:except 里不写 raise 就等于把异常吞了,效果和 __exit__ 返回 True 一模一样。要么 raise,要么用 contextlib.suppress。
🛑 第二个休息点 —— 中段讲完了(约 36 分钟)。 最后一段还有:
contextmanager的三个坑,和一个好消息 · 站内那两行 · 一张排查表 这一章确实长,分三次读完全没问题 —— 回来直接从下一节接着看。
🛑 读到这里可以停 —— 已经读了约 88 分钟。 最后一段还有(约 30 分钟):
contextmanager的三个坑,和一个好消息 · 站内那两行 · 一张排查表 回来的时候不用重读,直接从下一节接着看就行。
⚠️ 十、contextmanager 的三个坑,和一个好消息
# contextmanager 的三个坑:yield 前的异常 / 两个 yield / 一次性
from contextlib import contextmanager
@contextmanager
def check(path):
if not path.endswith(".csv"):
raise ValueError("只支持 csv") # ⭐ 在 __enter__ 里抛,body 根本不会开始
print(" 打开", path)
try:
yield path
finally:
print(" 关闭", path)
try:
with check("data.txt"):
print(" ⚠️ 这一行不会出现")
except ValueError as e:
print("① with 那一行就抛了:", type(e).__name__, ":", e)
@contextmanager
def two_yields():
yield 1
yield 2 # ⚠️ 只允许一个 yield
try:
with two_yields():
pass
except RuntimeError as e:
print("② 两个 yield ->", type(e).__name__, ":", e)
# ⚠️ contextmanager 造出来的对象是【一次性】的(它底下就是个生成器)
cm = check("a.csv")
with cm:
pass
try:
with cm: # 第二次
pass
except Exception as e:
print("③ 同一个对象用第二次 ->", type(e).__name__)
操作步骤
| 坑 | 机制 | 怎么办 |
|---|---|---|
① yield 前的异常在 with 那一行抛 |
__enter__ 内部就是一次 next(),会跑到 yield 为止。⭐ 这跟上一章「生成器函数调用时一行不执行」不矛盾 —— @contextmanager 帮你调了那一次 next |
这是好事:参数校验放 yield 前,非法输入根本进不了 body。⚠️ 但此时清理不会跑,因为还没进 try |
② 只能有一个 yield |
第二个 yield 让生成器在 __exit__ 里没能停下,抛 RuntimeError: generator didn't stop |
一个上下文管理器只管一对进出。要嵌套就嵌套 with,或用 ExitStack |
| ③ 造出来的对象是一次性的 | 底下就是个生成器,02 章 的「只能走一次」原样适用 | ⭐ 别把它存进变量重复用,每次 with 都重新调一次工厂函数:with check("a.csv"): |
⚠️ 坑 ③ 报的是什么错取决于版本(本机 3.13 是 AttributeError),⭐ 别指望靠错误信息认出它,靠的是「cm = xxx() 存进变量」这个写法本身。
⭐ 好消息在这里:@contextmanager 造出来的对象同时也是个装饰器(它继承了 ContextDecorator),而且当装饰器用时每次调用都会自己重建一个新生成器,不受坑 ③ 影响。
# ⭐ 同一个东西既能 with 也能 @:contextmanager 造出来的对象自带 ContextDecorator
from contextlib import contextmanager
import time
@contextmanager
def timer(tag):
t0 = time.perf_counter()
try:
yield
finally:
print(f" [{tag}] 耗时 {(time.perf_counter() - t0) * 1000:.1f} ms")
with timer("当 with 用"):
sum(range(1_000_000))
@timer("当装饰器用") # ⭐ 同一个对象,直接贴到函数上
def work():
return sum(range(1_000_000))
work()
work() # ⭐ 能用第二次:每次调用内部会【重建】一个生成器
print("timer('x') 是什么:", type(timer("x")).__name__)
对照
[当 with 用] 耗时 9.8 ms
[当装饰器用] 耗时 8.9 ms
[当装饰器用] 耗时 9.6 ms
timer('x') 是什么: _GeneratorContextManager
⚠️ 三个耗时数字每次跑都会不一样(本机 8 核 Windows / CPython 3.13,量的是 sum(range(1_000_000))),⭐ 要点是那三行都打印出来了 —— 一份代码,with timer(...) 和 @timer(...) 两种用法,而且装饰器那种能重复调用。怎么把这类计时做得可信(预热、多次取最小值、剖析器自身开销),见 05 章。
⚠️ 但它不能装饰 async def,异步那边要用 contextlib.asynccontextmanager 配 async with。
💀 十一、站内那两行
⭐ 本章的两条主线在站内各有一处已经写好的对照,正好一正一反。
反例:PyTorch这个框架本身 07 · train 和 eval 改了什么 里演示 BatchNorm 污染时,用的是手工切换:
# 🧩 骨架:`bn` 来自你自己的代码,这一段只看写法
bn.train() # ⚠️ 忘了切回 eval()
with torch.no_grad(): # ⚠️ 而且包在 no_grad 里,看起来很安全
bn(val_x)
bn.eval()
⭐ 那两条注释指出的正是本章第七节的判据:no_grad() 是个上下文管理器,但它只管梯度,管不到 train/eval 模式;而 train/eval 这一对是手写的,中间一旦出异常就还原不了。实测:
# 成对操作写成手工的「设置…还原」,中途一炸就还原不了
from contextlib import contextmanager
class FakeModel:
def __init__(self):
self.training = True
def eval(self):
self.training = False
def train(self):
self.training = True
def forward(self, x):
if x < 0:
raise ValueError("坏样本")
return x * 2
m = FakeModel()
def validate_manual(model, batch):
model.eval()
out = [model.forward(x) for x in batch]
model.train() # ⚠️ 抛异常就跑不到这一行
return out
try:
validate_manual(m, [1, 2, -1])
except ValueError:
pass
print("手工还原:出异常后 model.training =", m.training, "⚠️ 卡在 eval 模式了")
@contextmanager
def eval_mode(model):
was = model.training
model.eval()
try:
yield model
finally:
model.training = was # ⭐ 出不出异常都还原
m.train()
def validate_with(model, batch):
with eval_mode(model):
return [model.forward(x) for x in batch]
try:
validate_with(m, [1, 2, -1])
except ValueError:
pass
print("with 还原:出异常后 model.training =", m.training, "⭐ 还原了")
要点
手工还原:出异常后 model.training = False ⚠️ 卡在 eval 模式了
with 还原:出异常后 model.training = True ⭐ 还原了
💀 卡在 eval 模式的后果:接下来的训练里 BatchNorm 不再更新 running 统计量、Dropout 不再丢弃 —— loss 反而会更好看,因为 Dropout 关了。你不会去怀疑一个「验证时抛过一次异常、被 except 掉了」的地方。
正例:智能体工程教程 16c · 接进真实产品 里有站内(本章之外)唯一一处真正在用 @contextlib.contextmanager 的代码,形状和本章第九节的 robust 完全一致 —— yield 包在 try 里、s.close() 写在 finally 里,注释写着「取消/异常/正常结束都会走到」。⭐ 读那一章之前先读本章第九节,否则你只会照抄那个 finally 而不知道为什么它不能挪到 yield 后面。
🛑 读到这里可以停 —— 已经读了约 110 分钟。 最后一段还有(约 28 分钟):一张排查表 · 检查点与走神救援 回来的时候不用重读,直接从下一节接着看就行。
📋 十二、一张排查表
| 现象 | 大概率是哪一节 | 一行验证 |
|---|---|---|
日志里函数名全是 wrapper |
⭐ 二、没写 functools.wraps |
print(f.__name__) |
@tool 生成的 schema 里 name/参数全错 |
二、签名被吃成 (*args, **kwargs) |
print(inspect.signature(f)) |
| FastAPI/Pydantic 校验形同虚设 | 二、框架读到的是包装函数的签名 | 同上 |
TypeError: decorator() missing 1 required positional argument |
⭐ 三、@retry 少写了 (...) |
看装饰器是不是带参数的 |
类上的静态方法 self.helper(...) 报「多了一个参数」 |
⭐ 五、@staticmethod 没写在最外层 |
type(C.__dict__["helper"]) 应该是 staticmethod |
as x 拿到 None |
六、__enter__ 忘了 return self |
print(x) 在 with 块第一行 |
| 数据静默少一半,没有任何 traceback | ⭐ 八、__exit__ 返回了真值 |
把 return True 删掉,改用 suppress |
| 出异常时连接没关 / 锁没放 / 状态没还原 | ⭐ 九、@contextmanager 里没写 try/finally |
看 yield 是不是裸的 |
RuntimeError: generator didn't stop |
十②、生成器里有两个 yield |
数 yield 的个数 |
| 同一个上下文管理器对象第二次用就炸 | 十③、它是一次性的 | 别存变量,每次重新调工厂函数 |
| 函数明明写了,框架说找不到 | 一、模块没被 import,装饰器没跑 | 检查注册模块有没有真的被 import |
🔗 这一章连到哪里
| 相关的地方 | 为什么 |
|---|---|
| 02 · 生成器与惰性求值 | ⭐ 本章第九、十节的前置。@contextmanager 就是一个生成器:yield 暂停在半路、with 块跑完再恢复,连「只能走一次」都原样继承了 |
| 06 · 类型标注是给工具看的 | 它讲「标注怎么被 inspect.signature 读出来」,本章第二节讲「不写 wraps 的话读到的是错的」。⭐ 两章合起来才是一个完整的 @tool |
| 07 · 异常、traceback 和调试 | __exit__ 返回 True 和 except Exception: pass 是同一类问题。那一章讲怎么读被吞掉之前的现场 |
| 04 · GIL:为什么多线程救不了你 | ⭐ 它第八节的结论是「只要有两个线程会写同一个东西,就上锁(threading.Lock)」—— 而 Lock 正是个上下文管理器,with lock: 就是本章第七节「加锁/解锁必须成双」那条判据的实例 |
| 智能体工程教程 18 · 手搓 Agent 框架 | 它的 tool 装饰器就是本章第一节的注册表形状(挂 _schema 后 return fn)。⭐ 而 T1 那条验收项要生成 {name, description, parameters},靠的正是本章第二节保住的那三样元信息 |
| 智能体工程教程 16c · 接进真实产品 | ⭐ 站内(本章之外)唯一一处真在用 @contextlib.contextmanager 的代码,finally 里 s.close()。本章第九节讲的就是那个 finally 为什么不能挪到 yield 后面 |
| AI全栈 01 · 第一天-两小时上线 | @app.post("/api/chat") 的出处。⭐ 路由表是 import 时填好的,不是第一个请求来了才填 —— 本章第一节 |
| PyTorch这个框架本身 07 · train 和 eval 改了什么 | 第十一节那段手工 train()/eval() 的出处。⭐ 读那一章时留意:no_grad() 是上下文管理器,train/eval 不是 |
| 框架底下是C++ 02 · 值语义与所有权 | ⭐ 它有一张表把 with / __exit__ / try-finally 一一对应到 C++ 的 RAII 和析构函数。C++ 没有 finally,因为析构函数覆盖了它的用途 —— 反过来看能理解 with 到底在补什么 |
✅ 检查点
@deco展开成哪一行代码?deco的函数体是在什么时候执行的?- 第一节那个注册表,一次函数都没调用过,
REGISTRY里为什么已经有两个键了?这个机制的代价是什么? - 不写
functools.wraps,实测会丢掉哪四样东西?其中哪一样会让@tool生成的 schema 变成一堆垃圾? @retry(3)里一共发生了几次函数调用?三层分别收什么参数、各执行几次?- 把
@retry(3)写成@retry,报错发生在哪一行、错误信息长什么样?由此得到的判据是什么? - 多个装饰器叠在一起,「应用」和「执行」的顺序分别是什么方向?鉴权和计时该怎么排?
@log和@staticmethod顺序反了会怎样?为什么说bad_static比当场报错的bad_class更贵?一行代码怎么验证?with obj as x:里的x是什么?__exit__在哪几种退出方式下会被执行?__exit__返回True会发生什么?为什么它比except Exception: pass更隐蔽?真想吞异常该用什么?@contextmanager里的yield不包在try/finally里,什么时候会出问题?为什么这个 bug 测不出来?@contextmanager的三个坑分别是什么?为什么「yield前抛的异常在with那一行就炸了」和上一章「生成器调用时一行不执行」不矛盾?- 手工
model.eval()…model.train()中途抛异常会留下什么状态?为什么这个错误在训练曲线上看起来「更好」?
👀 答案
- 展开成
f = deco(f)。deco的函数体在定义被装饰函数那一刻执行 —— 也就是模块被 import 的时候,不是调用f()的时候。实跑证据:[deco 被执行了]打印在「定义完了,还没调用过」之前。 - 因为
REGISTRY[name] = fn写在decorator里,而decorator在定义load_csv/load_json那一刻就跑了。代价是注册表靠 import 副作用填满 —— 模块没被 import 就等于没注册,你会得到「函数明明写了、框架说找不到」,而报错的位置离根因很远。 - 丢掉
__name__(变成wrapper)、__doc__(变成None)、签名(变成(*args, kwargs),参数名变成['args', 'kwargs'])、__qualname__(变成naked.<locals>.wrapper)。签名**那一样最致命:@tool读inspect.signature生成 parameters,模型收到的会是「参数叫 args 和 kwargs」。顺带wraps还挂了__wrapped__指回原函数。 - 两次:
retry(3)一次、decorator(fn)一次;之后每次flaky()调用wrapper一次。第 1 层收装饰器的配置、第 2 层收被装饰的函数(这两层各执行一次,都在定义时),第 3 层收调用时的实参(每次调用都执行)。 - 装饰那一步不报错,报错发生在调用
ping()时,信息是TypeError: retry.<locals>.decorator() missing 1 required positional argument: 'fn'。判据:错误信息里出现了装饰器内部的函数名(decorator/wrapper),八成是括号写漏了或多了。 - 应用从下往上(最靠近
def的先贴),执行从上往下(最外层先进、最后出),像洋葱。排序规矩是「谁的语义该最先生效谁写最上面」:鉴权在计时之外(未授权的请求不该被算进耗时),限流在重试之外(重试不该绕开限流)。 - 类字典里存的从
staticmethod变成了普通function。bad_class当场抛'classmethod' object is not callable,当天就会被修;而bad_static从类上调C.bad_static(3)返回 6 一切正常,只有从实例上调C().bad_static(3)才抛takes 1 positional argument but 2 were given(多出来的是self,错误信息不会告诉你)。验证:type(C.__dict__["bad_static"])应该是staticmethod。🗓️ 它能从类上调通是 Python 3.10 之后的行为,3.9 会直接炸 —— 新版本反而更危险。 x是__enter__的返回值,不一定是那个对象本身(本章例子里是个字符串;contextlib.suppress()返回None)。__exit__在正常结束、return、break、continue、抛异常五条路上都会执行。- 异常被吞掉,
with块被当作正常结束,代码继续往下跑 —— 实跑里只写进 1 条数据,但外面的except OSError什么也没捕获到。比except: pass隐蔽,是因为后者在源码里显眼地写着,而return True藏在一个看起来只负责收尾的方法里。真想吞用contextlib.suppress(具体异常类型):实跑中它吞掉FileNotFoundError,PermissionError照样往外走。 - 只有出异常那条路会漏掉清理。实跑:
naive正常路径打印了「退出后」,异常路径从来没打印过。测不出来是因为正常路径的输出完全正确,而出异常恰恰是最需要清理的时候(连接、锁、临时文件、全局状态)。 - ①
yield前的异常在with那一行就抛(__enter__内部会next()到yield);② 只能有一个yield,第二个会抛RuntimeError: generator didn't stop;③ 造出来的对象是一次性的,存进变量用第二次会炸(本机 3.13 报AttributeError,报什么错取决于版本)。不矛盾的原因:@contextmanager替你调了那一次next,所以函数体确实是在with那一行才开始跑的。 - 留在 eval 模式(实跑:手工版出异常后
model.training = False,with版是True)。曲线上看起来更好,是因为 Dropout 被关了、BatchNorm 不再更新 running 统计量,loss 反而下降得更漂亮 —— 于是没人会去怀疑一个「验证时抛过异常并被except掉」的地方。
🛑 可以停在这里
⚡ 走神救援
⭐
@deco就是f = deco(f),with x:就是try/finally。装饰器最反直觉的一点:
deco的函数体在【定义那一刻】就执行了,不是调用时——这跟上一章正好相反(生成器是「调用了也不执行」)。这个性质就是注册表的全部原理;⚠️ 代价是它靠 import 副作用,模块没被 import 就等于没注册,症状是「函数明明写了、框架说找不到」。⚠️ 一号必修是
functools.wraps。不写它会丢掉四样:__name__、__doc__、__qualname__,以及 ⭐ 最贵的签名——@tool和 FastAPI 都靠inspect.signature读参数,读到的是错的,而返回值完全正确、所以测试全绿。带参数的装饰器要三层,因为
@retry(3)里发生了两次调用。⚠️ 少写括号写成@retry,装饰那步不报错、调用时才炸——⭐ 判据:错误信息里冒出装饰器内部的函数名,就是括号写错了。叠装饰器时应用从下往上、执行从上往下。💀 叠在类方法上顺序反了最贵:
@staticmethod必须在最外层,反过来之后——classmethod那个当场报错(好事),而staticmethod那个从类上调完全正常,只有从实例上调才炸(多出来的是self)。⭐ 验证一行:type(C.__dict__["名字"])。
with那半边:⚠️as x绑的是__enter__的返回值——忘了return self会拿到None,而且不报错。它比手写try/finally多干三件事:清理逻辑有名字能复用、多资源时前面开好的一定关掉、数量不定时有ExitStack。
下一节 👉 04-GIL:为什么多线程救不了你.md