Python · 生成器与迭代协议

for 循环的真面目: __iter__/__next__ 协议 — yield 让函数"可暂停", 数据逐个流动, 内存 O(n) 变 O(1)

实现 表达式形式 迭代协议 — for 的真面目 for x in obj: 等价于反复 next(it) it = iter(obj) → __iter__ next(it) → __next__ 抛 StopIteration → for 正常结束 list/str/dict/file 都是迭代器协议的客户 生成器 — 可暂停的函数 def g(): ... yield x ← 调用不执行 返回生成器对象; next() 执行到 yield 暂停 帧被完整挂起: 局部变量 + 执行位置保存 再 next() 从暂停点恢复 — 零拷贝零物化 async/await 的前身与同构机制 两种推导式 [x*x for x in seq] 立即构建完整 list — O(n) 内存 (x*x for x in seq) 惰性生成器 — 用一个算一个 只差一对括号, 内存模型完全不同 A · 列表链 — 每步全量物化 read → list 10GB 全读进内存 ✗ filter → 新 list 又一份 ~8GB map → 新 list 又一份 ~9GB sum 峰值 3×O(n) ✗ B · 生成器链 — 逐个流动 read → gen 逐行 yield ✓ filter gen 合格才往下传 map gen 算一个传一个 sum 峰值 O(1) ✓ 同样的代码结构, 只把中转 list 换成生成器: 峰值内存 3×O(n) → O(1), 10GB 日志在 512MB 容器也能跑 进阶机制 yield from sub_gen() — 委托子生成器, 逐层透传 (异常/返回值也透传) g.send(v) — 向暂停点注入值, 双向通信 (协程的雏形) g.close() — 向暂停点抛 GeneratorExit, 触发 finally 清理 Legend 协议 / 机制 生成器 (惰性) 列表链 (全量物化) 对比概念

迭代协议是统一接口

  • • for / sum / zip / unpack 全走 __iter__/__next__
  • • 自定义类实现 __iter__ 即可被 for — 鸭子类型
  • • 文件对象天生是迭代器: for line in f 逐行惰性读

yield = 可暂停的栈帧

  • • 暂停时保存局部变量与执行位置, 恢复零成本
  • • 数据"拉"式流动: 下游要一个, 上游算一个
  • • asyncio 的 await 挂起点就是同款机制

生产价值 = 内存可控

  • • 流式 ETL: 10GB 文件在 512MB 容器跑完
  • • 分页游标: 无限滚动的"下一页"天然是生成器
  • • 早停: 找到即停, 不必处理整个数据集

💡 一句话理解

生成器是"可暂停的函数": 执行到 yield 就地冻结(保存局部变量和位置), next() 时从断点复活。这让数据可以一个一个流过处理链而不是每步都造一份完整副本 —— 同样的管道代码, 内存从 3×O(n) 降到 O(1)。它也是 Python 一切"惰性"与"并发"(asyncio)的地基。

🧠 必知必会 必考 & 必会

for 的展开形式
for x in obj = it = iter(obj) 循环 x = next(it) 直到 StopIteration。迭代协议是 Python 最普及的接口, 没有之一。
it = iter([1, 2, 3])
next(it)                            # → 1
next(it); next(it)                  # → 2, 3 — for 循环内部就是反复 next
next(it)                            # → StopIteration — for 靠它正常收尾
含 yield 即生成器
函数体只要出现 yield, 调用它不执行任何函数体, 只返回生成器对象; 函数体在首次 next() 时才开跑。
def g():
    print("started")               # 关键: 调用 g() 时一行都不执行
    yield 1
gen = g()                           # 只创建生成器对象, 函数体未跑
next(gen)                           # 此刻才打印 started 并产出 1
一次性消费
生成器只能迭代一次, 耗尽后再 next 抛 StopIteration, 再 for 得到空。需要重复使用就 list(g) 物化(付出内存代价)或重建。
g = (x for x in range(3))
list(g)                             # → [0, 1, 2]
list(g)                             # → [] — 已耗尽, 静默空转不报错
data = list(make_gen())             # 对策: 要重复用就物化, 或每次重建
惰性的代价
异常在消费时才触发; 不消费就没有副作用(比如"只写不跑"的日志生成器); len()/切片不可用。
g = (1 / x for x in [1, 0])     # 创建时不报错
next(g)                             # → 1.0
next(g)                             # → ZeroDivisionError — 消费时才爆发
len(g)                              # → TypeError: 生成器没有 len/切片
yield from
yield from sub() 委托子生成器逐个透传, 等价于 for + yield, 还会透传 return 值与异常 — 生成器组合的标准写法。
def sub():
    yield 1; yield 2
    return 3
def outer():
    r = yield from sub()       # 关键: 逐个透传, 还接住 return 值
    yield r
list(outer())                       # → [1, 2, 3]
send / close
g.send(v) 把值注入暂停点(yield 表达式的值), 实现双向通信; g.close() 抛 GeneratorExit 触发 finally — 手动管理生成器生命周期。
def echo():
    while True:
        got = yield              # yield 表达式接收 send 注入的值
        print(got)
g = echo(); next(g)                 # 关键: 先预激到第一个 yield
g.send("hi")                       # → 打印 hi
g.close()                           # 在暂停点抛 GeneratorExit → 走 finally
itertools
islice(惰性切片)、chain(拼接)、groupby(分组) —— 生成器世界的标准库武器, 全部 O(1) 内存。
from itertools import islice, chain, count
list(islice(count(1), 3))        # → [1, 2, 3] — 无限流的"切片"仍 O(1) 内存
list(chain([1], [2, 3]))         # → [1, 2, 3] — 惰性拼接

🏭 生产实战 real world

场景 1 · 10GB 日志流式 ETL, 512MB 容器跑通

统计每个渠道的错误码分布。全量读必 OOM, 生成器管道全程只驻留一行:

def read_lines(path):
    with open(path, 'rb') as f:
        for line in f:                    # 文件对象本身就是惰性迭代器
            yield line

bad = (l for l in read_lines('app.log') if b'ERROR' in l)
parsed = (parse(l) for l in bad)     # 生成器链: 元素逐个流过
for (code, n) in Counter(p.channel for p in parsed).most_common(10):
    print(code, n)   # 峰值内存 = 一行 + 一个计数器

场景 2 · 数据库游标分页, 接口"无限滚动"

把分页查询封装成生成器, 调用方像遍历普通序列一样用, 内部自动翻页:

def iter_page(db, size=500):
    last_id = 0
    while True:
        rows = db.query("SELECT * FROM t WHERE id > %s ORDER BY id LIMIT %s",
                        (last_id, size))
        if not rows: return
        yield from rows                   # 委托透传
        last_id = rows[-1].id

for row in iter_page(db):    # 消费方无感: 找到目标即 break, 后面页不查 (早停)
    ...

对比 fetchall() 一次拉百万行: 生成器版本数据库压力与内存都恒定; 配合早停, 未见过的数据根本不会查询。

场景 3 · islice 实现生成器的"切片"与限流

from itertools import islice
head = islice(huge_stream(), 100)          # 惰性取前 100 个, 不会拉全量
sample = islice(stream, 0, None, 100)     # 每 100 个抽 1 个做采样监控

场景 4 · 海量数据流式去重

10 亿 URL 放不进 set: 分片哈希让"同一条必落同一片", 每片独立去重, 内存恒定:

def shard_urls(urls, n=64):
    files = [open(f"tmp/shard_{i}.txt", "w") for i in range(n)]
    try:
        for u in urls:                       # urls 是读源文件的生成器
            files[hash(u) % n].write(u + "\n")  # 同一 URL 恒定落同一片
    finally:
        for f in files: f.close()
# 之后逐片 set() 去重 — 单片集合大小 = 总量/64, 可控

场景 5 · 实时日志 tail 监控

自实现 tail -f: 生成器内轮询增量行, 消费方拿到的就是"持续到来的新行":

import time
def follow(path):
    with open(path, "rb") as f:
        f.seek(0, 2)                        # 跳到文件末尾 (断点续读改 seek(上次偏移))
        while True:
            line = f.readline()
            if not line:
                time.sleep(0.5); continue   # 没新内容, 稍后再看
            yield line
for line in follow("/var/log/app.log"):
    if b"ERROR" in line: alert(line)

场景 6 · 可测试的 ETL 管道工厂

每阶段都是"生成器进、生成器出"的纯函数, 单测喂 iter([...]) 三行数据即可全链路验证:

def parse(lines):      yield from (json.loads(l) for l in lines)
def keep(evt):         yield from (e for e in evt if e["ok"])
def enrich(evt):       yield from ({**e, "region": region(e["ip"])} for e in evt)

pipeline = lambda src: enrich(keep(parse(src)))   # 组合即代码
# 单测: assert list(pipeline(iter([line1, line2]))) == [...]

场景 7 · 消息流按批提交

逐条写库太慢、全量攒怕丢: 生成器把消息流切成固定批量, 吞吐与安全的平衡点:

from itertools import islice
def batches(msgs, size=500):
    while batch := list(islice(msgs, size)):   # 取满 500 或耗尽
        yield batch
for batch in batches(consumer):
    db.executemany(INSERT_SQL, batch)            # 500 行一次落库
    consumer.ack(batch)                          # 批内全部成功才 ack

场景 8 · 令牌桶限速器

限速逻辑封进生成器, 业务循环拿不到令牌就等在生成器里, 与业务代码解耦:

import time
def token_bucket(rate=100):                  # 100 QPS
    tokens, last = 0.0, time.monotonic()
    while True:
        now = time.monotonic()
        tokens = min(rate, tokens + (now - last) * rate)   # 按时间补充
        last = now
        if tokens >= 1:
            tokens -= 1; yield                     # 发放一枚令牌
        else:
            time.sleep((1 - tokens) / rate)       # 差多少睡多少

场景 9 · 千万元数据分批导入

游标生成器逐行读源表 + islice 分批写目标表, 源不爆、事务日志不膨胀:

def iter_rows(cur):
    while rows := cur.fetchmany(5000):   # 服务端游标, 常驻内存一行
        yield from rows
src = iter_rows(src_conn.cursor())
while chunk := list(islice(src, 5000)):
    dst.executemany(INSERT, chunk)              # 5000 行/事务, 可断点续跑
    mark_progress(len(chunk))                   # 进度可观测, 失败从断点重来

场景 10 · 惰性测试夹具

fixture 返回生成器, 样本生成开销按需支付, 参数化取之不尽:

import itertools, pytest

@pytest.fixture
def users():
    return (make_user(f"u{i}") for i in itertools.count())  # 无限样本流

def test_topk(users):
    top10 = list(islice(users, 10))       # 只造 10 个, 不是一万个
    assert rank(top10) == sorted(top10, key=score, reverse=True)

⚠️ 编码注意与常见坑 pitfalls

坑 1 · 生成器只能用一次 — list(g) 之后 g 已耗尽, 二次 for 是静默空转(不报错)。正解: 需要多次消费就物化成 list/tuple, 或把生成器做成工厂函数每次重建。
g = gen(); total = sum(g)
again = sum(g)                      # 错: → 0, 二次消费静默空转
data = list(gen())                  # 对: 要多次消费就物化或做成工厂
坑 2 · 惰性导致异常延迟爆发 — 管道里的 bug 在消费端才抛出, 调用栈指向消费处而非定义处。正解: 边界处尽早消费/校验(比如入口先 next 一次), 单测里 list() 全量跑一遍。
rows = (parse(l) for l in lines)  # 错: 坏数据此刻不炸, 消费端才炸
next(rows, None)                    # 对: 边界处先拉一条尽早校验
坑 3 · 把生成器传给需要 len 的代码 — len()/切片/反转直接 TypeError。正解: sum(1 for _ in g)(会耗尽)或 itertools.islice; 接口要求序列就别给生成器。
len(g)                              # 错: → TypeError: object of type 'generator' has no len()
g[:10]                             # 错: 切片同炸
head = islice(g, 10)                # 对: 惰性取前 N; 计数用 sum(1 for _ in g)
坑 4 · 提前 abandon 不关闭 — 半途 break 后生成器挂起的文件句柄/事务等 finally 要等 GC 才执行。正解: 用 contextlib.closing(g) 或 with 语句管理, break 前显式 close。
for row in read_huge():           # 错: break 后文件句柄悬到 GC 才关
    break
with closing(read_huge()) as r:    # 对: 退出即 close, 触发生成器 finally
    for row in r: break
坑 5 · 生成器里做副作用 — 混入写库/发消息的"惰性副作用"极易失控(没消费就没执行, 重试就重复执行)。正解: 生成器只做纯数据变换, 副作用放在显式消费循环里。
def bad():                          # 错: 惰性副作用 — 没消费就没执行, 重试就重复执行
    for r in src: db.insert(r); yield r
def pure(rs):                       # 对: 生成器只做纯数据变换
    yield from (transform(r) for r in rs)
for r in pure(src): db.insert(r)     # 副作用放显式消费循环里
坑 6 · all/any 短路后副作用没执行 — any(send(x) for x in batch) 短路即停, 后面的元素根本没发送。正解: 需要全量副作用先物化或拆两步; all/any 只用于纯判断。
ok = any(send(x) for x in batch)   # 错: 遇 True 短路, 后面的 x 根本没发送
sent = [send(x) for x in batch]    # 对: 全量物化保证都发; 判断与发送拆开
坑 7 · zip 长度不齐静默截断 — 两个序列一长一短, zip 悄悄按短的截, 数据无声丢失。正解: 3.10+ 用 zip(a, b, strict=True) 直接抛错; 老版本先用 len 对齐断言。
list(zip([1, 2, 3], ['a']))         # 错: → [(1, 'a')] — 多出的 2, 3 无声丢失
list(zip([1, 2, 3], ['a'], strict=True))  # 对: → ValueError (3.10+)
坑 8 · islice 消耗了原生成器 — islice(g, 10) 之外的部分也已被部分拉取/状态推进, 原 g 再用行为不可预期。正解: 需要多次取段就把源头做成工厂函数, 每次新生成器。
head = islice(g, 10)               # 错: g 状态已被推进, 再用行为不可预期
rest = list(g)                      # 拿到的是第 11 个之后
make = src_gen                      # 对: 源头做成工厂, 每次取段 islice(make(), 10)
坑 9 · 深层管道异常定位难 — 5 层生成器链里 parse 抛错, 栈只指向消费处。正解: 每层 except 后 raise 附加阶段名; 关键节点旁路打印样本(tee 技巧); 单测分层覆盖。
try:
    yield from upstream()          # 错: 5 层管道里炸, 栈只指向消费处
except ParseError as e:
    raise ParseError(f"stage=parse: {e}") from e   # 对: 逐层附加阶段名
坑 10 · yield 挂着资源等 GC — 生成器里打开的文件, 要到耗尽或 GC 才关, 半途 break 就悬着。正解: 生成器体内用 with 管理资源 + 消费方显式 close()(触发 GeneratorExit 走 finally)。
def read(path):
    f = open(path)                  # 错: 半途 break, f 要等耗尽或 GC 才关
    yield from f
def read(path):
    with open(path) as f:           # 对: close() 触发 GeneratorExit, with 收尾
        yield from f
坑 11 · 慢消费拖垮上游资源 — 生成器持有 DB 游标, 消费端每秒一条 → 游标超时/连接占用数小时。正解: 有超时的资源上游加缓存层(先取批进内存再 yield), 或消费提速/断开重连策略。
for row in iter_cursor(cur):       # 错: 每秒 1 条 → DB 游标/连接挂几小时
    slow_process(row)
def batches(cur, n=1000):          # 对: 先取批进内存再 yield, 缩短持有时间
    while rows := cur.fetchmany(n): yield from rows
坑 12 · 深递归生成器性能 — 树遍历多层 yield from, 每个元素要穿过 N 层帧, 深树明显变慢。正解: 显式栈/队列迭代改写热点层; yield from 留给浅层组合。
def walk(node):                       # 错: 深树递归 yield from, 每元素穿 N 层帧
    yield node.val
    for c in node.children: yield from walk(c)
stack = [root]                        # 对: 热点层显式栈迭代, yield from 留给浅层
while stack: node = stack.pop(); ...
坑 13 · send 之前忘了启动 — 新生成器必须先 next(g)(或 g.send(None))跑到第一个 yield, 否则 TypeError。正解: 用 itertools.count 类预置装饰器 @prime 统一预激。
g = echo()
g.send("hi")                         # 错: → TypeError: can't send non-None value to a just-started generator
g = echo(); next(g)                 # 对: 先预激到第一个 yield 再 send
坑 14 · 把 send 式生成器当协程用 — 原生协程(async def)才是现代写法, 老式 send 协程难以组合与调试。正解: 历史代码看清类型; 新代码一律 async/await。
g = coro(); g.send(None)            # 错: 老式 send 协程难组合难调试, 别再写
async def coro(): ...               # 对: 新代码一律 async/await 原生协程
await coro()
坑 15 · 生成器帧长期持有大 buffer — 局部变量跟着挂起的帧活着, 一个引用一个 100MB 的 df 就是隐形泄漏。正解: 用完即 del buf 或置 None 再挂起; 大对象尽量不进生成器作用域。
def pipe():
    buf = load_100mb()               # 错: buf 跟挂起帧活到耗尽 → 隐形常驻 100MB
    yield from transform(buf)
def pipe():
    yield from transform(load_100mb())  # 对: 大对象不进生成器局部; 或用完 del buf
坑 16 · 日志打印耗尽了生成器 — log.debug("items=%s", list(g)) 顺手物化, 正式消费时已空。正解: 调试打印用 islice(g, 3) 采样并接受副作用; 或统一由消费端记数。
log.debug("items=%s", list(g))              # 错: 一行日志把流喝光, 正式消费已空
log.debug("sample=%s", list(islice(g, 3)))  # 对: 只采样前 3 条 (也推进状态, 接受副作用)
坑 17 · 惰性链上的 bug 延迟到生产 — 测试只测了构造没消费, 异常路径从未执行。正解: 单测必须消费到底(list(g) 或 reduce), 并覆盖脏数据样例。
pipeline = enrich(keep(parse(src)))   # 错: 测试只断言构造成功 — 一行都没跑
assert list(pipeline(iter([dirty])))  # 对: 单测必须消费到底, 覆盖脏数据样例
坑 18 · 多线程共享一个生成器 — next 虽在 GIL 下原子, 但"取出的元素间"的语义由谁处理会乱; send/close 更是状态灾难。正解: 每线程独立生成器, 或队列分发元素。
g = src()                             # 错: 两线程 next 同一个 g, 元素归属混乱
Thread(target=consume, args=(src(),))  # 对: 每线程独立生成器, 或经队列分发
坑 19 · 为 len() 物化整个流 — len(list(g)) 把 O(1) 内存变成 O(n)。正解: 计数用 sum(1 for _ in g)(仍会耗尽), 真要反复用就老实物化并接受成本。
n = len(list(g))                     # 错: O(1) 内存变 O(n), 10GB 流直接爆
n = sum(1 for _ in g)               # 对: 流式计数 (会耗尽); 要反复用就物化并接受成本
坑 20 · 无限生成器没有护栏 — while True 生成器一旦进入无条件消费循环就是死循环。正解: 消费端固定 islice/计数上限; 生成器内部带 max_iter 参数防御性收尾。
def ids():                           # 错: while True 无护栏, 进了 for 就是死循环
    i = 0
    while True: yield i; i += 1
for x in islice(ids(), 1000): ...  # 对: 消费端固定上限; 生成器内带 max_iter 防御