本文目录
打开文件、加锁、临时改目录、开启数据库事务——这类操作都有固定模式:进入时 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 里大致等价于(语义层面):
- 调用
mgr.__enter__(),返回值绑给as后的变量(可省略as)。 - 执行 with 块。
- 无论块内是否异常,调用
mgr.__exit__(exc_type, exc_val, exc_tb)。
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 里处理掉了);返回 False 或 None 则异常继续向外传播。文件、锁的管理器通常返回 False,让调用方仍能感知错误;只在明确「这里已消化错误、不应再向上冒」时才返回 True——滥用会掩盖 bug。
内置 open() 返回的对象实现了上下文管理器:离开 with 时自动 close(),即便块里读了半截抛错也会关文件。这比手写 try/finally 更短,且把「句柄生命周期」绑在缩进块上,阅读时边界清晰:
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,进入顺序从左到右,退出顺序从右到左:
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:
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 更可维护:
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 适合「文件可能不存在、没有也算正常」的路径:
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 减轻样板代码。下一篇转向类型结构:ABC 与 abstractmethod,在类层次上声明「子类必须实现什么」。