本节目标:掌握
send()/throw()/close()让生成器「双向通信」的用法,理解yield from的委托语义,并能用生成器搭出一条真正的惰性数据管道。
适用版本:Python 3.12+(实测 3.14.6)
8.2 生成器进阶:send / yield from 与惰性管道
上一节我们把生成器当成「惰性吐值的迭代器」。但 yield 其实是一个表达式——它既能向外送出一个值,也能接收外部送进来的值。这个双向能力,让生成器成了协程的雏形。
send():把值送进生成器
yield 作为表达式的值,来自调用方的 generator.send(x):
def echo():
print(" ready")
while True:
received = yield # 暂停在这里,等 send 送值进来
print(" got:", received)
g = echo()
next(g) # 必须先「预热」,推进到第一个 yield
g.send("hello") # got: hello
g.send("world") # got: world
输出是:
ready
got: hello
got: world
有两个细节必须记住:
- 第一次必须用
next(g)或g.send(None)预热。生成器还没跑到yield时没有「暂停点」可接收值,直接send非None会抛TypeError。 next(g)等价于g.send(None)。
生成器是协程的雏形
把生成器当状态机用,就能看出「协程」的意思:它有自己的执行位置和局部变量,靠 yield/send 与调用方来回交接控制权。下面是一个持续计算滑动平均值的生成器:
def running_avg():
total, count, avg = 0.0, 0, None
while True:
x = yield avg # 送出当前平均值,收回新数据
total += x
count += 1
avg = total / count
avg = running_avg()
next(avg) # 预热,丢弃第一个 None
print("avg:", avg.send(10)) # 10.0
print("avg:", avg.send(20)) # 15.0
print("avg:", avg.send(30)) # 20.0
这种「yield 既是出口又是入口」的写法,正是早期 asyncio 协程(@asyncio.coroutine + yield from)的基础。今天我们用 async/await 写协程,但底层的状态机模型没有变——第 13 章 13.1 async/await 与事件循环
会回到这条线索。
send() 的返回值
send(x) 的返回值,是本次暂停的那个 yield 表达式对外送出的值(即下一个 yield 的右值)。用一个「带命令的计数器」看清这点:
def counter():
n = 0
while True:
cmd = yield n # 送出 n,收回命令
if cmd == "reset":
n = 0
else:
n += 1
c = counter()
next(c) # 预热到第一个 yield
print(c.send(None)) # 1
print(c.send(None)) # 2
print(c.send("reset")) # 0
print(c.send(None)) # 1
每一次 send,你拿到的是上一次暂停时送出的值,而你送进去的值成为下一次 yield 的结果。把这条时序理清,send 就不再有魔法。
throw() 与 close():注入异常与清理
生成器支持从外部注入异常(throw)和请求关闭(close)。throw() 会在生成器暂停处抛出指定异常:
def careful():
while True:
try:
yield
except ValueError as e:
print(" caught:", e) # 在生成器内部被捕获
g = careful()
next(g)
g.throw(ValueError("boom")) # caught: boom
print("still alive:", next(g) is None) # still alive: True
如果生成器内部没有捕获,异常会向外传播,并导致生成器结束。
close() 则是请求生成器「收尾」——它在暂停处抛出 GeneratorExit:
def resource():
print(" open")
try:
yield 1
yield 2
finally:
print(" close") # finally 一定会执行
r = resource()
print("first:", next(r)) # open / first: 1
r.close() # close
GeneratorExit 是 BaseException 的子类:生成器必须让它继续传播(不能吞掉),否则 close() 会抛 RuntimeError。日常写法就是不要 except GeneratorExit,只用 finally 做清理——上面 resource() 的 finally 之所以会执行,正是因为 close() 在暂停处注入了 GeneratorExit。
这也解释了 7.3 上下文管理器与 with
里 with 能保证清理的底层机制。
yield from:委托给子生成器
yield from iterable 把当前生成器的「吐值 / 收值 / 抛异常」全部委托给子迭代器。最直观的用途是把子生成器的返回值带出来:
def finite():
yield 1
yield 2
return "finished" # 生成器里的 return 不是普通返回
def delegate():
result = yield from finite() # 拿到子生成器的返回值
print(" delegated returned:", result)
yield result
print("delegate:", list(delegate()))
# delegated returned: finished
# delegate: [1, 2, 'finished']
注意 finite() 里的 return "finished":生成器函数里的 return value 不产生元素,它把值放进 StopIteration.value,而 yield from 会自动把这个值作为表达式结果返回。如果直接手写循环,你就得自己捕获 StopIteration 再读 .value。
yield from 更完整的委托语义是:调用方的 send() 会透传给子生成器,throw() 也会被转发进去。下面这个例子同时演示了透传与恢复:
def inner():
try:
while True:
got = yield
print(" inner got:", got)
except ValueError as e:
print(" inner caught:", e)
yield "recovered" # 捕获后还能继续产出
def outer():
yield from inner()
o = outer()
next(o)
o.send("A") # inner got: A
o.send("B") # inner got: B
print("throw ->", o.throw(ValueError("x")))
# inner caught: x
# throw -> recovered
自己用 for 循环模拟 yield from,就得额外处理 send 的透传、throw 的转发、返回值的提取——这也是标准库推荐 yield from 而非手写委托的原因。
用生成器搭惰性管道
生成器最实用的形态是惰性管道:每个环节是一个生成器,数据像水流一样一节节穿过,全程不产生中间列表。下面这条管道读入文本行,解析出 名字 分数,过滤出及格者,再取出名字:
import io
DATA = """\
alice 88
bob 42
carol 95
dave 67
"""
def read_lines(stream):
for line in stream:
line = line.strip()
if line:
yield line
def parse_records(lines):
for line in lines:
name, score = line.split()
yield name, int(score)
def passing(records, threshold=60):
for name, score in records:
if score >= threshold:
yield name, score
def names(records):
for name, _ in records:
yield name
pipeline = names(passing(parse_records(read_lines(io.StringIO(DATA)))))
print(list(pipeline)) # ['alice', 'carol', 'dave']
它比「每步生成一个列表」省在哪? 关键在于数据的流动方式。如果每一步都返回列表,就必须等上一步全部算完才轮到下一步,且所有中间结果同时驻留内存。生成器管道则是一个元素走完全程,才轮到下一个元素:
def traced(name, src):
for x in src:
print(f" {name} pulls", x)
yield x
p = traced("B", traced("A", [1, 2, 3]))
print(" built")
print(" first:", next(p))
输出是:
built
A pulls 1
B pulls 1
first: 1
注意「built」时什么都没发生,直到第一次 next 才拉动数据;而且 A 只取了一个元素,B 就消费一个——没有整表等待,也没有中间列表。管道越长、数据越大,这个优势越明显。管道末端可以直接接聚合函数:
total = sum(score for _, score in parse_records(read_lines(io.StringIO(DATA))))
print("total:", total) # 292
生成器与 contextlib.contextmanager
第 7 章讲过 @contextlib.contextmanager:把一个生成器函数变成上下文管理器。它之所以成立,正是因为生成器的「暂停 / 恢复」恰好对应 with 的「进入 / 退出」——yield 之前是进入逻辑,之后是退出逻辑,异常则通过 throw 注入:
import contextlib
@contextlib.contextmanager
def tag(name):
print(f" <{name}>")
try:
yield name
finally:
print(f" </{name}>")
with tag("div") as t:
print(" inside", t)
# <div>
# inside div
# </div>
理解了 throw() 与 finally,你就明白 with 为什么能在异常路径上也保证清理。
常见坑
坑一:把 return 当成「产出最后一个值」。 生成器里的 return value 不会 yield 出 value,它只设置 StopIteration.value。要产出值就写 yield:
def bad():
yield 1
return 2 # 不会产生元素 2
print(list(bad())) # [1]
坑二:以为生成器被垃圾回收时 finally 不会执行。 在 CPython 里,生成器对象一旦引用计数归零,会被立即 close(),finally 随之执行:
def gen_with_finally():
try:
yield 1
finally:
print(" cleanup ran")
g = gen_with_finally()
next(g)
del g # 引用计数归零,生成器被立即 close()
# cleanup ran
不过这只是 CPython 的实现细节:不要依赖 GC 时机做资源清理,显式用 with 或 close() 才可靠。如果生成器内部持有对外部资源的引用、形成引用环,回收会被推迟到 GC 周期。
坑三:async 生成器是另一套东西。 async def 里用 yield 得到的是异步生成器,要用 async for 消费,且不能用 send/throw 那套同步语义。这是异步专属话题,放到 13.3 异步生态与常见陷阱
。
小结
yield是表达式,send(x)把值送进生成器;第一次必须先next()预热。- 生成器靠「暂停 / 恢复」与调用方双向交接控制权,是协程的雏形。
throw()注入异常,close()抛GeneratorExit;生成器里只用finally做清理,不要吞掉GeneratorExit。yield from把吐值、收值、抛异常全部委托给子生成器,并自动取出子生成器的返回值。- 生成器管道一个元素走完全程,不产生中间列表,内存占用与数据总量无关。
- 生成器里的
return value只写入StopIteration.value,不产出元素。
生成器让「惰性」落到了数据流上。下一节换一个角度:如何在不改动函数体的情况下,给函数追加行为——比如计时、重试、缓存、鉴权。这正是装饰器的舞台,也是第 4 章「一等函数」的直接延伸。
阅读导航:上一节:8.1 可迭代协议、迭代器与生成器 · 下一节:8.3 装饰器原理与实战 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。