把 Python 的 with 语句拆开看:上下文管理器协议的三个底层细节

举报
yd_263393080 发表于 2026/11/03 09:04:54 2026/11/03
【摘要】 从 __enter__/__exit__ 协议、异常吞咽陷阱、contextlib 的实现原理到 async with 的差异,把平时用 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。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。