本节目标:理解
with语句背后的上下文管理协议,学会用类或contextlib编写自己的上下文管理器,掌握suppress与ExitStack两个实用工具,并弄清with多资源写法与等价展开。
适用版本:Python 3.12+(实测 3.14.6)
7.3 上下文管理器与 with
上一节我们把异常处理讲透了。但有一类问题异常单独解决不了:资源必须「用完就还」——文件要关闭、锁要释放、数据库连接要归还连接池。如果中途抛异常,关闭代码就可能被跳过,资源泄漏。try/finally 能解决,但写起来啰嗦。with 语句就是 Python 为此提供的专门语法。
7.3.1 没有 with 的日子
先看用 try/finally 手动管理资源的写法:
def read_config(path):
f = open(path, encoding="utf-8")
try:
return f.read()
finally:
f.close() # 无论是否抛异常,都会执行
这段代码是对的,但每开一个资源就要配一套 try/finally,嵌套两层就是两层缩进。更糟的是容易漏写——打开文件、加锁、连数据库,只要有一处忘了配 finally,泄漏就埋下了。
with 把「获取—使用—释放」这个固定模式收进一个语法块:
def read_config(path):
with open(path, encoding="utf-8") as f:
return f.read()
文件在离开 with 块时保证被关闭,无论正常返回还是抛异常。这就是上下文管理器存在的意义。
7.3.2 上下文管理协议:__enter__ 与 __exit__
with 能作用在任何实现了上下文管理协议的对象上——也就是定义了 __enter__ 和 __exit__ 两个方法的对象。
class Managed:
def __init__(self, name):
self.name = name
def __enter__(self):
print(f"enter {self.name}")
return self.name.upper() # 返回值绑定给 as 后面的变量
def __exit__(self, exc_type, exc_val, exc_tb):
print(f"exit {self.name}: exc_type={exc_type}")
return False # 不吞异常
with Managed("res") as v:
print("body, value =", v)
# 输出:
# enter res
# body, value = RES
# exit res: exc_type=None
执行顺序一目了然:
- 求值
with后面的表达式,得到上下文管理器对象。 - 调用它的
__enter__(),把返回值绑定给as后面的变量。 - 执行
with块主体。 - 无论主体是否抛异常,都调用
__exit__(...)。
注意 __enter__ 的返回值和对象本身是两回事:open() 返回的文件对象,它的 __enter__ 返回的就是文件对象自己,所以 as f 拿到的是文件。但这不是强制的——__enter__ 完全可以返回别的值(比如一个连接的游标)。
7.3.3 __exit__ 的三个参数与「返回真值即吞掉异常」
__exit__ 收到三个参数,分别描述 with 块里发生的异常:
| 参数 | 含义 | 无异常时 |
|---|---|---|
exc_type | 异常类 | None |
exc_val | 异常实例 | None |
exc_tb | traceback 对象 | None |
关键规则:如果 __exit__ 返回一个真值,异常就被「吞掉」,with 之后的代码继续执行;返回假值(None / False)则异常继续向外传播。
class Swallow:
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
print("exit, swallowing:", exc_type.__name__ if exc_type else None)
return True # 真值 → 吞掉异常
with Swallow():
raise ValueError("this will be swallowed")
print("after with, still running")
# 输出:
# exit, swallowing: ValueError
# after with, still running
raise ValueError 没有让程序崩溃,因为 __exit__ 返回了 True。
绝大多数情况下 __exit__ 应该返回假值(或干脆不写 return)。吞异常是极少数场景才用的能力,比如 contextlib.suppress 就是刻意利用这个机制实现的。一个容易忽视的细节:如果 with 块里的异常类型不在 __exit__ 能处理的范围内,那就该老老实实返回假值让它传出去。
还有一点:如果 __enter__ 自己抛异常,__exit__ 不会被调用(因为资源压根没获取成功)。所以 __enter__ 内部也要保证要么成功、要么不留半成品。
7.3.4 contextlib.contextmanager:用生成器写上下文管理器
为一个简单的资源写一个完整的类,样板代码太多。contextlib.contextmanager 允许你用一个生成器函数来表达上下文管理器:yield 之前是「获取」,yield 的值就是 as 拿到的对象,yield 之后是「释放」。
import time
from contextlib import contextmanager
@contextmanager
def timer(label):
start = time.perf_counter()
try:
yield label # yield 的值绑定给 as 变量
finally:
print(f"{label}: {time.perf_counter() - start:.4f}s")
with timer("work") as label:
print("inside", label)
# 输出:
# inside work
# work: 0.0000s
要点:
yield前是__enter__,yield后是__exit__。- 异常会被「抛回」
yield那一行。也就是说,如果with块里出错,异常会在生成器的yield处重新抛出,你可以在那里try/except处理。 - 必须用
try/finally包住yield,否则with块抛异常时释放逻辑不会执行。
看一个把异常「接住再放行」的例子:
@contextmanager
def managed():
print("acquire")
try:
yield "res"
except ValueError as e:
print("handle in cm:", e)
raise # 处理完仍然抛出
finally:
print("release")
try:
with managed() as r:
print("use", r)
raise ValueError("oops")
except ValueError as e:
print("outer:", e)
acquire
use res
handle in cm: oops
release
outer: oops
注意 release 在 outer 之前打印——finally 先执行,异常随后才传到最外层。这个顺序和上一节讲的 finally 语义完全一致。
7.3.5 contextlib.suppress:优雅地忽略指定异常
有些异常你明确知道「忽略即可」,比如尝试删除一个可能不存在的文件。上一节说过别写裸 except:,suppress 提供了精确的替代:
from contextlib import suppress
with suppress(FileNotFoundError):
open("/nonexistent/path/xyz")
print("no error escaped") # no error escaped
它等价于一段 try/except FileNotFoundError: pass,但更短也更不容易写错。suppress 也可以传多个异常类型:with suppress(FileNotFoundError, PermissionError):。但它只该用于「确实无关紧要」的情况——如果异常背后可能有真问题,抑制它只会让 bug 更难查。
7.3.6 contextlib.ExitStack:动态管理多个资源
with 的静态写法要求你在写代码时就知道要开几个资源。但有时数量是运行期才知道的——比如「打开一个目录下所有文件」。ExitStack 让你把资源一个一个压入栈,离开 with 时按后进先出(LIFO)顺序统一释放。
from contextlib import ExitStack
import tempfile, os
with tempfile.TemporaryDirectory() as d:
paths = [os.path.join(d, f"f{i}.txt") for i in range(3)]
with ExitStack() as stack:
files = [stack.enter_context(open(p, "w")) for p in paths]
for i, f in enumerate(files):
f.write(f"line {i}\n")
print("opened:", len(files), "files") # opened: 3 files
print("all closed:", all(f.closed for f in files)) # all closed: True
ExitStack 的两个核心方法:
enter_context(cm):调用cm.__enter__(),把__exit__注册进栈,返回__enter__的结果。callback(fn, *args):注册一个普通函数,退出时调用。
退出顺序是严格的 LIFO(后注册的先执行):
with ExitStack() as stack:
stack.callback(lambda: print("callback 1"))
stack.callback(lambda: print("callback 2"))
print("body")
# 输出:body → callback 2 → callback 1
LIFO 不是随意选的:资源之间常有依赖关系(后开的可能依赖先开的),先释放后来的、再释放早来的,才能保证释放时不引用已销毁的东西。用 try/finally 手写嵌套也是同样的顺序。
ExitStack 特别适合动态资源 + 可选资源的场景:把「需要时才创建」的资源在运行时压栈,无论后续哪一步失败,已创建的全部会被清理。它还是写装饰器、上下文管理器组合工具的基础设施。
7.3.7 多资源 with 与等价展开
with 可以一次管理多个资源,用逗号分隔:
with open("a.txt", "w") as fa, open("b.txt", "w") as fb:
fa.write("A")
fb.write("B")
它等价于把两个 with 嵌套起来。而单个 with 又等价于下面这段 try/finally——这就是 with 的完整展开,理解了它,就理解了 with 的一切:
mgr = open("a.txt", "w")
enter = type(mgr).__enter__ # 注意:在类型上查找,跳过实例属性
exit_ = type(mgr).__exit__
fa = enter(mgr) # __enter__ 的返回值
try:
fa.write("A")
except BaseException:
if not exit_(mgr, *sys.exc_info()): # __exit__ 返回假值 → 异常继续抛
raise
else:
exit_(mgr, None, None, None) # 无异常,参数全是 None
几个从展开里能看出来的关键点:
- 异常穿透时
__exit__收到的是真实的异常三元组,返回假值就raise原异常。 - 正常结束时
__exit__收到三个None。 - 查找
__enter__/__exit__用的是类型而不是实例,所以给实例动态挂方法是不生效的。
Python 3.10 起,with 还支持带括号的多行写法,用 with ( 起头、每个资源一行、) 收尾,长资源列表更易读。
7.3.8 为什么文件、锁、连接都做成上下文管理器
回头看「上下文管理器」的本质:它把「成对的进入与退出」抽象成一个可复用的协议。 凡是具备这个形状的资源,都适合做成上下文管理器:
| 资源 | 进入 | 退出 |
|---|---|---|
| 文件 | open() | close() |
| 线程锁 | acquire() | release() |
| 数据库连接 | 从连接池取出 | 归还连接池 / 回滚未提交事务 |
| 临时目录 | 创建 | 递归删除 |
| 计时器 | 记录起始时间 | 打印/上报耗时 |
以锁为例,threading.Lock 本身就是上下文管理器:
import threading
lock = threading.Lock()
with lock:
print("locked, locked() =", lock.locked())
print("after with, locked() =", lock.locked())
locked, locked() = True
after with, locked() = False
如果不用 with,就得在 acquire() 之后配一个 finally: lock.release();一旦中间有 return 或异常,漏掉释放就死锁。上下文管理器的价值就是把「配对」变成语言层面的保证,让你不可能忘记释放。
自己实现时,一个常见的增强是让 __exit__ 处理事务语义——比如数据库连接在异常时回滚、正常时提交:
class Transaction:
def __init__(self, conn):
self.conn = conn
def __enter__(self):
self.conn.begin()
return self.conn
def __exit__(self, exc_type, exc_val, exc_tb):
self.conn.rollback() if exc_type else self.conn.commit()
return False # 不吞异常,让调用方知道失败了
__exit__ 拿到了 exc_type,就能根据「有没有出错」决定提交还是回滚——这正是它比 try/finally 更强的地方:释放逻辑可以感知异常。
7.3.9 一句话预告 async with
异步世界里有一个对应的协议:__aenter__ / __aexit__,配合同样是异步的 async with 使用,语法形状完全一致:
async with aiohttp.ClientSession() as session: # 第 13 章展开
...
它解决的是同一个问题——只是「进入」和「退出」本身也变成了需要 await 的异步操作。第 13 章会结合事件循环详细讲。
小结
with把「获取—使用—释放」固定模式收进一个语法块,保证资源一定被释放,即使中途抛异常。- 上下文管理协议 =
__enter__(返回值绑定给as变量)+__exit__(exc_type, exc_val, exc_tb)。 __exit__返回真值即吞掉异常,返回假值则异常继续传播;多数情况应返回假值。__enter__抛异常时__exit__不会被调用。@contextmanager把生成器变成上下文管理器:yield前是进入、yield后是退出,异常会抛回yield处,务必用try/finally包住yield。suppress精确忽略指定异常;ExitStack在运行期动态压入多个资源,退出时按 LIFO 统一释放。with A as a, B as b等价于嵌套with,而单个with展开成try/finally;Python 3.10 起支持带括号的多行写法。- 文件、锁、连接、临时目录都适合做成上下文管理器;
__exit__能感知异常,可据此实现提交/回滚等事务语义。 - 异步版本是
__aenter__/__aexit__与async with,第 13 章展开。
到这里第 7 章就结束了:我们讲了异常如何表达错误、如何设计错误类型、如何用 with 管理资源。下一章进入 Python 最优雅的机制之一——迭代协议与生成器:for 循环背后发生了什么,yield 如何让函数「暂停」,以及如何用生成器写出惰性、省内存的数据管道。
阅读导航:上一节:7.2 自定义异常、异常链与错误设计 · 下一节:8.1 可迭代协议、迭代器与生成器 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。