「Python 进阶之路」系列 Day06
写在前面
打开一个文件忘了关、拿到一把锁忘了释放、建立的数据库连接忘了断开------这些资源泄漏问题,本质上都是同一类疏忽:清理逻辑写在了一个可能被跳过的地方 。手写 try/finally 当然能解决,但前提是你每次都记得写、而且写对了位置。
with 语句能把这件事从"程序员的自觉"变成"语言层面的保证"。今天这篇把 with 背后的 __enter__/__exit__ 协议讲透,顺便讲讲 contextlib.contextmanager 这种更常用的简化写法。
一、是什么:with 语句与上下文管理器协议
上下文管理器(Context Manager) :任何实现了 __enter__ 和 __exit__ 这两个方法的对象,都可以配合 with 语句使用。with 语句会在进入代码块前自动调用 __enter__,离开代码块时(不管是正常结束还是抛了异常)自动调用 __exit__。
python
class Resource:
def __init__(self, name):
self.name = name
def __enter__(self):
print(f"打开资源 {self.name}")
return self # with...as 绑定的就是这里的返回值
def __exit__(self, exc_type, exc_value, tb):
print(f"关闭资源 {self.name}")
return False # 不吞异常,见下文
with Resource("db") as r:
print("使用资源:", r.name)
# 打开资源 db
# 使用资源: db
# 关闭资源 db
二、为什么:解决资源清理容易被遗漏的问题
在 with 语句出现之前,资源清理只能靠手写 try/finally:
python
r = acquire_resource()
try:
use(r)
finally:
r.release() # 必须写在 finally 里,否则 use(r) 抛异常时资源永远不会被释放
这种写法完全正确,但依赖程序员自己记得写 finally ,一旦漏写,代码块中途抛异常就会导致资源泄漏。with 语句把"无论是否异常都要执行清理"这个逻辑固化成语言层面的协议------只要对象实现了 __enter__/__exit__,清理动作就绝对不会被遗漏,不用每次手写 try/finally,也不用担心哪次疏忽漏写。
用代码验证清理逻辑必然执行,哪怕代码块内抛了异常:
python
try:
with Resource("file") as r:
print("使用资源:", r.name)
raise ValueError("出错了")
except ValueError as e:
print("外面捕获到异常:", e)
# 打开资源 file
# 使用资源: file
# 关闭资源 file ← __exit__ 依然执行了!
# 外面捕获到异常: 出错了
三、怎么用
1. exit 的三个参数与异常处理
__exit__(self, exc_type, exc_value, tb) 的三个参数,携带的正是代码块里发生的异常信息:代码块正常执行完没有异常时,三个参数全是 None;代码块抛了异常时,exc_type 是异常类型、exc_value 是异常实例、tb 是 traceback 对象。
__exit__ 的返回值决定异常要不要继续往外传播 :返回真值会把异常"吞掉",代码继续往下执行,就像什么都没发生过;返回假值(或不写返回值,默认 None)异常会正常继续向外传播。
python
class Suppressor:
def __enter__(self):
return self
def __exit__(self, exc_type, exc_value, tb):
if exc_type is not None:
print(f"吞掉了异常: {exc_type.__name__}: {exc_value}")
return True # 吞掉异常
with Suppressor():
raise RuntimeError("这个异常会被吞掉")
print("程序正常继续运行,没有崩溃")
这个吞异常的能力要谨慎使用------__exit__ 返回 True 会让代码块里任何异常都悄无声息地消失,包括你完全没预料到的 bug,容易把真正的问题掩盖掉。一般只在明确知道要忽略哪类异常时才这样做,不要无差别吞掉所有异常。
2. 常见坑:enter 里抛异常,exit 不会被调用
python
class BadEnter:
def __enter__(self):
raise RuntimeError("enter 阶段就失败了")
def __exit__(self, exc_type, exc_value, tb):
print("这行不会被打印!")
return False
with BadEnter():
print("这里也不会执行")
# RuntimeError: enter 阶段就失败了 ← __exit__ 根本没被调用过
原因 :with 语句先调用 __enter__,只有 __enter__ 成功返回,才算真正"进入"了这个上下文,后续代码块和 __exit__ 才会被安排执行。如果连 __enter__ 本身都失败了,说明这个上下文压根没能建立起来,自然也没有"退出"这一说。这意味着如果 __enter__ 里包含真正的资源获取逻辑(打开文件、建立连接),一定要确保这部分逻辑本身足够健壮,因为一旦它失败,不会有任何自动清理机会。
3. contextlib.contextmanager:用 yield 简化写法
写一个完整的类实现 __enter__/__exit__ 有点繁琐,contextlib.contextmanager 装饰器可以用一个生成器函数搞定同样的效果:
python
from contextlib import contextmanager
@contextmanager
def resource_cm(name):
print(f"打开资源 {name}")
try:
yield name # yield 之前的代码相当于 __enter__,yield 的值就是 as 绑定的对象
finally:
print(f"关闭资源 {name}") # finally 块相当于 __exit__,异常也会走到这里
with resource_cm("cache") as name:
print("使用资源:", name)
try:
with resource_cm("cache2") as name:
raise ValueError("模拟异常")
except ValueError as e:
print("异常照样能正常传出来:", e)
对应关系很直观:yield 之前的代码 = __enter__;yield 出去的值 = with...as 绑定的对象;try/finally 里 finally 部分的代码 = __exit__。这种写法比手写一个类简洁得多,是实际项目里更常见的做法。
4. 多个上下文管理器与 contextlib 实用工具
一个 with 语句里可以同时管理多个上下文管理器,退出时按声明的相反顺序依次调用 __exit__(后进先出):
python
with Resource("a") as ra, Resource("b") as rb:
print("同时使用:", ra.name, rb.name)
# 打开资源 a
# 打开资源 b
# 同时使用: a b
# 关闭资源 b ← 先关后声明的 b
# 关闭资源 a ← 再关先声明的 a
contextlib 里还有现成的实用工具,比如只想忽略某一种明确的异常:
python
from contextlib import suppress
with suppress(ZeroDivisionError): # 明确声明只忽略这一种异常,比无脑吞所有异常安全得多
1 / 0
print("除零异常被忽略了,程序继续")
内置对象也遵循这套协议------文件对象天生就实现了 __enter__/__exit__,这也是为什么打开文件建议永远用 with open(...) as f: 而不是手动 open()/close()。
四、面试追问
Q1:什么是上下文管理器?
任何实现了 __enter__ 和 __exit__ 方法的对象,都可以配合 with 语句使用。with 语句在进入代码块前自动调用 __enter__,离开代码块时(无论正常结束还是抛出异常)自动调用 __exit__,从而保证资源获取和释放这一对操作总是成对出现。
Q2:with 语句相比手写 try/finally 解决了什么问题?
try/finally 完全能实现同样的清理效果,但依赖程序员每次都记得写、并且写在正确的位置,一旦遗漏就会导致资源泄漏。with 语句把"无论是否异常都执行清理"这个逻辑固化成语言协议,只要对象实现了上下文管理器接口,清理动作就不会被意外遗漏。
Q3:__exit__ 的三个参数是什么,返回值有什么作用?
三个参数依次是异常类型 exc_type、异常实例 exc_value、traceback 对象 tb;代码块正常执行完时三者都是 None。__exit__ 的返回值决定异常要不要继续往外传播:返回真值会把异常吞掉,代码继续正常执行;返回假值(包括不写返回值,默认 None)异常会正常向外传播,不受影响。
Q4:__enter__ 里抛异常会发生什么?
__exit__ 根本不会被调用。因为 with 语句需要 __enter__ 先成功返回,才算真正进入这个上下文;如果 __enter__ 本身就失败了,说明上下文压根没有建立起来,异常会直接从 with 语句往外抛出,不存在"退出"的概念,也就没有清理的机会。
Q5:contextlib.contextmanager 是怎么简化上下文管理器写法的?
用一个装饰了 @contextmanager 的生成器函数代替手写一个完整的类:yield 语句之前的代码相当于 __enter__ 逻辑,yield 出去的值相当于 with...as 绑定的对象,包裹 yield 的 try/finally 结构里 finally 部分的代码相当于 __exit__ 逻辑,异常同样会正常流经这个 finally 块。
下一篇预告
Day07 讲迭代器协议------__iter__/__next__ 这两个魔法方法和 for 循环之间到底是什么关系,for x in obj 背后 Python 实际做了哪几步。