3. 上下文管理器:with语句的原理与使用,自动管理资源
说到文件读写,有个话题绕不开——上下文管理器。说白了,就是 Python 里那个 with 语句。我刚开始学 Python 时,觉得它就是个「打开文件自动关」的语法糖。后来踩过坑才明白,这玩意儿背后的设计思想,远比我想象的深刻。
3.1 为什么需要上下文管理器?
先问一个问题:你写文件操作时,有没有忘记调用 close()?
我承认,我犯过这个错。而且不止一次。
看个反面教材:
f = open('data.txt', 'w')
f.write('Hello, World!')
# 忘了 close() —— 文件句柄泄漏了
这代码跑一次没事,跑多了呢?文件句柄会被耗尽。尤其在生产环境,一个进程能打开的文件数量是有限的。我记得有一次线上服务莫名其妙报 Too many open files,查了半天,就是某个同事在循环里打开文件没关。
你可能会说:「加个 try...finally 不就行了?」
没错,可以这样:
f = open('data.txt', 'w')
try:
f.write('Hello, World!')
finally:
f.close()
但每次都要写 try...finally,你不觉得烦吗?
Python 的设计者显然也觉得烦。于是就有了 with 语句。
3.2 with 语句的基本用法
用 with 改写上面的代码:
with open('data.txt', 'w') as f:
f.write('Hello, World!')
# 退出 with 块后,文件自动关闭
简洁、清晰、安全。三行变两行,还不用操心关闭的事。
我个人习惯是:只要涉及资源获取和释放,一律用 with。文件、锁、数据库连接、网络套接字……统统交给它。
3.3 原理:上下文管理协议
with 语句能这么智能,靠的是两个魔法方法:
__enter__(self)—— 进入with块时调用,返回值赋给as后面的变量__exit__(self, exc_type, exc_val, exc_tb)—— 退出with块时调用,负责清理资源
执行流程是这样的:
- 调用对象的
__enter__方法 - 把返回值赋给
as后面的变量 - 执行
with块内的代码 - 无论是否发生异常,都调用
__exit__方法 - 如果
__exit__返回True,异常会被吞掉(不推荐)
我画了一张流程图,帮你理清这个逻辑:
3.4 自定义上下文管理器
你可能会问:「我能不能自己写一个支持 with 的类?」
当然可以。实现 __enter__ 和 __exit__ 就行。
举个例子,模拟一个数据库连接:
class DatabaseConnection:
def __init__(self, host):
self.host = host
self.connected = False
def __enter__(self):
print(f'连接数据库 {self.host}...')
self.connected = True
return self # 返回自身,供 with 块使用
def __exit__(self, exc_type, exc_val, exc_tb):
print('关闭数据库连接...')
self.connected = False
# 如果返回 False,异常会继续向上传播
# 如果返回 True,异常会被抑制
return False
def query(self, sql):
if not self.connected:
raise RuntimeError('数据库未连接')
print(f'执行查询: {sql}')
# 使用
with DatabaseConnection('localhost') as db:
db.query('SELECT * FROM users')
# 自动关闭连接
输出:
连接数据库 localhost...
执行查询: SELECT * FROM users
关闭数据库连接...
嗯,这里要注意:__exit__ 的返回值很关键。返回 False 表示异常继续传播,返回 True 表示异常已被处理。我建议除非你有特殊需求,否则一律返回 False。吞异常是个坏习惯。
3.5 使用 contextlib 简化
每次写一个类来实现上下文管理器,有点重。Python 标准库提供了 contextlib,可以更轻量地实现。
我最常用的是 @contextmanager 装饰器:
from contextlib import contextmanager
@contextmanager
def database_connection(host):
print(f'连接数据库 {host}...')
# 在 yield 之前是 __enter__
conn = {'host': host, 'connected': True}
try:
yield conn # 把 conn 返回给 as 变量
finally:
# 在 yield 之后是 __exit__
print('关闭数据库连接...')
conn['connected'] = False
# 使用
with database_connection('localhost') as db:
print(f'已连接: {db["connected"]}')
输出:
连接数据库 localhost...
已连接: True
关闭数据库连接...
你看,用 yield 把代码分成两段:前半段是进入时的操作,后半段是退出时的清理。简洁多了。
contextlib.redirect_stdout() 或自己写一个简单的上下文管理器。我在调试时经常用这个来捕获打印输出。
3.6 多资源管理
有时候需要同时管理多个资源。比如同时打开两个文件,把一个的内容复制到另一个:
# 错误写法:嵌套太深
with open('source.txt', 'r') as src:
with open('dest.txt', 'w') as dst:
dst.write(src.read())
# 正确写法:用逗号分隔
with open('source.txt', 'r') as src, open('dest.txt', 'w') as dst:
dst.write(src.read())
Python 3.1 之后支持这种写法。多个资源用逗号隔开,with 会依次调用它们的 __enter__,退出时按相反顺序调用 __exit__。
我曾经在项目中用这个特性同时管理文件锁和文件句柄:
import threading
file_lock = threading.Lock()
with file_lock, open('shared.txt', 'a') as f:
f.write('线程安全的写入\n')
锁和文件都自动管理,省心不少。
3.7 避坑指南
- 不要吞异常:
__exit__返回True会抑制异常,这可能导致 bug 难以排查。除非你明确知道自己在做什么,否则返回False。 - 注意资源顺序:多个资源时,退出顺序与进入顺序相反。如果资源之间有依赖关系,要小心。
- 不要滥用:
with适用于资源管理,不要用它来替代普通的代码块。我见过有人用with来包裹一段逻辑,只是为了「看起来优雅」——没必要。
3.8 总结一下
| 概念 | 说明 |
|---|---|
with 语句 |
自动管理资源的语法糖,确保退出时清理 |
__enter__ |
进入时调用,返回值赋给 as 变量 |
__exit__ |
退出时调用,负责清理资源 |
@contextmanager |
用生成器快速创建上下文管理器 |
| 多资源管理 | 用逗号分隔多个资源,按序进入、逆序退出 |
说白了,with 语句就是 Python 帮你写好了 try...finally。你只管用,它管收尾。这种「约定优于配置」的设计,正是 Python 优雅哲学的体现。
下次写文件操作时,记得用 with。你的代码会感谢你的。