🏠 总目录📚 本教程 03 · 装饰器与上下文管理器 ← →
📑 本页目录(点开跳转)

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)

关键信息

[原函数]
__name__ = original
__doc__ = '把 a 和 b 加起来再乘以 scale。'
签名 = (a, b=10, *, scale=1.0)
参数名 = ['a', 'b', 'scale']
[naked]
__name__ = wrapper
__doc__ = None
签名 = (*args, **kwargs)
参数名 = ['args', 'kwargs']
[wraps]
__name__ = original
__doc__ = '把 a 和 b 加起来再乘以 scale。'
签名 = (a, b=10, *, scale=1.0)
参数名 = ['a', 'b', 'scale']
__qualname__ : naked -> naked.<locals>.wrapper | wraps -> original
__wrapped__ : naked 有吗 False | wraps 指回原函数 True

💀 事故的完整形状:

项 内容
症状 装饰过的函数跑起来完全正常,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())

执行记录:按先后顺序读

  1. --- 定义 flaky ---
  2. [1] retry(times) 执行,times = 3
  3. [2] decorator(fn) 执行,fn = flaky
  4. --- 定义完了 ---
  5. [3] 第 1 次失败: 第 1 次不行
  6. [3] 第 2 次失败: 第 2 次不行
  7. 结果: 第 3 次成功
  8. [1] retry(times) 执行,times = 3
  9. [2] decorator(fn) 执行,fn = flaky2
  10. 手写等价: 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__)

关键信息

[log] ok_static args= (3,)
ok_static : 6
[log] ok_class args= (<class '__main__.C'>, 3)
ok_class : C:3
[log] bad_static args= (3,)
bad_static: 6
[log] bad_class args= (3,)
bad_class: TypeError : 'classmethod' object is not callable
类字典里存的是什么类型:
ok_static -> staticmethod
bad_static -> function
ok_class -> classmethod
bad_class -> function

⭐ 最后四行是全部证据:正确顺序下类字典里存的是 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)

操作步骤

Swallow : ⚠️ with 块【正常结束】了,代码继续往下跑
Swallow : 实际写进去 1 条 -> ['第一条']
---
Propagate: 外面捕获到 OSError: 磁盘满了
Propagate: 实际写进去 1 条 -> ['第一条']
--- 真想吞,用 contextlib.suppress,写明吞哪一种 ---
FileNotFoundError 被吞了,程序还在跑
PermissionError 没被吞: 别的异常照样往外走

⭐ 两行输出的差别就是全部:两边都只写进去 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__)

操作步骤

① with 那一行就抛了: ValueError : 只支持 csv
② 两个 yield -> RuntimeError : generator didn't stop
打开 a.csv
关闭 a.csv
③ 同一个对象用第二次 -> AttributeError
坑 机制 怎么办
① 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 到底在补什么

✅ 检查点

  1. @deco 展开成哪一行代码?deco 的函数体是在什么时候执行的?
  2. 第一节那个注册表,一次函数都没调用过,REGISTRY 里为什么已经有两个键了?这个机制的代价是什么?
  3. 不写 functools.wraps,实测会丢掉哪四样东西?其中哪一样会让 @tool 生成的 schema 变成一堆垃圾?
  4. @retry(3) 里一共发生了几次函数调用?三层分别收什么参数、各执行几次?
  5. 把 @retry(3) 写成 @retry,报错发生在哪一行、错误信息长什么样?由此得到的判据是什么?
  6. 多个装饰器叠在一起,「应用」和「执行」的顺序分别是什么方向?鉴权和计时该怎么排?
  7. @log 和 @staticmethod 顺序反了会怎样?为什么说 bad_static 比当场报错的 bad_class 更贵?一行代码怎么验证?
  8. with obj as x: 里的 x 是什么?__exit__ 在哪几种退出方式下会被执行?
  9. __exit__ 返回 True 会发生什么?为什么它比 except Exception: pass 更隐蔽?真想吞异常该用什么?
  10. @contextmanager 里的 yield 不包在 try/finally 里,什么时候会出问题?为什么这个 bug 测不出来?
  11. @contextmanager 的三个坑分别是什么?为什么「yield 前抛的异常在 with 那一行就炸了」和上一章「生成器调用时一行不执行」不矛盾?
  12. 手工 model.eval() … model.train() 中途抛异常会留下什么状态?为什么这个错误在训练曲线上看起来「更好」?
👀 答案
  1. 展开成 f = deco(f)。deco 的函数体在定义被装饰函数那一刻执行 —— 也就是模块被 import 的时候,不是调用 f() 的时候。实跑证据:[deco 被执行了] 打印在「定义完了,还没调用过」之前。
  2. 因为 REGISTRY[name] = fn 写在 decorator 里,而 decorator 在定义 load_csv / load_json 那一刻就跑了。代价是注册表靠 import 副作用填满 —— 模块没被 import 就等于没注册,你会得到「函数明明写了、框架说找不到」,而报错的位置离根因很远。
  3. 丢掉 __name__(变成 wrapper)、__doc__(变成 None)、签名(变成 (*args, kwargs),参数名变成 ['args', 'kwargs'])、__qualname__(变成 naked.<locals>.wrapper)。签名**那一样最致命:@tool 读 inspect.signature 生成 parameters,模型收到的会是「参数叫 args 和 kwargs」。顺带 wraps 还挂了 __wrapped__ 指回原函数。
  4. 两次:retry(3) 一次、decorator(fn) 一次;之后每次 flaky() 调用 wrapper 一次。第 1 层收装饰器的配置、第 2 层收被装饰的函数(这两层各执行一次,都在定义时),第 3 层收调用时的实参(每次调用都执行)。
  5. 装饰那一步不报错,报错发生在调用 ping() 时,信息是 TypeError: retry.<locals>.decorator() missing 1 required positional argument: 'fn'。判据:错误信息里出现了装饰器内部的函数名(decorator / wrapper),八成是括号写漏了或多了。
  6. 应用从下往上(最靠近 def 的先贴),执行从上往下(最外层先进、最后出),像洋葱。排序规矩是「谁的语义该最先生效谁写最上面」:鉴权在计时之外(未授权的请求不该被算进耗时),限流在重试之外(重试不该绕开限流)。
  7. 类字典里存的从 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 会直接炸 —— 新版本反而更危险。
  8. x 是 __enter__ 的返回值,不一定是那个对象本身(本章例子里是个字符串;contextlib.suppress() 返回 None)。__exit__ 在正常结束、return、break、continue、抛异常五条路上都会执行。
  9. 异常被吞掉,with 块被当作正常结束,代码继续往下跑 —— 实跑里只写进 1 条数据,但外面的 except OSError 什么也没捕获到。比 except: pass 隐蔽,是因为后者在源码里显眼地写着,而 return True 藏在一个看起来只负责收尾的方法里。真想吞用 contextlib.suppress(具体异常类型):实跑中它吞掉 FileNotFoundError,PermissionError 照样往外走。
  10. 只有出异常那条路会漏掉清理。实跑:naive 正常路径打印了「退出后」,异常路径从来没打印过。测不出来是因为正常路径的输出完全正确,而出异常恰恰是最需要清理的时候(连接、锁、临时文件、全局状态)。
  11. ① yield 前的异常在 with 那一行就抛(__enter__ 内部会 next() 到 yield);② 只能有一个 yield,第二个会抛 RuntimeError: generator didn't stop;③ 造出来的对象是一次性的,存进变量用第二次会炸(本机 3.13 报 AttributeError,报什么错取决于版本)。不矛盾的原因:@contextmanager 替你调了那一次 next,所以函数体确实是在 with 那一行才开始跑的。
  12. 留在 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

打卡记录保存在你的浏览器里,首页能看到总进度