Python contextmanager:资源清理完成了,异常为什么还应该传出去
把资源状态和业务结果分开验收
导入报表时,第二行金额格式错误,读取流已经关闭,任务是否可以显示成功?当然不能。资源是否释放与数据是否处理完成,是两项独立结果。把关闭动作封装成上下文管理器后,最容易漏掉的是后半项:为了记录错误写了捕获,却忘记继续抛出,外层便以为这次导入正常结束。
Python 官方文档说明,contextmanager 装饰的生成器必须恰好产出一次;with 主体在这个暂停点运行。主体抛出的未处理异常,会在生成器的 yield 位置重新出现。因此,围住 yield 的 try 可以观察业务失败,finally 负责释放资源,而只为记录错误设置的 except 仍应使用 raise 继续传播。
用三条执行路径检查同一份约定
下面只使用标准库,保存为 Python 文件即可运行。StringIO 提供可关闭的内存文本流,避免例子依赖磁盘权限。第一轮处理两行整数,第二轮故意加入坏数据;第三轮运行一个错误示范,让异常被吞掉。事件列表既记录关闭动作,也让外层是否收到异常变得可以核对。
AI生成概念示意图,非真实界面
from contextlib import contextmanager
from io import StringIO
@contextmanager
def report_text(text, events):
stream = StringIO(text)
events.append('open')
try:
yield stream
except ValueError:
events.append('invalid')
raise
finally:
stream.close()
events.append('close')
events = []
with report_text('12\n18\n', events) as stream:
total = sum(int(line) for line in stream)
assert total == 30 and stream.closed
assert events == ['open', 'close']
print('success:', total, events)
events = []
try:
with report_text('12\nbad\n', events) as stream:
total = sum(int(line) for line in stream)
except ValueError:
events.append('caller-caught')
else:
raise AssertionError('the failure was hidden')
assert stream.closed
assert events == ['open', 'invalid', 'close', 'caller-caught']
print('failure:', events)
@contextmanager
def broken_report(text, events):
stream = StringIO(text)
try:
yield stream
except ValueError:
events.append('swallowed')
finally:
stream.close()
events = []
with broken_report('bad\n', events) as stream:
int(stream.readline())
events.append('caller-continued')
assert stream.closed
assert events == ['swallowed', 'caller-continued']
print('broken:', events)成功路径输出总数三十,事件只有打开和关闭。失败路径的顺序应为打开、发现错误、关闭、外层捕获。这个顺序说明管理器没有靠调用者补做清理,也没有把失败改写成成功。错误示范同样关闭了流,但调用者直接执行后续语句;仅检查 closed 会让这个缺陷漏过验收。
清理代码不能顺手改写控制流
需要释放资源时,应把释放放在 finally 中,而不是仅写在 yield 后面:异常从暂停点进入时,普通的后续语句可能被跳过。资源取得后若还有初始化步骤,也应尽早进入受保护区,避免初始化失败时留下半成品。取得资源之前就失败,则要区分“尚未取得”与“已取得但未交给主体”。
语言参考还规定,finally 自己抛出新异常时,原异常成为新异常的上下文;在 finally 中执行 return 则可能丢弃原异常。因此不要用返回成功值来“保证收尾”。若关闭连接本身失败,要明确记录业务错误和清理错误,保留异常链,让上层知道哪一步需要重试,而不是用一条笼统日志替代失败状态。
这里的 ValueError 是导入格式错误的演示,不代表所有管理器都该捕获它。只需要清理时,try 与 finally 已经足够;需要分类记录时再增加对应捕获。若某类异常确实允许忽略,应把这个决定写进调用约定,并让测试直接证明忽略之后的数据仍可使用。
验收记录可以分别写“资源已释放”和“导入失败”,不要压成一个成功标记。尤其当调用者负责报警或决定是否重试时,异常传播本身就是这份接口提供的必要结果。
人工复核三个容易遗漏的边界
把坏数据放在第一行与最后一行各跑一次,确认错误位置不影响关闭,也不让部分结果被当成完整结果。
把主体改成主动抛出其他异常,检查 finally 仍然执行;不要把“记录了格式错误”等同于“覆盖了所有失败”。
每次进入都重新调用工厂函数,不要把同一个生成器管理器实例反复使用;清理完成后也不要继续读取已关闭的流。


