每次写 with open('data.txt') as f: 时,你已经在用上下文管理器了。但很多人对它的理解停留在"自动关闭文件"这一层,遇到需要管理锁、数据库连接、临时目录切换的场景,往往还是手动写 try/finally。这篇文章把 with 的机制拆开,再给出几种自己写上下文管理器的实用方式。
with 到底做了什么
with 语句的本质是:在进入代码块时获取资源,在退出代码块时释放资源——无论代码块是正常结束还是抛了异常。展开来看,它等价于:
mgr = open('data.txt')
f = mgr.__enter__()
try:
# 你的代码块
content = f.read()
finally:
mgr.__exit__(None, None, None)
__enter__ 返回的对象赋给 as 后面的变量;__exit__ 在 finally 里被调用,保证资源释放。三个参数 exc_type, exc_val, exc_tb 在没有异常时全是 None,有异常时传入异常信息。如果 __exit__ 返回 True,异常会被吞掉;返回 False(默认),异常继续向外传播。
常见的内置上下文管理器
除了 open(),标准库里有不少现成的:
| 管理器 | 用途 |
|---|---|
threading.Lock |
获取/释放互斥锁 |
contextlib.suppress(Exception) |
静默忽略指定异常 |
contextlib.redirect_stdout(target) |
临时重定向标准输出 |
decimal.Context |
临时切换 decimal 精度 |
tempfile.TemporaryDirectory |
创建并自动清理临时目录 |
一个容易被忽略的用法——用 suppress 替代空的 try/except:
import contextlib
# 以前你可能这样写
try:
os.remove('tmp.log')
except FileNotFoundError:
pass
# 现在可以这样写
with contextlib.suppress(FileNotFoundError):
os.remove('tmp.log')
意图更清晰,也少了一层缩进。
手写类式上下文管理器
当你需要管理自定义资源(数据库连接、REST 会话、临时修改全局状态),最直接的方式是写一个带 __enter__ 和 __exit__ 的类。
下面是一个"临时切换工作目录"的完整示例,可以直接复制运行:
import os
class WorkingDirectory:
"""进入时切换到指定目录,退出时恢复原目录,即使内部抛异常也不丢。"""
def __init__(self, target_dir):
self.target_dir = target_dir
self.original_dir = None
def __enter__(self):
self.original_dir = os.getcwd()
os.chdir(self.target_dir)
return self # as 后面的变量拿到 self,可以继续访问属性
def __exit__(self, exc_type, exc_val, exc_tb):
os.chdir(self.original_dir)
return False # 不吞异常,让它正常传播
# 使用示例
print("当前目录:", os.getcwd())
with WorkingDirectory('/tmp'):
print("进入后目录:", os.getcwd())
# 即使这里 raise ValueError("boom"),退出后目录也会恢复
print("退出后目录:", os.getcwd())
运行前把 /tmp 改成你系统上存在的路径。关键点:__exit__ 里不返回 True,所以如果代码块抛异常,异常会正常上报,但目录已经恢复了——这正是 try/finally 手写时容易遗漏的地方。
用 contextlib 简化:生成器方式
如果不需要复杂的状态管理,contextlib.contextmanager 装饰器比类写法省很多代码。你只需要写一个生成器,yield 之前是"进入",yield 之后是"退出"。
一个实际场景:给长时间运行的函数加计时日志。
import contextlib
import time
import logging
logging.basicConfig(level=logging.INFO)
@contextlib.contextmanager
def timer(label):
start = time.perf_counter()
yield # 这里暂停,交给 with 代码块执行
elapsed = time.perf_counter() - start
logging.info(f"{label} 耗时 {elapsed:.3f}s")
# 使用
with timer("数据加载"):
# 模拟耗时操作
_ = [i ** 2 for i in range(10 ** 6)]
# 输出类似: INFO:root:数据加载 耗时 0.042s
生成器方式的注意事项:yield 后面的清理代码相当于 __exit__ 的 finally 部分,一定会执行。但如果想在 yield 之后捕获代码块的异常,需要自己写 try/except 包住 yield——不捕获的话异常会正常传播,清理代码依然先跑完。
多资源同时管理
with 支持同时管理多个资源:
with open('input.txt') as src, open('output.txt', 'w') as dst:
dst.write(src.read())
但有个细节:如果第二个 open() 抛异常,第一个文件已经进入了 __enter__,所以它的 __exit__ 仍会被调用。Python 3.1+ 的多 with 保证了这一点。如果用嵌套写法(with ... : with ... :),行为一样,只是缩进更深。
对于三个以上的资源,嵌套写法可读性下降,可以用 contextlib.ExitStack:
import contextlib
files = ['a.txt', 'b.txt', 'c.txt']
with contextlib.ExitStack() as stack:
handles = [stack.enter_context(open(f)) for f in files]
# 所有文件在退出时自动关闭,即使中间某个 open 失败
ExitStack 会按进入的逆序调用各管理器的 __exit__,和嵌套 with 的语义一致。
什么时候该自己写上下文管理器
一个简单的判断标准:如果你写了 try/finally 只是为了在 finally 里做清理,就该考虑上下文管理器。典型场景:
- 数据库连接/事务的获取与回滚
- 文件锁或线程锁的获取与释放
- 临时修改环境变量、工作目录、日志级别后恢复
- 测试中 mock 某个外部服务后还原
不需要上下文管理器的情况:清理逻辑本身就很简单且只出现一次,包一层管理器反而增加阅读成本。工具的选择取决于复用频率——同一个 try/finally 模式出现两次以上,就值得提取。
检查清单
写自己的上下文管理器时,对照这几条:
__exit__是否处理了异常参数——决定吞掉还是传播,别默认返回True。__enter__返回什么——返回self最常见,但也可以返回其他对象(比如open()返回文件对象而非管理器本身)。- 生成器方式中
yield是否被 try 包住——如果需要在退出时根据异常做不同处理,必须包;如果只做无条件清理,不用包。 - 多资源场景是否用了
ExitStack——超过两个资源时优先考虑。 - 清理顺序是否正确——
ExitStack和嵌套with都是逆序释放,和直觉一致,但自己手写类组合时要注意。
上下文管理器不是什么高级魔法,它是 Python 把"获取-使用-释放"这个模式标准化后的产物。用好内置的、在重复场景自己写一个,代码里裸 try/finally 的数量会明显减少。