Python生成器与迭代器惰性求值的实际收益
ARDrive
8 min read
生成器不是语法糖,而是把"内存占用从 O(n) 压到 O(1)"的刚需手段——只要数据量和处理管线同时变大,它就不是可选项。很多团队在本地小文件上跑得好好的脚本,一上生产环境处理大日志就内存爆掉,根因往往就是该用生成器却用了列表。
迭代器协议的本质
任何对象只要实现了 __iter__ 返回自身、并实现 __next__ 抛出 StopIteration 作为结束信号,它就是迭代器。for 循环、解包、成员判断全部建立在这套协议之上。可迭代对象(iterable)和迭代器(iterator)不是一回事:前者能被 iter() 多次调用产生新的迭代器,后者自己保存游标、只能往前走。
class CountUp:
def __init__(self, stop):
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 x in CountUp(3):
print(x) # 0 1 2
这里 CountUp 既是可迭代对象(有 __iter__)也是迭代器(有 __next__)。更干净的做法是 __iter__ 返回一个新的独立迭代器对象,避免同一个对象被两处遍历时游标互相干扰。区分二者很关键:列表、字符串、字典是 iterable,但 iter(列表) 返回的 list_iterator 才是 iterator。
生成器函数与 yield
yield 让普通函数变成生成器函数。调用它不立即执行函数体,而是返回一个生成器对象;每次 next() 推进到下一个 yield 并冻结局部状态,下次再进来从冻结点之后继续。这种"挂起—恢复"的机制靠的是编译器把函数体改写成状态机,而不是真起一个线程。
def fib(limit):
a, b = 0, 1
while a < limit:
yield a
a, b = b, a + b
g = fib(1000)
print(next(g), next(g), next(g)) # 0 1 1
yield 表达式还能接收值:gen.send(v) 把 v 作为当前 yield 的返回值,实现协程式双向通信。这种能力在流式管道里很实用,比如让生成器根据外部反馈动态调整产出节奏;但新手容易混淆"产出"和"收到",调试时尤其要分清两者。
惰性求值的收益
假设要统计一个几十 GB 日志里某状态码的出现次数。用列表读完再处理会直接撑爆内存,因为所有行同时驻留:
# 危险:整文件进内存
lines = open("huge.log", encoding="utf-8").readlines()
cnt = sum(1 for ln in lines if "503" in ln)
# 安全:逐行惰性
cnt = 0
with open("huge.log", encoding="utf-8") as f:
for ln in f: # 文件对象本身是可迭代的
if "503" in ln:
cnt += 1
生成器表达式写法更紧凑,且同样惰性:
cnt = sum(1 for ln in open("huge.log", encoding="utf-8") if "503" in ln)
sum、any、max 这类聚合消费掉生成器,但因为结果只要一个数,中间元素从不同时驻留内存。管道越长,收益越明显:读取、过滤、转换、聚合串成一条流,全程只保留当前这一行。
生成器表达式 vs 列表推导
sq_list = [x*x for x in range(10)] # 立即构建完整列表
sq_gen = (x*x for x in range(10)) # 返回一个生成器
差异在于构建时机:列表推导当场算完、占满结果数组;生成器表达式只在被 next 时算下一个。需要多次遍历、随机访问、切片时,列表更合适;只需要"流过一次"的管道,生成器更省。经验上,结果集可能很大、又只需顺序消费一次,先用生成器表达式几乎总是更稳。
无限序列与分页拉取
生成器天然适合表达"没有上界"的序列,因为根本不预存全部元素:
def evens():
n = 0
while True:
yield n
n += 2
e = evens()
for _ in range(5):
print(next(e)) # 0 2 4 6 8
调用第三方接口做分页拉取也是典型场景:每次 yield 一页,直到没有下一页为止,主循环完全不用关心总共多少页。
def paged(api, page_size=100):
page = 0
while True:
rows = api.fetch(page, page_size)
if not rows:
return
yield from rows
page += 1
yield from 把子迭代器逐元素委托出去,写分页、树遍历时比手写循环清爽。
边界条件
一次性耗尽:迭代器没有"倒带"能力,遍历两遍会得到空。需要重复遍历,要么重新构造,要么转成列表。
StopIteration 被吞:在生成器里用 return 携带的值会被当作结束信号丢弃(PEP 380 的 return value 仅对 yield from 可见)。yield from 子生成器的返回值要用 g = yield from sub() 接住,普通 next 拿不到。
异常传播:生成器内部抛出的异常会原样穿过 next() 冒泡给调用方,务必在消费处或产生处处理,否则整条管道崩溃。用 gen.throw(exc) 还能从外部往挂起点注入异常,配合 gen.close() 触发生成器里的 GeneratorExit 做清理。
踩坑
生成器里的资源要在 finally 关闭。with 语句配合生成器函数时,如果生成器从不被耗尽(提前 break),with 块仍然能正常退出清理;但手动 open 后若中途 return,文件句柄可能泄漏,建议始终用 with。
不要对生成器做 len()——它不知道总长。需要长度又想惰性,常见折中是边遍历边计数,或先用 itertools.islice 限定消费窗口。
生成器不是线程安全的。一个生成器对象被多个线程并发 next 会引发状态错乱,跨线程消费要加锁,或改用 queue.Queue 做生产者消费解耦。
惰性求值的代价是调试变难:错误发生在真正消费的那一刻,而非定义处。管道过长时,给每一步命名成独立函数、带上明确的返回值类型,能让堆栈更易读。需要即时看全部结果做检查,临时包一层 list() 转成列表即可,但记住这只该发生在数据量可控时。
生成器与迭代器这套机制的核心思想,是把"数据的产生"和"数据的消费"解耦成一条按需流动的管道。它不改变算法复杂度,却能把工程上最致命的内存峰值从随数据量线性增长,压成一条平稳的常数值。
1 view
0 likes
0 comments