《Python编程入门》8.2 生成器进阶:send / yield from 与惰性管道

yield 不只是用来吐出值的。本节从 send() 讲起,说明生成器为什么被称为协程的雏形;再讲 throw() 与 close() 的清理语义、GeneratorExit 为何必须重新抛出、yield from 的委托规则以及它如何取出子生成器的返回值;最后用生成器搭一条读文件、过滤、转换、聚合的惰性管道,说清它比每步生成一个列表省在哪里,并点一句异步生成器为何是另一套东西。

本节目标:掌握 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

有两个细节必须记住:

  1. 第一次必须用 next(g) 或 g.send(None) 预热。生成器还没跑到 yield 时没有「暂停点」可接收值,直接 send 非 None 会抛 TypeError。
  2. 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 异步生态与常见陷阱 。

小结

  1. yield 是表达式,send(x) 把值送进生成器;第一次必须先 next() 预热。
  2. 生成器靠「暂停 / 恢复」与调用方双向交接控制权,是协程的雏形。
  3. throw() 注入异常,close() 抛 GeneratorExit;生成器里只用 finally 做清理,不要吞掉 GeneratorExit。
  4. yield from 把吐值、收值、抛异常全部委托给子生成器,并自动取出子生成器的返回值。
  5. 生成器管道一个元素走完全程,不产生中间列表,内存占用与数据总量无关。
  6. 生成器里的 return value 只写入 StopIteration.value,不产出元素。

生成器让「惰性」落到了数据流上。下一节换一个角度:如何在不改动函数体的情况下,给函数追加行为——比如计时、重试、缓存、鉴权。这正是装饰器的舞台,也是第 4 章「一等函数」的直接延伸。

阅读导航:上一节:8.1 可迭代协议、迭代器与生成器 · 下一节:8.3 装饰器原理与实战 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「python」更多文章

  1. 《Python高级编程》目录
  2. 《Python高级编程》11.3 PEP 流程与版本迁移策略
  3. 《Python高级编程》11.2 嵌入式与自由线程运行时