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. 自测
__exit__返回True会发生什么?为什么suppress要判断异常类型?contextmanager装饰的函数里,yield之前的代码对应哪个协议方法?yield写在try外,异常路径上会发生什么?- 生成器被
close()时收到什么异常?清理代码应该放哪? - 列表推导与生成器表达式在"捕获变量"上的差别是什么?
ExitStack的清理顺序是什么?为什么它能解决"with里返回生成器"的问题?