完整讲解 · 5 段教学·配 4 道练习题·预计 30 分钟
本页是本章的通读版,可直接读完全部讲解。想动手写代码、跑判分,去 闯关模式。
各位先来看一段代码,猜猜哪里有坑:
f = open('/tmp/diary.txt', 'w')
f.write('今天打卡迟到了三分钟。')
f.close()「这有什么问题?文件打开了、写完了、关掉了,一气呵成。」善于思考的你可能会这么说。
可是,万一 f.write 那一行抛了异常呢?
close 永远不会被执行,文件描述符就这么悬在那儿。一两次没事,可如果是个 Web 服务跑一整天,几千个请求里只要有几十个写文件失败,操作系统的 fd 就会被慢慢耗光,最后报一个看起来八竿子打不着的「Too many open files」。这种 bug,定位起来非常痛苦。
那怎么办?老办法当然是 try/finally:
f = open('/tmp/diary.txt', 'w')
try:
f.write('今天打卡迟到了三分钟。')
finally:
f.close()但这样很啰嗦。Python 早就提供了一个语法糖:
with open('/tmp/diary.txt', 'w') as f:
f.write('今天打卡迟到了三分钟。')一行顶六行,且保证不管中间是否抛异常,文件都会被关掉。
这就是 with 语句——它背后的机制叫上下文管理器(context manager)。
with 后面跟的不是「文件」也不是「连接」,而是一类东西,Python 给它起了个名字,叫上下文管理器。
什么样的对象能算上下文管理器?只要满足两个方法:
__enter__(self):进入 with 块时被调用,返回值赋给 as 后面的变量__exit__(self, exc_type, exc_val, tb):离开 with 块时被调用——无论是正常离开还是异常离开这就是「上下文管理协议」。它的全部规则就这两条,没有第三条。
class Punch:
def __enter__(self):
print('进入打卡区')
return self
def __exit__(self, exc_type, exc_val, tb):
print('离开打卡区')
with Punch() as p:
print('正在工位摸鱼')输出:
进入打卡区
正在工位摸鱼
离开打卡区
with Punch() as p 这一行做了三件事:
Punch() 实例__enter__() 方法__enter__() 的返回值赋给 p然后才执行 with 块里面的代码。等 with 块结束(不管是正常结束还是异常结束),就调用 __exit__()。
这个流程本质上就是把:
p_obj = Punch()
p = p_obj.__enter__()
try:
print('正在工位摸鱼')
finally:
p_obj.__exit__(None, None, None)这一坨样板代码,藏到了 with 这个语法糖背后。
光是 print 几行字太单薄了,我们让上下文管理器做点正经事——比如记录进入和离开的事件:
class PunchLog:
def __init__(self, name):
self.name = name
self.records = []
def __enter__(self):
self.records.append(f'{self.name} 进入工位')
return self
def __exit__(self, exc_type, exc_val, tb):
self.records.append(f'{self.name} 离开工位')
log = PunchLog('两点水')
with log:
print('正在划水……')
for line in log.records:
print(line)输出:
正在划水……
两点水 进入工位
两点水 离开工位
⚠️ 常见坑:如果改成
with log as l:,那l会变成None,因为__enter__没写return self,默认返回None。「我的as x怎么是None?」八成是__enter__忘了return。
如果 with 块里抛了异常,__exit__ 会拿到三个参数:
exc_type:异常类型exc_val:异常实例tb:traceback 对象class CatchAll:
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, tb):
if exc_type is not None:
print(f'捕获到异常:{exc_type.__name__}: {exc_val}')
return True # 返回 True 表示「这个异常我吞掉了」
with CatchAll():
raise ValueError('故意的')
print('with 之后的代码继续执行')输出:
捕获到异常:ValueError: 故意的
with 之后的代码继续执行
__exit__ 返回 True 表示吞掉异常——异常不会再向外传播。返回 False(或 None)表示让异常继续往上抛。
写一个上下文管理器,只为了管几行代码,居然要写一个完整的类 + 两个 dunder 方法,是不是有点重?
Python 标准库 contextlib 提供了一个装饰器 @contextmanager,可以把一个生成器函数变成上下文管理器:
yield 之前的代码 = __enter__yield 出来的值 = __enter__ 的返回值yield 之后的代码 = __exit__把上一节的 PunchLog 用 @contextmanager 重写:
from contextlib import contextmanager
@contextmanager
def punch_log(name):
records = []
records.append(f'{name} 进入工位')
try:
yield records
finally:
records.append(f'{name} 离开工位')
with punch_log('两点水') as records:
print('继续划水……')
for line in records:
print(line)输出:
继续划水……
两点水 进入工位
两点水 离开工位
是不是清爽多了?类变成了一个函数,两个 dunder 方法变成了一个 yield。
注意上面那个 try/finally。为什么需要它?
因为 with 块里面可能抛异常,异常会从 yield 那一行重新「钻」回生成器函数里。如果不写 try/finally,异常会绕过后面的清理代码,资源就泄露了。
这是用
@contextmanager写上下文管理器最重要的一条铁律:清理代码要包在finally里。
如果想吞掉某种特定异常:
from contextlib import contextmanager
@contextmanager
def ignore_zero_division():
try:
yield
except ZeroDivisionError as e:
print(f'吞掉了:{e}')
with ignore_zero_division():
x = 1 / 0
print('这一行不会执行')
print('但是这一行会')输出:
吞掉了:division by zero
但是这一行会
contextlib 不只有 @contextmanager,还有几个开箱即用的小工具,用过几次之后会觉得离不开。
suppress:优雅地忽略某种异常有时候我们就是想忽略某个无关紧要的错误,老办法是:
try:
do_something()
except SomeError:
pass写多了很啰嗦。suppress 让它变一行:
from contextlib import suppress
with suppress(FileNotFoundError):
import os
os.remove('not-exist.txt') # 不存在也不会报错如果文件不存在,FileNotFoundError 会被静默吞掉;如果是别的异常,照常往外抛。
closing:把没实现 with 的对象包成上下文不是所有需要 close 的对象都实现了上下文管理器协议。closing 帮我们补上这一层:
from contextlib import closing
class MyResource:
def close(self):
print('资源已释放')
with closing(MyResource()):
print('使用资源中…')输出:
使用资源中…
资源已释放
closing 的实现非常简单——它就是确保 with 块结束时调用对象的 .close() 方法。
redirect_stdout:临时改写标准输出有时候你想把 print 的内容暂时收集起来——可以用 redirect_stdout:
import io
from contextlib import redirect_stdout
buffer = io.StringIO()
with redirect_stdout(buffer):
print('这一行不会出现在终端')
print('而是被收进了 buffer')
captured = buffer.getvalue()
print('外面打印捕获到的内容:')
print(captured)| 写上下文管理器的三种方式 | 适合什么场景 |
|---|---|
写类 + __enter__/__exit__ | 资源对象有自己的状态、需要好几个属性 |
@contextmanager 装饰生成器 | 临时的、一次性的资源管理 |
contextlib.suppress / closing 等内置 | 标准模式,能用就直接用 |
凡是「有借有还、成对操作」的事情——开/关、连/断、获取/释放——扔给 with 去管准没错。
读完了?动手练一遍才算真会。
去闯关模式练习 →