别再手写 try/finally 了:一文讲透 with 语句背后的上下文管理器协议

「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__ 依然执行了!
# 外面捕获到异常: 出错了

三、怎么用

flowchart LR A[调用<br/>enter] --> B[执行with<br/>代码块] B --> C{代码块是<br/>否抛异常} C -->|否| D[调用exit三个<br/>参数都是None] C -->|是| E[调用exit传<br/>入异常信息] E --> F{exit返回<br/>True吗} F -->|是| G[异常被吞掉<br/>程序继续] F -->|否| H[异常继续<br/>向外传播] D --> I[with语句结束]

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/finallyfinally 部分的代码 = __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 绑定的对象,包裹 yieldtry/finally 结构里 finally 部分的代码相当于 __exit__ 逻辑,异常同样会正常流经这个 finally 块。


下一篇预告

Day07 讲迭代器协议------__iter__/__next__ 这两个魔法方法和 for 循环之间到底是什么关系,for x in obj 背后 Python 实际做了哪几步。

相关推荐
进击的程序猿~1 小时前
Go 内存分配与垃圾回收源码深度学习手册
开发语言·后端·golang
李可以量化1 小时前
量化高性能服务框架 Tornado 全面解析(上):异步非阻塞的核心能力与场景落地
大数据·python·量化交易·tornado·qmt·ptrade
geovindu1 小时前
go:Bit Operation Algorithm
开发语言·后端·算法·golang·位运算法
内蒙深海大鲨鱼1 小时前
3.Introduction to PyTorch YouTube Series--Autograd
人工智能·pytorch·python
用户0332126663671 小时前
使用 Python 在 Excel 中添加或删除批注
python·excel
dogstarhuang1 小时前
手把手用 Doubao-Seed-Evolving 写一个网站监控脚本:完整代码与两处踩坑
python·ai编程·掘金技术征文
元界metalite2 小时前
Spring-AOP切面越写越多怎么办-8类场景的一条有序处理器链
后端
有米9792 小时前
Logstash的安装和Elasticsearch的整合
后端
newerp2 小时前
reflect.Value 与动态值操作
后端