把 Python 的 with 语句拆开看:上下文管理器协议的三个底层细节
with 语句大概是 Python 里最被低估的语法糖。人人都会用 with open(...) as f,但少有人深究它背后到底发生了什么——比如 __exit__ 返回 True 为什么能吞掉异常,contextlib.contextmanager 是怎么用一个生成器模拟出两个魔法方法的,以及 async with 和普通 with 在协议上差在哪。这篇把这些底层细节一次拆开。
一、with 的展开形式
with 语句不是什么黑魔法,它完全等价于一段 try/finally。理解这一点是理解后面所有细节的基础:
with cm() as obj:
do(obj)
# 等价于
_cm = cm()
obj = _cm.__enter__()
try:
do(obj)
except BaseException as e:
if not _cm.__exit__(type(e), e, e.__traceback__):
raise
else:
_cm.__exit__(None, None, None)
注意三个点:第一,__enter__ 在 try 之外,所以 __enter__ 里抛异常不会触发 __exit__;第二,正常结束时 __exit__ 收到三个 None;第三,异常是否重新抛出,取决于 __exit__ 的返回值。这三条决定了上下文管理器的全部行为。
二、__exit__ 返回 True 吞异常的陷阱
__exit__ 如果返回真值,with 块里抛出的异常会被静默吞掉,就像外面套了个 bare except 一样。这有时是你想要的(比如 suppress),但更多时候是个隐蔽的 bug 源头。一个经典翻车场景:
class DbConn:
def __enter__(self):
return self
def __exit__(self, *exc):
self.close()
return True # 灾难:吞掉所有异常
with DbConn() as c:
c.execute(bad_sql) # 抛异常
print('继续执行') # 异常被吞,这行会执行
正确做法是 __exit__ 里只做清理,不返回真值(返回 None 即可),让异常正常向上传播。只有当你明确要 suppress 时才返回 True,而且最好用 contextlib.suppress 这种显式工具,别手写。另一个坑:__exit__ 即使吞了异常,finally 块照样会执行,因为 with 展开里异常已经被处理掉了,finally 看到的是正常退出路径。
三、contextlib.contextmanager 的原理
手写 __enter__/__exit__ 需要建类,啰嗦。contextlib.contextmanager 让你用一个生成器函数搞定,原理很巧妙:它把 yield 之前的部分当 __enter__,yield 出来的值当 as 绑定的对象,yield 之后的部分当 __exit__。
@contextmanager
def timer():
start = time.time()
yield start # 这里 "返回" 给 with 块
print(time.time() - start)
# 大致等价于
class timer:
def __enter__(self):
self._g = self._gen()
return next(self._g) # 跑到 yield,拿到 yield 的值
def __exit__(self, *exc):
try:
if exc[0] is None:
next(self._g) # 正常:推进到生成器结束
else:
self._g.throw(*exc) # 异常:把异常 throw 进生成器
except StopIteration:
return True # 生成器正常结束,吞掉 StopIteration
except BaseException:
return False # 生成器又抛了新异常,向上传播
关键在 throw:with 块里抛的异常会被 throw 进生成器,在 yield 处重新抛出。这就是为什么你可以在 @contextmanager 函数里用 try/except 捕获 with 块内的异常——异常确实被送回到了 yield 那一行。如果你在 yield 后面不写 try,异常会直接窜出生成器,contextmanager 把它转成 __exit__ 返回 False,异常继续向上传,行为正确。
四、async with 差在哪
async with 的协议和普通 with 几乎一样,只是把 __enter__/__exit__ 换成了 __aenter__/__aexit__,而且它们是协程,必须 await。差别看似只是加了个 a,但有个实际影响:__aexit__ 里可以 await,这意味着异步上下文的清理本身可以是异步的——比如异步关闭连接、等待 flush 完成。而普通 with 的 __exit__ 是同步的,在里面 await 会直接报错。
class AsyncLock:
async def __aenter__(self):
await self.acquire()
return self
async def __aexit__(self, *exc):
await self.release() # 释放也可以是异步的
async with AsyncLock() as lk:
await do_work()
一个容易混淆的点:async with 不能用 @contextmanager 装饰,得用 @asynccontextmanager。两者的实现几乎一样,只是把 next/throw 换成了 anext/athrow。Python 3.10 之前 anext 还没有,asynccontextmanager 内部是手动 send/throw 配 await 实现的。
五、小结
把 with 想成 try/finally 的语法糖,绝大多数疑惑就解了:__enter__ 在 try 外、__exit__ 的返回值控制异常传播、contextmanager 用生成器的 yield 切分两个阶段、async with 只是让这两个阶段都能 await。真正容易踩的坑就一个——别让 __exit__ 默默返回 True 吞异常,除非你真的要 suppress。
- 点赞
- 收藏
- 关注作者
评论(0)