第 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.0val = 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 章会再见),现在你先记住它「扁平化嵌套产出」的能力。
五、惰性求值收益:省内存不是唯一,还有短路与无限序列
惰性真正的三个甜头:
- 内存常数级:处理 8GB 文件/百万行 DB 结果,内存就几 KB。核心服务不会因为一次性拉全量而 OOM。
- 短路(short-circuit):
any(is_error(line) for line in huge_file)一旦找到第一个 True 立刻停,后面的行根本不读——相当于你 Go 里for里if cond { return }提前退出。 - 无限序列:生成器能表达「永不结束」的流,比如传感器持续采样、斐波那契无限列,列表根本存不下,生成器却能一直
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。有几类场景它反而是负优化:
- 数据小且要多次遍历。你
for一遍算均值、再for一遍找最大,生成器走完即废,得重开或物化成list,反而折腾。小数据直接list一次物化,遍历随便来。 - 需要随机访问 / 下标。生成器不能
data[100],要第 N 个得一路next过去。要索引就用列表或itertools.islice(但 islice 也是消费式的,取过就没了)。 - debug 困难期。生成器状态藏在暂停点,断点不好打。原型验证阶段先用列表把数据摊开看清楚,确认逻辑对了再改成生成器省内存——这是「先正确,后优化」的纪律。
墨叔:一句话口诀——「大数据、单次流、做聚合,上生成器;小数据、要索引、反复看,用列表」。和你 Go 里「小切片直接用,大流用 channel 边读边处理」的判断完全同频。
十、收益与代价的总账
- 收益:内存常数级,能处理远超内存的数据;短路早停省算力;表达无限序列;串成流式管道,代码像 Unix 管道一样清爽;单线程零并发开销。
- 代价:只能单向遍历一遍(要多次用得
list()物化或重开生成器);调试比列表难,因为状态藏在「暂停点」;yield from/send心智负担高;生成器里抛的异常栈不如普通函数直观;过早惰性化(把本该一次性算完的小数据也写成生成器)反而增加复杂度,无收益。 - 设计意图:Python 把「迭代协议」作为语言一等公民,再用
yield把「手写迭代器类」简化成一行。它的哲学是「数据该流式就流式,别为了方便一次性吃光内存」——和你 Go 工程师的流式本能完全同频。
收个尾给你一句可落地的判断口诀,写代码前先问自己:这份数据会不会「比内存大」,或者「产出无限、消费有限」?只要命中一条,生成器或迭代器就应该是你的默认武器,而不是 list() 一把梭。反过来,数据确定很小、又要反复看、要随机取,就老老实实用列表,别为了「显得 Pythonic」硬上生成器,那叫过度设计。工程里最贵的不是多写几行,而是用错工具后半夜被 OOM 叫醒——这条经验,Go 老兵和 Python 老炮是同一个痛。
【思考题】
- 下面两段代码内存和语义差别在哪?
total = sum([x*x for x in range(10_000_000)])对比total = sum(x*x for x in range(10_000_000))。如果数据只有 10 个,你还坚持用生成器表达式吗?为什么? - 你写了一个生成器函数
def gen(): yield 1; yield 2,然后g = gen(),next(g)拿到 1,此时如果你对它next(g)第三次(已无值),会发生什么?这个行为和for循环怎么对应?如果想「重置」重新从头遍历,该怎么做? - Go 里你常用
chan+for v := range ch做流式消费。Python 生成器和 channel 在数据流模型上像,但在「并发 / 跨线程 / 背压」上有什么本质不同?什么场景你宁可上 Go 的 channel 或 Python 的 asyncio.Queue 而不是生成器?
【参考答案】
- 列表推导版
[...]:先把一千万个x*x全部算出塞进一个 list,再交给sum——峰值内存占用约千万个 int 对象(几十上百 MB),且白算了一遍又弃掉,纯浪费。生成器表达式版(...):返回一个生成器,sum内部每次next才现算一个值,内存几乎为零,且只在需要时计算。这是「惰性 vs 物化」的典型对比。若数据只有 10 个:没必要用生成器,反而多一层生成器对象开销、且失去「可重复遍历/索引」能力;小数据直接用列表推导更直观。经验法则:数据量大、只消费一次、做聚合/写出,用生成器表达式;数据小或要多次用/要索引,用列表。 g = gen()调next(g)第一次得 1,第二次得 2;第三次时函数体已执行完毕,生成器自动抛出StopIteration异常。这正是for循环结束的信号——for内部不断next(),捕获到StopIteration就静默退出循环。所以for不会让你看到这个异常,而手动next()第三次会直接抛StopIteration(在 REPL 里显示StopIteration,在代码里不捕获就会崩)。想「重置重来」:生成器是一次性状态机,不能 reset,只能重新调用gen()得到一个新的生成器对象g2 = gen(),再next(g2)从头开始。这正好印证正文「迭代器分离」的要点——可迭代对象负责「能反复开新迭代器」,迭代器本身走完即废。- 模型像、机制不同:生成器是单线程协作式的暂停/恢复,生产者(
yield)和消费者(next)在同一线程交替执行,没有真正的并发,也不跨线程;channel 是并发原语,chan连接的是可能在不同 goroutine 上跑的生产者和消费者,自带调度、可多生产者多消费者。背压方面:生成器是「消费者驱动」——你不next它就不产,天然背压,但只在单线程;channel 也有背压(缓冲满则阻塞),且能跨 goroutine 协调。何时宁可上 channel / asyncio.Queue:①需要真正的并发生产者与消费者(多核并行、IO 等待时让出);②多生产者/多消费者解耦(如多个 worker 抢任务队列);③跨线程/跨协程传递,且要超时、取消等控制。生成器适合「单线程内、生产者消费者同速、纯 CPU 流式处理」——它零并发开销、最简单;一旦涉及并发或跨线程,就该换 asyncio.Queue(协程间)或 Go 的 channel(真正的多核并发)了。