K 的一隅

Python Python 语言核心

with 保证进出成对:上下文管理器

with 语句调用 __enter__ 与 __exit__;异常时 __exit__ 仍执行;contextlib 提供装饰器与 suppress 等便捷写法。

8 分钟阅读 更新于 2026-07-28
本文目录

打开文件、加锁、临时改目录、开启数据库事务——这类操作都有固定模式:进入时 setup,离开时 teardown。若中间抛异常,teardown 仍必须跑,否则泄漏句柄、死锁或脏数据留在磁盘上。手写 try/finally 可行但重复且容易漏;with 把这套模式收成一条语句,由上下文管理器协议保证对称。读标准库源码时,你会反复看到成对的 acquire/release、enter/exit,语法层则统一叫「上下文管理器」。PEP 343 引入 with 正是为了把这种 recurring pattern 从样板里解放出来。

迭代篇讲如何逐个取值;这一篇讲如何限定一段代码的生存期,让资源在块结束时可靠释放。理解协议后,自己写小型计时器、临时环境变量、测试用 patch 都会用同一套 __enter__ / __exit__@contextmanager 骨架。可以把 with 块理解成「作用域 + 生命周期」:不仅变量可见性受缩进约束,连外部资源的借还也绑在同一视觉边界上。

with 语句与协议

表达式 with mgr as var: 在 CPython 里大致等价于(语义层面):

  1. 调用 mgr.__enter__(),返回值绑给 as 后的变量(可省略 as)。
  2. 执行 with 块。
  3. 无论块内是否异常,调用 mgr.__exit__(exc_type, exc_val, exc_tb)
python
class Tag:
    def __init__(self, name: str) -> None:
        self.name = name

    def __enter__(self) -> "Tag":
        print(f"<{self.name}>")
        return self

    def __exit__(
        self,
        exc_type: type[BaseException] | None,
        exc_val: BaseException | None,
        exc_tb: object | None,
    ) -> bool:
        print(f"</{self.name}>")
        return False  # 不吞异常


with Tag("p") as t:
    print(f"inside {t.name}")
# 输出 <p> inside p </p>

__exit__ 的三个参数在正常结束时为 None;块内抛错时传入异常类型、实例和 traceback。若 __exit__ 返回真值,会抑制 with 块内抛出的异常(相当于在 exit 里处理掉了);返回 FalseNone 则异常继续向外传播。文件、锁的管理器通常返回 False,让调用方仍能感知错误;只在明确「这里已消化错误、不应再向上冒」时才返回 True——滥用会掩盖 bug。

内置 open() 返回的对象实现了上下文管理器:离开 with 时自动 close(),即便块里读了半截抛错也会关文件。这比手写 try/finally 更短,且把「句柄生命周期」绑在缩进块上,阅读时边界清晰:

python
with open("data.txt", encoding="utf-8") as f:
    for line in f:
        process(line)
# 此处 f 已关闭,再 f.read() 会 ValueError

多个上下文管理器

Python 3.1+ 可写 with A() as a, B() as b:,等价于嵌套 with,进入顺序从左到右,退出顺序从右到左

python
with open("a.txt", encoding="utf-8") as fa, open("b.txt", encoding="utf-8") as fb:
    left = fa.read()
    right = fb.read()

若第一个 __enter__ 成功、第二个失败,已成功的管理器仍会按相反顺序调 __exit__ 清理。上下文数量运行时才知道时,用 contextlib.ExitStack:在 with 块里 stack.enter_context(mgr) 动态压栈,退出时统一弹出,适合「循环里打开 N 个文件再一并处理」的模式。ExitStack 也可 callback 注册任意 teardown 函数,不必每个资源都写完整类。

类实现 vs contextlib

手写 __enter__ / __exit__ 最直观,适合要在 enter 里构造复杂对象、在 exit 里根据 exc_type 决定 commit/rollback 的场景。contextlib.contextmanager生成器包装:一次 yield 之前是 enter,之后(通常在 finally 里)是 exit:

python
from contextlib import contextmanager
from collections.abc import Iterator


@contextmanager
def temp_cwd(path: str) -> Iterator[None]:
    import os
    prev = os.getcwd()
    os.chdir(path)
    try:
        yield
    finally:
        os.chdir(prev)

yield 右侧的值会作为 as 绑定的对象;上面例子只 yield None。若在 with 块里抛错,生成器会在 yield 处恢复,然后执行 finally 里的 chdir 还原——与手写类版行为一致。写 @contextmanager 时务必用 try/finally 包住 yield,否则块内异常可能导致清理代码根本不跑。

常用 contextlib 辅助:

  • suppress(FileNotFoundError):在 with 块内忽略指定异常,代替空的 except,意图更明确。
  • closing(obj):对只有 close()、没写完整协议的对象包一层。
  • nullcontext():什么都不做,方便「有时需要 with、有时不需要」的统一写法。
  • redirect_stdout / redirect_stderr:测试里捕获输出时常用。

异步代码里有 async with,对应 __aenter__ / __aexit__,由 @asynccontextmanager 装饰异步生成器;机制与同步版平行,细节留给异步章节。

典型场景与反模式

场景管理器
文件open(...)
with lock:
计时 / 日志跨度自写或第三方
数据库事务connection 的 with / begin

反模式一:在 with 块外仍使用已关闭的文件或已释放的锁——as 变量只在块内保证状态有效。反模式二:在 __exit__ 里吞掉所有异常却不记录。反模式三:把耗时的业务逻辑塞进 __enter__,导致「进 with 之前就已经阻塞很久」——enter 应只做获取资源所需的最小工作。

上下文管理器与装饰器可以组合:例如用 with 包一层临时 monkeypatch,exit 时还原模块属性。核心不变:成对;无论成功失败,exit 都要能跑到。

ExitStack 与 suppress 示例

动态数量资源时,ExitStack 比手写 N 层嵌套 with 更可维护:

python
from contextlib import ExitStack
from pathlib import Path


def merge_files(paths: list[Path]) -> str:
    parts: list[str] = []
    with ExitStack() as stack:
        files = [stack.enter_context(p.open(encoding="utf-8")) for p in paths]
        for f in files:
            parts.append(f.read())
    return "\n".join(parts)

任一 open 失败,已成功打开的文件仍会被 stack 按相反顺序关闭。stack.callback(fn) 可在退出时调任意清理函数,即使没实现完整协议。

suppress 适合「文件可能不存在、没有也算正常」的路径:

python
from contextlib import suppress
from pathlib import Path

cache = Path("cache.json")
with suppress(FileNotFoundError):
    cache.unlink()

比 bare except 窄,读代码的人一眼知道忽略了哪种失败。别用 suppress 包住宽泛的 Exception,那和吞错无异。

类版管理器若持有 OS 资源,在 __exit__ 里应保证 idempotent:多次 close 不炸。文件对象第二次 close 在 CPython 里通常安全,锁 release 则需自己防重入。

线程锁的 with lock: 在 enter 时 acquire、exit 时 release,即便块内抛错也会放锁——这是避免死锁的关键。数据库连接上下文往往在 exit 里 commit 或 rollback:exc_type is None 时提交,否则回滚,再 close 连接。模式固定,但每个驱动 API 名字不同,语义都是「块成功则提交,失败则回滚,最后归还连接池」。

测试里常用 unittest.mock.patch 作为上下文管理器,或 contextlib.redirect_stdout 捕获 print。写自己的计时上下文时,enter 记 time.perf_counter(),exit 里算差值打印即可——不必继承复杂基类。

@contextmanager 装饰的函数不能与普通 yield 生成器混用业务逻辑:整个函数体只能有一个 yield 点(除非拆成多个 contextmanager)。若需要多个 yield,应拆成多个管理器或改类实现。enter 与 exit 之间若要用 as 绑定非 None 对象,yield 该对象:yield resource

同步 with 与异步 async with 不要混在同一函数里除非明确 await;IO 密集型异步服务里,文件有时用 aiofiles,其 async with 走 __aenter__。概念上与同步版平行:进入借资源,离开还资源,异常同样触发 exit。

把上下文管理器当成「缩进块绑定的 RAII」有助于从 C++ 等语言转过来的读者:对象生命周期不超出 with,编译器(这里是解释器协议)保证 exit。差别是 Python 的 exit 可以决定吞不吞异常,灵活性更大,责任也更大。团队规范里可约定:业务管理器默认 return False,只有 UI 层或 CLI 最外层才吞用户输入错误。

自定义类若同时实现 enter 与 exit,enter 里抛错时 exit 不会被调用——与构造失败类似,此时对象可能处于半初始化,调用方应丢弃该管理器实例。contextmanager 生成器若在 yield 前抛错,同样没有 teardown,因此 heavy setup 也建议放 try/finally 或保证 enter 失败时无资源泄漏。

标准库 decimal.localcontext()unittest.mock.patch 都是典型 with 场景:前者临时改算术上下文,后者临时替换属性,块结束自动还原。读第三方库文档时看到「supports context manager protocol」,指的就是可安全用于 with。自己写库若 export 了需要 close 的对象,实现协议比文档里写「请记得调 close」可靠一个数量级。

嵌套 with 深度过深时,可考虑 ExitStack 或拆函数:每个 with 块对应一个清晰子任务,避免「向右漂移的三角形」缩进。Code review 看到 try/finally 手动 close 仍常见,可温和建议改成 with——语义等价,读者更少数 finally 里的分支。

上下文管理器不替代异常处理:with 保证 exit,不保证业务成功。块内该 raise 仍 raise,只是在抛错前后资源状态一致。把「业务 except」与「资源 with」分层写,比一个大 try 包住全部更清晰——这也是 else 在异常篇、with 在本章各管一段的原因。典型组合是:外层 with 管文件,内层 try/except 管解析格式,finally 不再重复 close。这样读代码的人能分开看「资源借还」与「数据是否合法」,维护时改其中一层不会误伤另一层。contextlib 里的工具函数大多可单独 import,不必为用一个 suppress 就引入整套框架依赖。写库时在 docstring 标明「推荐 with 使用」,能降低调用方忘记 close 的概率,也属于对外 API 契约的一部分。调用示例里尽量给出 with 块,比只写构造器更友好。

小结

上下文管理器用 __enter__ / __exit__(或 @contextmanager 的 yield 分裂)保证 setup/teardown 对称。with 是语法糖,异常路径也走 __exit__contextlib 减轻样板代码。下一篇转向类型结构:ABCabstractmethod,在类层次上声明「子类必须实现什么」。