资讯详情

资讯详情

Python上下文管理器高级用法全解析

前言上下文管理器context manager解决的是一个非常具体的问题一段资源在使用完毕后必须被释放而释放动作在正常返回、提前return、抛异常这三种路径下都不能漏掉。文件句柄、网络连接、线程锁、临时目录、数据库事务都属于这类资源。手写try/finally当然能覆盖这些路径但只要代码块变长、嵌套变深重复的样板就会淹没逻辑本身。这里有两个流传很广的误解。第一个是with的作用就是自动关文件其实with只负责调用__exit__至于__exit__里做什么关文件、回滚事务、恢复现场完全由你决定。第二个更危险with并不会吞掉异常。with open(...) as f: 1/0一样会把ZeroDivisionError抛出去with只是保证在抛出之前先执行了f.close()。把with当成异常保险箱是后面很多诡异 bug 的源头。本文先讲清__enter__/__exit__协议的返回值含义再给出contextlib.contextmanager的正确写法与它自带的限制最后覆盖ExitStack、suppress、nullcontext这类容易忽略但很好用的进阶工具。一、协议本体__enter__与__exit__with语句要求对象实现两个方法。__enter__()在进入代码块时调用一次它的返回值会绑定到as后面的名字__exit__()在离开代码块时调用一次无论正常还是异常。关键在__exit__的返回值和参数参数依次是exc_type、exc_value、traceback。正常退出时三者都是None发生异常时是异常的类型、实例、回溯对象。返回真值表示这个异常我处理了解释器会吞掉它继续执行with后面的代码。返回False或None不写return就是None表示我不处理异常照常向外传播。__exit__内部自己抛异常会替换掉正在传播的那个异常。# 适用于 Python 3.8import timeclass Timer:统计代码块耗时支持嵌套。def __enter__(self):self._start time.perf_counter()return selfdef __exit__(self, exc_type, exc_value, traceback):self.elapsed time.perf_counter() - self._startreturn False # 不吞异常等价于 None显式写出来更清楚with Timer() as t:total sum(range(100_000))print(f耗时 {t.elapsed:.4f} 秒结果 {total})注意Timer.__exit__里即使发生了异常也照常记录耗时然后把False返回异常继续向上抛。这正是try/finally的语义而不是try/except的语义。二、用装饰器把函数变成上下文管理器为每个小工具都写一个类太啰嗦。contextlib.contextmanager允许你写一个恰好yield一次的生成器函数yield之前的部分是进入yield之后的部分是退出。# 适用于 Python 3.8from contextlib import contextmanagercontextmanagerdef changed_dir(path):import osold os.getcwd()os.chdir(path)try:yield pathfinally:os.chdir(old)这里有两个必须掌握的点。第一yield处就是代码块执行的位置如果代码块里抛了异常该异常会在yield这一行被重新抛出到生成器内部。第二因此清理逻辑必须放在finally里。如果只写在yield后面而不管异常一旦代码块出错清理就永远不会执行——这是contextmanager最常见的写错方式。如果你确实想在生成器里消掉异常必须用try/except捕获后不再重新抛出此时contextmanager会把这个生成器的正常结束当作__exit__返回True异常被吞掉。反过来如果你希望异常继续传播就让它自然冒出yield即可。三、进阶工具箱ExitStack 与几个小工具contextlib.ExitStack解决要打开多少个资源取决于运行时数据的问题。它把多个上下文管理器挂到同一个with上退出时按后进先出的顺序依次清理。# 适用于 Python 3.8from contextlib import ExitStackpaths [a.txt, b.txt]with ExitStack() as stack:handles [stack.enter_context(open(p, w, encodingutf-8))for p in paths]for h in handles:h.write(hello\n)# 退出这里时两个文件按 b 先 a 后的顺序被关闭ExitStack还提供了stack.callback(func, *args)可以把任意清理函数不一定是上下文管理器注册进去同样保证被执行。另外几个常被忽略的工具工具作用典型场景contextlib.suppress(*excs)吞掉指定异常等价于空的except删除可能不存在的文件contextlib.nullcontext(x)什么都不做只把x交给as让上下文管理器变成可选开关contextlib.redirect_stdout(f)临时重定向sys.stdout捕获函数里的printcontextlib.closing(thing)退出时调用thing.close()只有close()没有with的对象# 适用于 Python 3.8import iofrom contextlib import nullcontext, redirect_stdout, suppressfrom pathlib import PathPath(tmp.txt).write_text(x, encodingutf-8)with suppress(FileNotFoundError):Path(tmp.txt).unlink()Path(tmp.txt).unlink() # 第二次不会抛异常buffer io.StringIO()with redirect_stdout(buffer):print(这行进了 buffer)print(buffer.getvalue().strip())# 用一个开关决定要不要真的重定向use_redirect Falsecm redirect_stdout(buffer) if use_redirect else nullcontext()with cm:print(走到哪里都行)nullcontext的价值在于调用方不必为要不要启用某个上下文写两套分支代码。四、异步与可重入Python 3.7 起提供了contextlib.asynccontextmanager配合async with使用写法与同步版完全对称。Python 3.10 起还新增了contextlib.aclosing()用于安全关闭异步生成器。# 适用于 Python 3.7from contextlib import asynccontextmanagerasynccontextmanagerasync def acquire(resource):await resource.open()try:yield resourcefinally:await resource.close()需要区分的是可重入与可重复使用。threading.Lock是上下文管理器但不是可重入的除非用RLock。而contextmanager装饰出来的对象是一次性的它本质上是一个生成器第二次进入会直接抛RuntimeError: generator didnt yield。如果需要反复使用写类而不是用装饰器。常见坑点❌ 把清理写在yield之后、finally之外✅ 清理必须放进finally否则代码块抛异常时清理被跳过 python # ❌ 有异常时 os.chdir(old) 不执行 old os.getcwd(); os.chdir(p); yield p; os.chdir(old) # ✅ old os.getcwd(); os.chdir(p) try: yield p finally: os.chdir(old)❌ 认为with会吞掉异常✅with只保证调用__exit__吞异常只有靠__exit__返回真值或contextlib.suppress。with open(...) as f: 1/0照旧抛ZeroDivisionError❌__exit__里写return True想顺手处理一下✅ 返回真值会静默吞掉包括KeyboardInterrupt、SystemExit在内的所有异常只想吞特定异常就判断exc_type❌ 复用同一个contextmanager生成的对象✅ 它只能进入一次第二次进入抛RuntimeError需要复用请改用类❌ 以为with会开一个新作用域块内变量出了块就没了✅ Python 的with不创建作用域块内绑定的名字在块外依然可见❌ 手工写f open(...)然后靠f.close()中间抛异常就泄漏句柄✅ 用with open(...) as f:关闭由__exit__兜底❌ 用ExitStack打开的文件以为关闭顺序是先进先出✅ 清理顺序是后进先出栈语义有依赖关系时要按这个顺序安排❌ 在__exit__里做可能失败的清理且不做保护✅__exit__自身抛出的异常会顶替正在传播的异常导致真正的错误被掩盖清理动作要尽量做成不会失败的操作总结概念要点__enter__返回as后面的对象__exit__返回真值吞异常None/False继续传播contextmanageryield一次清理放finally对象一次性ExitStack动态数量资源后进先出清理nullcontext让可选上下文不用写两条分支上下文管理器的精髓不在语法糖而在于把必须执行的清理从人的记忆里挪进类型协议里。把__exit__的返回值语义、contextmanager的单次可用性和with不吞异常这三点记牢绝大多数资源泄漏和异常被误吞的问题都不会再发生。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →