Python 上下文管理器:contextlib 与 with 的正确姿势
Python 上下文管理器:contextlib 与 with 的正确姿势
写 Python 的人几乎每天都在用with open(...) as f,但一问「with 到底帮你做了什么」「怎么给自己的类加上 with 支持」「try/finally 和 with 有啥区别」,很多人就说不清了。这篇把上下文管理器从原理到实战讲透:什么时候该用、怎么用contextlib少写一半代码、以及那些异常处理的坑。
从一段「看着没错」的代码说起
假设你要操作一个数据库连接,朴素写法是这样:
conn=get_connection()cursor=conn.cursor()cursor.execute("INSERT INTO logs VALUES (?)",(msg,))conn.commit()conn.close()# 问题:上面任何一行抛异常,这里就执行不到,连接泄漏只要execute或commit抛异常,conn.close()永远不会执行,连接就泄漏了。数据库连接池被占满后,整个服务就卡死。
有人会说:那我用 try/finally 兜住:
conn=get_connection()try:cursor=conn.cursor()cursor.execute("INSERT INTO logs VALUES (?)",(msg,))conn.commit()finally:conn.close()# 无论是否异常都会执行这样确实对了。但如果你每次操作连接都要写这一坨 try/finally,重复且容易漏。上下文管理器就是把这套「进入时准备、退出时收尾」的模式封装起来,让调用方一行with搞定。
with 背后的协议:enter和exit
with能作用于任何实现了__enter__和__exit__两个方法的对象。我们给数据库连接包一层:
classManagedConnection:def__enter__(self):self.conn=get_connection()returnself.conn# 这个返回值就是 as 后面拿到的东西def__exit__(self,exc_type,exc_val,exc_tb):# 无论正常结束还是抛异常,这里一定会被调用self.conn.close()# 返回 False(或不返回)表示不吞掉异常,让它继续往上抛returnFalse用起来:
withManagedConnection()asconn:conn.cursor().execute("INSERT INTO logs VALUES (?)",(msg,))conn.commit()# 出了 with 块,conn 已经自动 close,无论中间是否抛异常__exit__的三个参数是关键:
- 如果 with 块内没有异常,三个参数都是
None。 - 如果抛了异常,它们分别是异常类型、异常实例、traceback。
__exit__的返回值决定异常是否被吞掉:返回True表示「我处理了,异常到此为止」;返回False或None表示「我只做清理,异常继续往上抛」。
⚠️ 一个高频坑:不要在__exit__里无脑return True。那样会把 with 块内的所有异常都吞掉,调用方以为一切正常,实际上数据根本没写进去。除非你明确要「屏蔽某类异常」,否则让它默认往上抛。
用 contextlib.contextmanager 少写一半代码
为一个简单场景写整个类(__enter__/__exit__)太重。contextlib提供了装饰器写法,用yield一分为二:
fromcontextlibimportcontextmanager@contextmanagerdefmanaged_connection():conn=get_connection()try:yieldconn# yield 之前是 __enter__,yield 出去的值就是 as 的目标finally:conn.close()# yield 之后是 __exit__,finally 保证异常时也执行用法完全一样:
withmanaged_connection()asconn:conn.cursor().execute("...")conn.commit()这里的心智模型很清晰:
yield之前的代码 = 进入时执行(相当于__enter__)。yield出去的值=as绑定的对象。yield之后的代码 = 退出时执行(相当于__exit__)。
必须用 try/finally 包住 yield:如果 with 块内抛异常,异常会在yield这一行被重新抛出,只有 finally 才能保证清理代码执行。这是这种写法最容易漏的地方。
实战:一个计时上下文管理器
工作中常需要给某段代码计时。上下文管理器写起来特别干净:
importtimefromcontextlibimportcontextmanager@contextmanagerdeftimer(label):start=time.perf_counter()# perf_counter 比 time() 精度高,适合测耗时try:yieldfinally:cost=time.perf_counter()-startprint(f"[{label}] 耗时{cost*1000:.1f}ms")# 使用withtimer("批量导入"):do_heavy_import()# 输出: [批量导入] 耗时 1234.5ms注意这里yield后面没有值,因为计时不需要给调用方返回什么,with timer("x"):后面不用写as。
contextlib 的几个实用工具
1.suppress:优雅地忽略特定异常
fromcontextlibimportsuppress# 想删一个可能不存在的文件,不想为 FileNotFoundError 写 try/exceptwithsuppress(FileNotFoundError):os.remove("tmp.lock")# 文件不存在就静默跳过,存在就删掉;其他异常照常抛比try: ... except FileNotFoundError: pass更短更直观。但别滥用,suppress范围要精确到具体异常类型,不要suppress(Exception)把什么都吞了。
2.closing:给只有 close() 没有 with 支持的对象补上
fromcontextlibimportclosingfromurllib.requestimporturlopen# urlopen 返回的对象有 close() 但老版本不支持 with,用 closing 包一下withclosing(urlopen("http://example.com"))aspage:data=page.read()# 退出时自动调用 page.close()3.ExitStack:管理数量不定的上下文
当你要同时打开一批文件(数量运行时才知道),没法写死 N 个嵌套 with,用ExitStack:
fromcontextlibimportExitStackdefmerge_files(paths,out):withExitStack()asstack:files=[stack.enter_context(open(p))forpinpaths]# 所有文件都被登记到 stack,退出时统一按逆序关闭withopen(out,"w")asfout:forfinfiles:fout.write(f.read())# 无论中间哪个文件出错,已打开的都会被正确关闭ExitStack帮你把「动态数量的资源」的清理管起来,避免手写一堆嵌套 with 或者漏关。
小结
with的本质是__enter__/__exit__协议:进入时准备资源、退出时保证清理,即使中途抛异常也一定收尾。__exit__返回True会吞掉异常,别无脑这么写,默认让异常往上抛。- 简单场景用
@contextmanager+yield,记住「yield 前是进入、yield 后是退出、必须 try/finally 包住 yield」。 contextlib三件套:suppress(精确忽略异常)、closing(给裸 close 对象补 with)、ExitStack(管理动态数量的资源)。
一句话记忆点:凡是「用完必须收尾」的资源——文件、连接、锁、临时状态——都该交给上下文管理器,而不是靠自己记得写 close。
