第 4 章 生成器与迭代器:惰性计算救内存

这一章解决你迟早会撞上的墙:一个 5GB 的日志文件、一个返回百万条记录的数据库查询、一个无穷序列。你此前在 Go 里用 chan 流式处理、用 bufio.Scanner 一行行读,从不吃进全量。Python 里对应的武器是「生成器」——它让你「要一个给一个」,内存占用是常数级。

墨叔:老哥,问你个扎心的。假如有个 8GB 的 CSV,让你算某一列的和。你在 Go 里会怎么干?

老哥:那还用说,开个 bufio.Scanner 一行行读,边读边加,内存就几 KB。

墨叔:漂亮,这就是「流式 / 惰性」的本能。Python 里你要是写出 data = open(f).readlines() 再循环,恭喜,8GB 全塞内存,机器直接跪。Python 给了一模一样的武器——迭代器 + 生成器,核心就一句话:要一个,产一个,绝不全量加载。今天把这套协议从骨头到皮扒清楚。

一、可迭代与迭代器协议:for 循环背后的两套方法

你每天写 for x in something,但「something 凭什么能被 for」这件事,绝大多数人没真搞懂。答案在这俩「双下方法(dunder)」:

  • __iter__():返回迭代器本身(或一个迭代器)。有它的对象叫「可迭代对象(iterable)」。
  • __next__():返回下一个元素;没了就抛 StopIteration。有它的对象叫「迭代器(iterator)」。

for 循环的真面目,其实是:先调 iter(obj)(即 obj.__iter__())拿到迭代器,然后不停调 next(it)(即 it.__next__()),直到捕获 StopIteration 才停。

class CountUp:
    def __init__(self, stop: int) -> None:
        self.stop = stop
        self.n = 0
 
    def __iter__(self):
        return self          # 自己就是迭代器
 
    def __next__(self):
        if self.n >= self.stop:
            raise StopIteration
        val = self.n
        self.n += 1
        return val
 
for i in CountUp(3):
    print(i)        # 0 1 2

老哥:等等,迭代器只能走一遍?再 for 一次是不是空了?

墨叔:抓到要害了。上面这个 CountUp 既是可迭代又是迭代器,状态(n)在自身上,所以第二次 for 时 n 已经是 3,直接 StopIteration,啥也不出。正确做法是「可迭代对象」和「迭代器」分离:可迭代每次 __iter__ 都返回个全新的迭代器。

class CountUp:
    def __init__(self, stop: int) -> None:
        self.stop = stop
    def __iter__(self):
        return _CountIterator(self.stop)   # 每次都 new 一个迭代器
 
class _CountIterator:
    def __init__(self, stop: int) -> None:
        self.stop, self.n = stop, 0
    def __next__(self):
        if self.n >= self.stop:
            raise StopIteration
        v = self.n; self.n += 1; return v

墨叔:类比快递柜——「可迭代对象」是整排柜子,__iter__ 是「开一个新取件通道」;「迭代器」是这条通道,__next__ 是「取下一个包裹」。柜子能反复开通道,但一条通道取完就空了,得重新开。

这和你 Go 里「[]int 能 for range 多次,因为它每次 range 都从 0 新建游标」一个道理——只不过 Python 把「游标」显式做成了迭代器对象。

二、yield:一行顶一个迭代器类

手写 __iter__/__next__ 太啰嗦。Python 用 yield 关键字一句话把上面的类全干掉:

def count_up(stop: int):
    n = 0
    while n < stop:
        yield n      # 每次到这里「暂停并返回」,下次从这儿继续
        n += 1
 
for i in count_up(3):
    print(i)        # 0 1 2

含 yield 的函数叫生成器函数,调用它不执行函数体,而是返回一个生成器对象(generator,一种特殊的迭代器)。 每调一次 next(),函数体跑到 yield 暂停,把值交出来,下次接着往下走,直到函数结束自动抛 StopIteration。

老哥:这「暂停/恢复」不就是协程的雏形吗?

墨叔:眼光毒。yield 确实是 later 异步(asyncio)和协程的思想源头,不过这章先专注「惰性产出」,先别跑远。关键收益你现在就能感受到:count_up(10_000_000) 几乎不占内存,因为它一个值都没真存着,只是个「会产值的状态机」。换成 list(range(10_000_000)) 直接吃掉上 GB 内存。

三、生成器表达式:惰性版的列表推导

列表推导你熟:[x*x for x in range(10)] 一次性生成完整列表。把方括号换成圆括号,就是生成器表达式,惰性版:

eager = [x*x for x in range(10)]      # 立刻算完,占内存
lazy  = (x*x for x in range(10))      # 一个生成器,要才算
 
print(next(lazy))    # 0
print(next(lazy))    # 1
print(sum(x*x for x in range(10_000_000)))   # 惰性求和,内存极低

老哥:那是不是所有列表推导都该换成生成器表达式?

墨叔:反过来问得好——不是。要多次遍历、要随机下标访问、要改元素,就必须用列表(生成器只能走一遍,且不能索引)。生成器表达式适合「一次性流水线消费」「数据量大」「只求和/过滤/写文件」。另外生成器表达式最香的用法是当函数参数:sum(x*x for x in data) 比先建列表再 sum 省一整块内存。这跟你 Go 里「别先把全量塞 slice,边读边处理」的纪律完全一致。

四、send 与 yield from:双向通信与委托

基础 yield 是「单向产出」。加 send() 能「往回灌值」,让生成器变成双向通道:

def running_avg():
    total = 0.0
    count = 0
    avg = 0.0
    while True:
        val = yield avg          # 暂停,产出当前均值;被 send 唤醒时拿到新值
        total += val
        count += 1
        avg = total / count
 
r = running_avg()
next(r)              # 启动生成器,跑到第一个 yield 暂停(预激)
print(r.send(10))    # 10.0
print(r.send(20))    # 15.0
print(r.send(30))    # 20.0

val = yield avg 这行的含义:产出 avg,暂停;外部 r.send(新值) 时,那个「新值」就赋给了 val。注意第一次必须 next(r) 或 r.send(None) 把生成器推到第一个 yield 之后才能 send——这是个经典坑(PEP 342)。

yield from 则用来「把产出权委托给另一个生成器」,避免手写转发循环:

def chain(*iters):
    for it in iters:
        yield from it        # 等价于 for x in it: yield x,但更省、且能透传 send/throw
 
print(list(chain("ab", "cd", [1, 2])))   # ['a','b','c','d',1,2]

yield from 在写异步/协程嵌套时是关键语法(后面 asyncio 章会再见),现在你先记住它「扁平化嵌套产出」的能力。

五、惰性求值收益:省内存不是唯一,还有短路与无限序列

惰性真正的三个甜头:

  1. 内存常数级:处理 8GB 文件/百万行 DB 结果,内存就几 KB。核心服务不会因为一次性拉全量而 OOM。
  2. 短路(short-circuit):any(is_error(line) for line in huge_file) 一旦找到第一个 True 立刻停,后面的行根本不读——相当于你 Go 里 for 里 if cond { return } 提前退出。
  3. 无限序列:生成器能表达「永不结束」的流,比如传感器持续采样、斐波那契无限列,列表根本存不下,生成器却能一直 next。
def fib_infinite():
    a, b = 0, 1
    while True:
        yield a
        a, b = b, a + b
 
fibs = fib_infinite()
print([next(fibs) for _ in range(10)])   # 前 10 个斐波那契数

老哥:这对照 Go 不就是 chan int + go func 往里塞吗?

墨叔:神似。Go 用 goroutine 往 channel 流式推,消费者按需取;Python 用生成器在主线程里「暂停/恢复」推。两者都把「生产者」和「消费者」解耦、都惰性。区别是 Go 那是真并发(多核可并行),生成器是单线程协作式——但凡你只是「流式处理、不想吃内存」,生成器足够且更轻,不用搬出并发。

六、贴近项目:大文件与流式处理

给你一个真实场景:统计一个超大日志里各 IP 的出现次数,文件可能几十 GB,绝不许 readlines() 全读。

from collections import Counter
import re
 
IP_RE = re.compile(r"(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})")
 
def iter_lines(path: str):
    # 逐行惰性产出,内存只留当前一行
    with open(path, encoding="utf-8", errors="ignore") as f:
        for line in f:           # 文件对象本身就是迭代器,逐行给
            yield line
 
def count_ips(path: str) -> Counter:
    cnt = Counter()
    for line in iter_lines(path):
        m = IP_RE.search(line)
        if m:
            cnt[m.group(1)] += 1
    return cnt
 
# 想取 Top10,根本不用把全量结果存列表:用 heapq 或直接 Counter.most_common
top = count_ips("huge_access.log").most_common(10)

要点:open(path) 返回的文件对象本身就是迭代器,for line in f 是惰性逐行读——配合生成器,整个流水线内存占用是常数。如果你还想「边读边写另一个文件」「按窗口聚合」,只要继续串生成器即可,数据从不过量驻留。

再给一个「流式转换管道」的例子,体现生成器链像 Unix 管道:

def read_rows(path: str):
    with open(path, encoding="utf-8") as f:
        for line in f:
            yield line.strip()
 
def parse_rows(rows):
    for r in rows:
        if not r:
            continue
        yield r.split(",")        # 惰性解析,逐行
 
def filter_valid(parsed):
    for cols in parsed:
        if len(cols) >= 3 and cols[0].isdigit():
            yield cols
 
pipeline = filter_valid(parse_rows(read_rows("data.csv")))
for row in pipeline:              # 全程惰性,最后才真正推进
    print(row)

每一环都是生成器,数据像水一样流过管道,任何一环都不缓存全量。这不就是你 Go 里用 io.Reader 串 bufio.Scanner → 解析 → 过滤 的流式哲学吗?只是 Python 用 yield 把「管道阶段」写成了普通函数。

七、itertools 与生成器:把惰性玩出花

光靠手写 yield 还不够爽,标准库 itertools 是一整套「惰性迭代工具」,全返回迭代器、全不占内存,和生成器是天作之合。挑三个你项目高频用的:

import itertools
 
# islice:惰性切片,等于生成器的 [:100],但不物化
big = (x for x in range(10_000_000))
print(list(itertools.islice(big, 0, 3)))     # [0, 1, 2],只读前 3 个
 
# chain:惰性拼接多路数据源,比先合并列表省内存
for item in itertools.chain(read_rows("a.csv"), read_rows("b.csv")):
    ...
 
# groupby:惰性分组,做「按某键聚合」极顺手(需先排序)
rows = sorted(parse_rows(read_rows("orders.csv")), key=lambda r: r[1])
for key, group in itertools.groupby(rows, key=lambda r: r[1]):
    total = sum(float(r[2]) for r in group)   # 每组惰性求和

老哥:这 islice 不就是 Go 里「用 for i:=0;i<n;i++ 只循环前 N 个」嘛?

墨叔:对,本质都是「不把全量拉进来就截断」。itertools 的威力在于全链路惰性——islice 读大生成器前 100 个,后面几千万个根本不会被计算,等于你 Go 里一个带 break 的 for range 提前退出。它和你前面学的生成器表达式组合起来,能写出既省内存又极简的数据流水线,这是 Python 在「数据处理」这条赛道上最舒服的地方。

还有个进阶招:生成器配合 @contextmanager 能把「惰性产出 + 资源清理」绑一起。比如你要惰性读一个临时解压的文件,读完后自动删:

from contextlib import contextmanager
 
@contextmanager
def streaming_unzip(path: str):
    # 进入时打开资源,yield 处把生成器交给 with 块消费
    f = open(path, encoding="utf-8")
    try:
        yield (line for line in f)      # 惰性逐行产出
    finally:
        f.close()                        # 不论消费多少,结束必清理
 
with streaming_unzip("big.log") as lines:
    for line in lines:
        if "ERROR" in line:
            print(line)                  # 出了就停,文件句柄仍被正确关闭

这把第 5 章要讲的「上下文管理器(Python 的 defer)」和本章的生成器缝到了一处:资源用 with 兜底清理(对照 Go 的 defer f.Close()),数据用生成器惰性流出。你 Go 里要同时做到「defer 关文件 + bufio.Scanner 流式读」,也是这两件事的组合,心智模型完全对齐。

八、何时别用生成器:惰性化的边界

墨叔:老哥,我得泼盆冷水——别学完生成器就万物皆 yield。有几类场景它反而是负优化:

  1. 数据小且要多次遍历。你 for 一遍算均值、再 for 一遍找最大,生成器走完即废,得重开或物化成 list,反而折腾。小数据直接 list 一次物化,遍历随便来。
  2. 需要随机访问 / 下标。生成器不能 data[100],要第 N 个得一路 next 过去。要索引就用列表或 itertools.islice(但 islice 也是消费式的,取过就没了)。
  3. debug 困难期。生成器状态藏在暂停点,断点不好打。原型验证阶段先用列表把数据摊开看清楚,确认逻辑对了再改成生成器省内存——这是「先正确,后优化」的纪律。

墨叔:一句话口诀——「大数据、单次流、做聚合,上生成器;小数据、要索引、反复看,用列表」。和你 Go 里「小切片直接用,大流用 channel 边读边处理」的判断完全同频。

十、收益与代价的总账

  • 收益:内存常数级,能处理远超内存的数据;短路早停省算力;表达无限序列;串成流式管道,代码像 Unix 管道一样清爽;单线程零并发开销。
  • 代价:只能单向遍历一遍(要多次用得 list() 物化或重开生成器);调试比列表难,因为状态藏在「暂停点」;yield from/ send 心智负担高;生成器里抛的异常栈不如普通函数直观;过早惰性化(把本该一次性算完的小数据也写成生成器)反而增加复杂度,无收益。
  • 设计意图:Python 把「迭代协议」作为语言一等公民,再用 yield 把「手写迭代器类」简化成一行。它的哲学是「数据该流式就流式,别为了方便一次性吃光内存」——和你 Go 工程师的流式本能完全同频。

收个尾给你一句可落地的判断口诀,写代码前先问自己:这份数据会不会「比内存大」,或者「产出无限、消费有限」?只要命中一条,生成器或迭代器就应该是你的默认武器,而不是 list() 一把梭。反过来,数据确定很小、又要反复看、要随机取,就老老实实用列表,别为了「显得 Pythonic」硬上生成器,那叫过度设计。工程里最贵的不是多写几行,而是用错工具后半夜被 OOM 叫醒——这条经验,Go 老兵和 Python 老炮是同一个痛。

【思考题】

  1. 下面两段代码内存和语义差别在哪?total = sum([x*x for x in range(10_000_000)]) 对比 total = sum(x*x for x in range(10_000_000))。如果数据只有 10 个,你还坚持用生成器表达式吗?为什么?
  2. 你写了一个生成器函数 def gen(): yield 1; yield 2,然后 g = gen(),next(g) 拿到 1,此时如果你对它 next(g) 第三次(已无值),会发生什么?这个行为和 for 循环怎么对应?如果想「重置」重新从头遍历,该怎么做?
  3. Go 里你常用 chan + for v := range ch 做流式消费。Python 生成器和 channel 在数据流模型上像,但在「并发 / 跨线程 / 背压」上有什么本质不同?什么场景你宁可上 Go 的 channel 或 Python 的 asyncio.Queue 而不是生成器?

【参考答案】

  1. 列表推导版 [...]:先把一千万个 x*x 全部算出塞进一个 list,再交给 sum——峰值内存占用约千万个 int 对象(几十上百 MB),且白算了一遍又弃掉,纯浪费。生成器表达式版 (...):返回一个生成器,sum 内部每次 next 才现算一个值,内存几乎为零,且只在需要时计算。这是「惰性 vs 物化」的典型对比。若数据只有 10 个:没必要用生成器,反而多一层生成器对象开销、且失去「可重复遍历/索引」能力;小数据直接用列表推导更直观。经验法则:数据量大、只消费一次、做聚合/写出,用生成器表达式;数据小或要多次用/要索引,用列表。
  2. g = gen() 调 next(g) 第一次得 1,第二次得 2;第三次时函数体已执行完毕,生成器自动抛出 StopIteration 异常。这正是 for 循环结束的信号——for 内部不断 next(),捕获到 StopIteration 就静默退出循环。所以 for 不会让你看到这个异常,而手动 next() 第三次会直接抛 StopIteration(在 REPL 里显示 StopIteration,在代码里不捕获就会崩)。想「重置重来」:生成器是一次性状态机,不能 reset,只能重新调用 gen() 得到一个新的生成器对象 g2 = gen(),再 next(g2) 从头开始。这正好印证正文「迭代器分离」的要点——可迭代对象负责「能反复开新迭代器」,迭代器本身走完即废。
  3. 模型像、机制不同:生成器是单线程协作式的暂停/恢复,生产者(yield)和消费者(next)在同一线程交替执行,没有真正的并发,也不跨线程;channel 是并发原语,chan 连接的是可能在不同 goroutine 上跑的生产者和消费者,自带调度、可多生产者多消费者。背压方面:生成器是「消费者驱动」——你不 next 它就不产,天然背压,但只在单线程;channel 也有背压(缓冲满则阻塞),且能跨 goroutine 协调。何时宁可上 channel / asyncio.Queue:①需要真正的并发生产者与消费者(多核并行、IO 等待时让出);②多生产者/多消费者解耦(如多个 worker 抢任务队列);③跨线程/跨协程传递,且要超时、取消等控制。生成器适合「单线程内、生产者消费者同速、纯 CPU 流式处理」——它零并发开销、最简单;一旦涉及并发或跨线程,就该换 asyncio.Queue(协程间)或 Go 的 channel(真正的多核并发)了。