2165 字
约 7 分钟
20
S10-X13 · 上下文管理器与生成器

with 和 yield 是 Python 里最容易被"会用"掩盖"没懂"的两件事。前者靠两个魔法方法,后者靠一个协议——两者在 contextlib.contextmanager 里合并,就成了"把 yield 当分隔符写资源管理"的写法。误用它不会立刻报错,只会在异常路径上泄漏一个文件句柄。

这篇按协议 → 生成器语义 → 误用路径 → 异步与动态资源走一遍。


1. with 的两条协议

with expr as x: 展开成三步:

mgr = expr
x = type(mgr).__enter__(mgr)   # 或 mgr.__enter__()
try:
    body
finally:
    type(mgr).__exit__(mgr, *sys.exc_info())
方法 何时调用 返回什么
__enter__ 进入 with 块 赋给 as 的目标,可返回 None
__exit__(exc_type, exc_val, tb) 离开 with 块,含异常路径 真值 → 吞掉异常;假值 → 继续抛出

__exit__ 的三个参数在正常退出时全是 None。要"只处理某类异常、其余放行",返回 True 前必须判断类型:

class Suppress:
    def __init__(self, *types): self.types = types
    def __enter__(self): return self
    def __exit__(self, et, ev, tb):
        return et is not None and issubclass(et, self.types)

返回 True 而不判断类型,等于把 KeyboardInterrupt、SystemExit 一起吞掉——这是标准库 contextlib.suppress 用 issubclass 的理由。


2. contextmanager:把 yield 当分隔符

from contextlib import contextmanager

@contextmanager
def transaction(conn):
    conn.begin()
    try:
        yield conn            # 之前 = __enter__,之后 = __exit__
        conn.commit()
    except Exception:
        conn.rollback()
        raise

装饰器把生成器函数包装成一个 _GeneratorContextManager:__enter__ 里 next(gen) 跑到 yield,把 yield 的值交给 as 目标;__exit__ 里 gen.send(None) 继续,或用 gen.throw(...) 把异常抛进生成器。

关键点:yield 必须放在 try 里,否则异常发生时 finally 不执行:

@contextmanager
def bad(path):
    f = open(path)       # 若 open 之后、进入 try 之前抛错,f 泄漏
    yield f
    f.close()            # 异常路径永远到不了这行

@contextmanager
def good(path):
    f = open(path)
    try:
        yield f
    finally:
        f.close()        # 正常/异常/GeneratorExit 都会走

good 版本还有一个隐藏好处:close() 幂等,重复调用不报错,而 f.close() 写在 yield 之后只在正常路径执行一次。


3. 生成器:惰性求值与"不再是普通函数"

函数体里出现 yield,调用它就不再执行函数体,而是返回一个 generator 对象:

def gen():
    print("start")
    yield 1
    yield 2

g = gen()        # 什么都不打印
next(g)          # 打印 start,返回 1
性质 含义
惰性 不迭代就不执行,副作用延后
一次性 迭代完即耗尽,再取只得 StopIteration
状态保留 局部变量与执行位置挂在帧上,直到耗尽或关闭
无法回退 没有 seek,要重放就新建一个

yield from sub 把迭代委托给子生成器,同时透传 send/throw/close,并把子生成器的 return 值作为 yield from 表达式的值:

def chain(*iterables):
    for it in iterables:
        yield from it          # 等价于 for x in it: yield x,但保留 send/throw

手写 for x in it: yield x 不转发 send,在协程式生成器里行为不同。


4. 关闭与 GeneratorExit

gen.close() 在挂起点抛 GeneratorExit。生成器必须响应它:要么退出,要么抛 RuntimeError。

def reader(path):
    f = open(path)
    try:
        while True:
            chunk = f.read(1024)
            if not chunk:
                return
            yield chunk
    except GeneratorExit:
        raise                       # 允许重新抛出
    finally:
        f.close()                   # 清理放这里,别放在 except 里

三条规则:清理必须放 finally(except GeneratorExit 里 yield 会触发 RuntimeError: generator ignored GeneratorExit);不要在 finally 里 yield;close() 之后生成器状态为"已关闭",再 next() 抛 StopIteration。

垃圾回收时生成器也会被 close,但时机不确定——依赖 GC 关文件就是依赖"某次 GC 恰好及时"。


5. 生成器表达式与列表推导的内存差异

形式 求值 内存 可重复遍历
[f(x) for x in it] 立即,全部算完 与元素数成正比 是
(f(x) for x in it) 惰性,逐项算 常数级 否
sum(f(x) for x in it) 逐项消费 常数级 不需要

区别不只是内存:生成器表达式捕获外层变量的引用而不是值,循环里创建的生成器在循环结束后才求值,会读到循环变量的最终值。列表推导在 Python 3 里有独立作用域,没有这个问题。

需要多次遍历、需要 len()、需要索引——用列表。只跑一遍的管道(读文件、求和、喂给 join)——用生成器。


6. 误用清单

写法 问题 正确做法
yield 在 try 外 异常时清理不执行 try/finally 包住 yield
with 块里 return gen() 资源在生成器被消费前已释放 在 with 内消费完,或用 ExitStack
把生成器存进属性供多次遍历 第二次为空 每次新建,或缓存成 list
except GeneratorExit 里 yield RuntimeError 清理放 finally
生成器里改外部可变对象 副作用时机不可预期 显式收集后统一处理

第四条最隐蔽:close() 触发 GeneratorExit,若在 except 分支里 yield,CPython 直接抛 RuntimeError: generator ignored GeneratorExit,而这个异常常被上层 except Exception 吞掉。


7. asynccontextmanager

异步版本协议是 __aenter__/__aexit__(返回 awaitable):

from contextlib import asynccontextmanager

@asynccontextmanager
async def session_scope():
    s = await create_session()
    try:
        yield s
    finally:
        await s.close()

规则与同步版一致,多两条:yield 前后可以有 await;__aexit__ 必须 await。混用会报 AttributeError: __enter__——async with 只认 __aenter__,同步 with 只认 __enter__,两者不通用。


8. ExitStack:动态数量的资源

from contextlib import ExitStack

with ExitStack() as stack:
    files = [stack.enter_context(open(p)) for p in paths]
    conn = stack.enter_context(connect(dsn))
    # 退出时按**后进先出**顺序关闭

ExitStack 解决"资源数量运行时才知道"的问题,也解决"在 with 块里返回生成器"的问题——把清理注册给 stack,退出顺序由 stack 保证(与 with 的嵌套语义一致:后进先出)。

stack.callback(fn, *args) 注册任意清理函数,stack.push(ExitStack()) 可做条件性清理组。异常传播规则与 __exit__ 相同:callback 抛错会替换原异常。


9. send / throw:协程的雏形

def accumulator():
    total = 0
    while True:
        x = yield total        # yield 既产出又可接收
        if x is None:
            return total
        total += x

acc = accumulator()
next(acc)          # 预热到第一个 yield,返回 0
acc.send(5)        # 返回 5
acc.send(3)        # 返回 8

send(value) 把值作为 yield 表达式的值送回去;throw(exc) 在挂起点抛异常。这两个方法就是 asyncio 协程的底层形态——await 在字节码层面近似"把 future 交给事件循环,恢复时 send 结果回来"。

必须先用 next() 或 send(None) 预热,否则 TypeError: can't send non-None value to a just-started generator。


10. 排错表

现象 原因 处理
文件句柄数持续上涨 yield 不在 try 内,异常路径跳过 close 改 try/finally
第二次遍历得到空列表 生成器已耗尽 重建或转 list
RuntimeError: generator ignored GeneratorExit 清理分支里有 yield 移除 yield,清理放 finally
TypeError: can't send non-None value 未预热 先 next(gen)
AttributeError: __enter__ async with 与 with 混用 协议要配对
退出顺序与预期相反 多个 with 嵌套是后进先出 用 ExitStack 显式控制

11. 边界

需求 生成器/上下文管理器 更合适
一次性数据管道 是 —
需要随机访问、多次遍历 否 list/deque
资源释放 是(with) —
需要显式状态机 勉强 类 + 状态字段
真并发 否 async/线程

一条分界:生成器表达"按需产生",上下文管理器表达"成对发生"。把两者混在同一个函数里时,先问一句"谁负责保证配对"——如果答案是"调用方记得消费完",那就是设计缺陷。


12. 自测

  1. __exit__ 返回 True 会发生什么?为什么 suppress 要判断异常类型?
  2. contextmanager 装饰的函数里,yield 之前的代码对应哪个协议方法?
  3. yield 写在 try 外,异常路径上会发生什么?
  4. 生成器被 close() 时收到什么异常?清理代码应该放哪?
  5. 列表推导与生成器表达式在"捕获变量"上的差别是什么?
  6. ExitStack 的清理顺序是什么?为什么它能解决"with 里返回生成器"的问题?
S10-X13 · 上下文管理器与生成器
https://www.clxhxhhr.top/posts/5960/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。