凌晨三点,小陈盯着屏幕上的报错信息,头皮发麻。
“ResourceWarning: Unclosed file”
就这一行警告,让他在一堆历史代码里翻了两个小时。打开的文件忘记关了,数据库连接没释放,临时修改的目录路径也没改回来。代码跑起来没问题,但跑久了服务器就开始报“too many open files”。
同事老张路过,瞥了一眼屏幕:“你还在手动写 try...finally 呢?用 contextlib 啊,几行装饰器的事。”
小陈一脸懵:“那是什么?”
这就是很多 Python 开发者都会经历的阶段——被资源管理问题折磨过后,才发现标准库里藏着一个宝藏模块。
一、从一个没人关的文件说起
先看一段最常见的代码:
f = open('data.txt', 'r')
data = f.read()
print(data)
f.close()
写过 Python 的人都知道,这样写不够安全。如果 f.read() 中间抛异常,f.close() 永远不会执行,文件句柄就泄露了。
于是大家学会了 with 语句:
with open('data.txt', 'r') as f:
data = f.read()
print(data)
离开 with 代码块,文件自动关闭,不管里面有没有报错。
这个 with 语句背后的原理,就是上下文管理器。一个类只要实现了 enter 和 exit 两个魔法方法,就能放进 with 里用。
但问题来了:每次都要写一个完整的类,就为了管理一个资源?太啰嗦了。
contextlib 就是来解决这个痛点的。
二、contextlib 的核心武器:@contextmanager 装饰器
@contextmanager 是 contextlib 模块里最常用、也最好用的工具。它能把一个普通的生成器函数直接变成上下文管理器。
看看怎么用:
from contextlib import contextmanager
@contextmanager
def managed_file(filename):
f = open(filename, 'r')
try:
yield f
finally:
f.close()
使用方式
with managed_file('data.txt') as f:
data = f.read()
print(data)
关键点在这里: yield 前面的代码相当于 enter,yield 后面的代码(放在 finally 里)相当于 exit。不管 with 代码块里发生什么,finally 都会执行,文件一定能关掉。
这个写法比写一个完整的类清爽太多了。几行代码搞定,逻辑一目了然。
三、一个真实的数据库连接场景
假设你在写一个 Web 爬虫,需要把数据存到 SQLite 数据库。每次操作都要打开连接、获取游标、提交事务、关闭连接,写起来非常繁琐:
def save_data(data):
conn = sqlite3.connect('app.db')
cursor = conn.cursor()
cursor.execute('INSERT INTO items VALUES (?)', (data,))
conn.commit()
conn.close()
但这样写有几个隐患:如果 execute 报错,conn.commit() 和 conn.close() 都不会执行,数据库连接就悬在那里了。
用 @contextmanager 包装一下:
@contextmanager
def get_db():
conn = sqlite3.connect('app.db')
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
使用
with get_db() as conn:
cursor = conn.cursor()
cursor.execute('INSERT INTO items VALUES (?)', ('test',))
现在不管代码执行成功还是报错,连接都会正确关闭。成功了自动提交,失败了自动回滚。
一个装饰器,把资源管理的复杂度全部封装掉了。
四、计时器:用上下文做性能监控
上下文管理器不只能管理“打开-关闭”类的资源,任何“进来时做一件事、出去时做另一件事”的场景都能用。
比如你想测一段代码的执行时间:
import time
from contextlib import contextmanager
@contextmanager
def timer(name):
start = time.time()
print(f"{name} 开始...")
yield
elapsed = time.time() - start
print(f"{name} 完成,耗时 {elapsed:.2f} 秒")
使用
with timer("数据清洗"):
# 这里放你要测的代码
data = [i**2 for i in range(1000000)]
print(f"生成了 {len(data)} 条数据")
输出:
数据清洗 开始...
生成了 1000000 条数据
数据清洗 完成,耗时 0.18 秒
不需要写一堆 start = time.time() 和 print(...) 的重复代码。把计时逻辑包进上下文管理器里,用的时候一行 with timer(...) 就搞定了。
这对性能调优特别有用。 你可以快速给多个代码块加上计时,找出瓶颈在哪里。
五、临时切换目录:用完自动恢复
写脚本的时候,经常需要临时切换工作目录去处理文件。处理完得切回来,不然会影响后面的代码。
手动写 os.chdir 很容易忘记切回来:
import os
os.chdir('/tmp')
处理临时文件...
糟糕,忘记切回原来的目录了
os.remove('important.txt') # 删错了!
用 contextlib 包装一下:
@contextmanager
def cd(path):
old_dir = os.getcwd()
os.chdir(path)
try:
yield
finally:
os.chdir(old_dir)
使用
with cd('/tmp'):
# 在 /tmp 目录下操作
with open('temp.txt', 'w') as f:
f.write('临时数据')
# 离开 with 块自动切回原目录
这里已经回到原来的目录了
这个模式可以应用到很多场景:临时修改环境变量、临时重定向标准输出、临时禁用信号处理……核心思路都一样:进去时保存状态,出来时恢复状态。
六、更高级的工具:ExitStack
有时候你需要同时管理多个资源,而且这些资源的数量在运行时才能确定。比如打开一批文件:
files = []
for filename in file_list:
files.append(open(filename, 'r'))
万一中间某个文件打开失败,前面已经打开的文件怎么关?
手动处理会非常麻烦。ExitStack 就是为这种场景设计的:
from contextlib import ExitStack
with ExitStack() as stack:
files = []
for filename in file_list:
f = stack.enter_context(open(filename, 'r'))
files.append(f)
# 所有文件都成功打开,继续处理
for f in files:
print(f.read())
离开 with 块时,所有文件按相反顺序自动关闭
ExitStack 内部维护了一个栈。每次调用 enter_context,它就把这个资源记下来。离开 with 块时,按照后进先出的顺序自动清理所有资源。不管中间哪个步骤出问题,已经成功打开的资源都会被正确关闭。
还有一个常用场景:在旧代码里,有些资源不是上下文管理器,只有 close 方法。ExitStack 也能处理:
with ExitStack() as stack:
conn = stack.callback(lambda: db.close())
# 离开时 db.close() 会被自动调用
callback 方法让你可以注册任意的清理函数,非常灵活。
七、suppress:忽略你不关心的异常
有时候你并不想处理某个异常,只想让它静悄悄地过去。
比如删除一个可能不存在的文件:
try:
os.remove('temp.txt')
except FileNotFoundError:
pass
写 try-except-pass 太啰嗦了。contextlib.suppress 专门解决这个问题:
from contextlib import suppress
with suppress(FileNotFoundError):
os.remove('temp.txt')
可以同时抑制多种异常:
with suppress(FileNotFoundError, PermissionError):
os.remove('temp.txt')
代码干净了很多,意图也很明确:“这个异常出现了也没关系,忽略它。”
八、nullcontext:需要但不需要管理的时候
写函数的时候,有时候需要根据参数决定是否使用上下文管理器。比如调试模式下打开日志文件,生产模式下什么都不做:
def process_data(debug=False):
if debug:
manager = open('debug.log', 'w')
else:
manager = ??? # 这里放什么?
with manager as log:
log.write('处理中...')
nullcontext 就是那个“什么都不做”的上下文管理器:
from contextlib import nullcontext
def process_data(debug=False):
if debug:
manager = open('debug.log', 'w')
else:
manager = nullcontext()
with manager as log:
# 如果 debug=True,log 是文件对象
# 如果 debug=False,log 是 None,但代码仍然可以正常执行
if log is not None:
log.write('处理中...')
# 实际业务逻辑
print("数据处理完成")
这样你就不需要写两套逻辑,一套带 with 一套不带。统一用 with 结构,nullcontext 会乖乖地什么也不做。
九、把多个上下文管理器串起来
Python 3.10 之后,contextlib 提供了一个更简洁的写法。以前你要嵌套多个 with:
with open('input.txt') as infile:
with open('output.txt', 'w') as outfile:
outfile.write(infile.read())
现在可以写成一行:
with (
open('input.txt') as infile,
open('output.txt', 'w') as outfile
):
outfile.write(infile.read())
括号把多个上下文管理器包在一起,Python 会自动按顺序进入、按相反顺序退出。代码层级少了一层,看起来舒服很多。
十、实战:封装一个重试机制
把这些技巧组合起来,能做出很实用的工具。比如一个带重试功能的上下文管理器:
import time
from contextlib import contextmanager
@contextmanager
def retry(max_attempts=3, delay=1):
last_exception = None
for attempt in range(max_attempts):
try:
yield
return # 成功就退出
except Exception as e:
last_exception = e
print(f"第 {attempt + 1} 次尝试失败: {e}")
if attempt < max_attempts - 1:
time.sleep(delay)
raise last_exception
使用
with retry(max_attempts=5, delay=2):
# 这里放可能会临时失败的代码
response = requests.get('https://unstable-api.example.com/data')
response.raise_for_status()
这个上下文管理器会自动重试 5 次,每次失败等 2 秒。如果 5 次都失败,抛出最后一次的异常。
调用方的代码非常干净,不需要写任何重试逻辑。这就是 contextlib 的魅力——把横切关注点(cross-cutting concerns)封装起来,让业务代码保持简洁。
十一、contextlib 的底层原理
用 @contextmanager 装饰一个生成器函数,Python 背后做了这些事:
所以 yield 后面的代码一定要放在 try-finally 里,确保不管 with 块里发生了什么,清理逻辑都能执行。
这个设计非常巧妙。 生成器本来是用来产生序列的,Python 把它复用到上下文管理器的场景,用 yield 切开了“进入”和“退出”两个阶段。
写在最后
回头再看小陈的故事。如果当时他知道 @contextmanager,打开数据库连接的那段代码会写成这样:
@contextmanager
def get_conn():
conn = create_conn()
try:
yield conn
finally:
conn.close()
三个函数调用,一个 yield,一个 finally,搞定。不用写类,不用记 enter 和 exit,代码意图清晰得不能再清晰。
contextlib 这个模块不大,但每一行代码都经过精心设计。 它解决的是一个很具体的问题——让 with 语句的编写变得简单。但因为这个“具体问题”在编程里几乎天天遇到,它的价值就被无限放大了。
下次你再遇到需要“进去时做点事、出来时做点事”的场景,先想想能不能用 @contextmanager 包装一下。写出一个漂亮的上下文管理器,那种“用起来真舒服”的感觉,比写一百行注释都来得实在。
彩蛋: contextlib 还有 AbstractContextManager、AsyncContextDecorator 等高级工具,适合在写框架或库的时候使用。不过对于 90% 的日常开发,@contextmanager 加上 ExitStack、suppress、nullcontext 这四个工具,已经足够应对绝大多数资源管理问题了。