
Python 上下文管理器:contextlib 与 with 的正确姿势写 Python 的人几乎每天都在用with open(...) as f,但一问「with 到底帮你做了什么」「怎么给自己的类加上 with 支持」「try/finally 和 with 有啥区别」,很多人就说不清了。这篇把上下文管理器从原理到实战讲透:什么时候该用、怎么用contextlib少写一半代码、以及那些异常处理的坑。从一段「看着没错」的代码说起假设你要操作一个数据库连接,朴素写法是这样:connget_connection()cursorconn.cursor()cursor.execute(INSERT INTO logs VALUES (?),(msg,))conn.commit()conn.close()# 问题:上面任何一行抛异常,这里就执行不到,连接泄漏只要execute或commit抛异常,conn.close()永远不会执行,连接就泄漏了。数据库连接池被占满后,整个服务就卡死。有人会说:那我用 try/finally 兜住:connget_connection()try:cursorconn.cursor()cursor.execute(INSERT INTO logs VALUES (?),(msg,))conn.commit()finally:conn.close()# 无论是否异常都会执行这样确实对了。但如果你每次操作连接都要写这一坨 try/finally,重复且容易漏。上下文管理器就是把这套「进入时准备、退出时收尾」的模式封装起来,让调用方一行with搞定。with 背后的协议:enter和exitwith能作用于任何实现了__enter__和__exit__两个方法的对象。我们给数据库连接包一层:classManagedConnection:def__enter__(self):self.connget_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一分为二:fromcontextlibimportcontextmanagercontextmanagerdefmanaged_connection():connget_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 才能保证清理代码执行。这是这种写法最容易漏的地方。实战:一个计时上下文管理器工作中常需要给某段代码计时。上下文管理器写起来特别干净:importtimefromcontextlibimportcontextmanagercontextmanagerdeftimer(label):starttime.perf_counter()# perf_counter 比 time() 精度高,适合测耗时try:yieldfinally:costtime.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:datapage.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会吞掉异常,别无脑这么写,默认让异常往上抛。简单场景用contextmanageryield,记住「yield 前是进入、yield 后是退出、必须 try/finally 包住 yield」。contextlib三件套:suppress(精确忽略异常)、closing(给裸 close 对象补 with)、ExitStack(管理动态数量的资源)。一句话记忆点:凡是「用完必须收尾」的资源——文件、连接、锁、临时状态——都该交给上下文管理器,而不是靠自己记得写 close。