一段代码把 zip(a, b) 的结果存进变量,先用它"算长度",然后又用它在循环里"逐对处理"——而循环"一对都没跑到"。原因是:zip 返回的是迭代器,而"算长度"那一步把它"消费完了"。
Python 的"惰性"不是"性能优化",而是"一次性的消费语义"。而它有三处最容易踩:迭代器只能消费一次、len() 不支持迭代器、zip 在最短处停止.
1. 三种"可迭代"的差别
| 类型 | 能多次遍历 |
|---|---|
| 列表/元组 | 是 |
生成器/zip/map |
否 |
视图(dict.keys()) |
是(视图可重遍历) |
第二项是"坑"的来源:它们的"数据"不是"存着",而是"按需产出"第二项"按需产出"的机制是"__next__ 每次算下一个":算过就"过去了",没有"回到第一个"这回事——除非"重新创建一个".
——于是"第一次遍历"把它"走完"了。
2. "被消费完"的三种信号
| 信号 | 说明 |
|---|---|
| 循环体不执行 | 迭代器已空 |
len() 报错 |
迭代器不支持 |
第二次 list() 得空 |
同上 |
第三项的"调试价值"很高:在"怀疑被消费"的地方"打一行 print(len(list(it)))"——第一次是 N,第二次是 0,就确认了.
第三项最直观:list(it) 之后再 list(it) 得到 []**——而"看不出来"是因为"两个 list 长得一样".
"被消费"这件事可以用"一行断言"防住,而不是靠记:
// 三种"物化"的时机(按用途选)
items = list(z) # 小数据:直接存
items = [next(z) for _ in range(10)] # 只要前 N:islice 更好
from itertools import islice; items = list(islice(z, 10))
// 判据:需要"再遍历一次"就必须"物化",否则"必然空"
3. "要多次用"的三种做法
| 做法 | 代价 |
|---|---|
先 list() 存下来 |
内存(大对象慎用) |
每次重建(zip(a,b) 再调一次) |
重建成本(通常便宜) |
用 itertools.tee 分流 |
隐式缓冲(易爆内存) |
第二项是"最常推荐"的:zip 本身"零成本"第二项的"前提"是"源数据可重遍历":zip(a, b) 里的 a/b 若是"列表",重建"零成本"——若它们也是"生成器",重建就没用了(已耗尽).
——"重算一遍"比"缓存一份"更省.
第三项的"场景"很具体:"一路读得快、一路读得慢"——快的会把"差值"全缓在内存,于是"看起来省内存的写法"反而"吃内存".
第三项要小心:tee 会把"一个慢消费者没读的部分"缓存在内存里——于是"两路速度不同"时,内存会"悄悄涨".
"长度不一致"要"显式失败",否则"少几条"不会被发现:
// 三种"长度校验"
1 assert len(a) == len(b) # 列表:最直接
2 zip(a, b, strict=True) # Python 3.10+:不等长即抛错
3 zip_longest(a, b, fillvalue=None) # 允许不等长:显式填充
// 判据:期望"等长"就用 strict,期望"补齐"就用 long——不要默认"最短停"
4. "zip 在最短处停止"的三种后果
| 后果 | 说明 |
|---|---|
| 数据"少了一半" | 一个序列短 |
| 静默截断 | 不报错 |
| 索引错位 | 用 zip 代替"按下标配对" |
第二项最危险:"长度不一致"时不报错,只"少处理几条"第二项的"典型事故"是"报表少行":"用户表 1000 行、订单表 980 行"——**zip 只处理 980 对,于是"20 个用户的统计消失",而报表"看起来正常".
——于是"报表少了几行"没人发现.
// 三种"配对"的差别
z = zip(a, b) # 最短处停(静默)
z2 = zip_longest(a, b, fillvalue=None) # 以最长为准(显式填充)
// 判据:长度"是否可能不一致"——可能,就用 long/或先断言
"生成器里的副作用"是另一个坑,而它比"消费完"更隐蔽:
// 反例:生成器里改外部状态,遍历次数不同 → 结果不同
def gen():
for x in data:
counter[0] += 1 # 副作用:被"遍历几次"决定
yield x
// 正解:生成器"纯"(只读输入、只产值),副作用交给"消费者"
5. "惰性"的三个正面用途
| 用途 | 好处 |
|---|---|
| 大文件逐行 | 内存恒定 |
无限序列(count/cycle) |
只取所需 |
管道(map/filter 链) |
只算一次 |
第一项的"对比"很直观:readlines() 会把 10GB 装进内存(直接 OOM)——而"逐行读"的内存占用"与文件大小无关".
第一项是"最实用"的:"读 10GB 日志"用生成器,内存只占"一行"**——而 readlines() 会"全部装进内存".
"len() 报错"这件事有两种修法,选错会更糟:
// 两种"求长度"
1 list(it) → len(...) # 物化(大对象慎用,会吃内存)
2 遍历计数(sum(1 for _ in it)) # 不占内存,但"消费掉"
// 判据:还需要"再遍历"就必须选 1;只是"要个数"可以选 2(但要重建)
6. 排错:五种"惰性问题"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 第二轮循环无数据 | 已消费 | 重修/存 list |
len() 报 TypeError |
迭代器无长度 | list() 或计数 |
| 数据"少了几条" | zip 最短停 |
断言长度 |
| 内存悄悄涨 | tee 缓冲 |
改重建 |
| 结果"每次不同" | 生成器有副作用 | 别在生成器里改状态 |
7. 边界:什么情况"就该用迭代器"
| 场景 | 为什么 | 做法 |
|---|---|---|
| 数据量大(GB 级) | 不能全装 | 生成器 |
| 只需"前 N 个" | 不必算完 | 迭代器 + islice |
| 一次性管道处理 | 无二次消费 | map/filter |
| 流式读取(网络/文件) | 天然惰性 | 迭代器 |
判断口径一句话:"这份数据要"遍历几次"。最后一条与"团队"有关的边界:"函数返回生成器还是列表"要写进约定——返回生成器"省内存但只读一次",返回列表"随便用但吃内存";**而"不说明"时,调用方会按"列表"用,于是"第二轮空".
一次(流式处理),迭代器是正解——它省内存;多次(要统计、要循环、要复用),先"物化"(list)或"重算"——而"忘了它只能走一次"是最常见的错.
最后一个"实践"提醒:"惰性"的价值在"数据规模",而"坑"在"使用次数"——同一份代码在"1 万行"时"看不出问题",到"1000 万行"时"既省了内存、又暴露了'只读一次'"的假设。所以"用迭代器"这件事,应当"从设计上就说清":函数名里带 iter_ 前缀、docstring 写"一次性"——于是调用方不会"按列表用". 这个约定比"事后记着"可靠得多,而它只需要"一个命名前缀".
而这个前缀还有"副作用红利":读代码的人一看 iter_ 就知道"要小心",于是"第二轮空"这类 bug 在评审阶段就能被指出来——比"上线后查数据"便宜太多。
同样的道理也适用于"返回 map/filter 的函数":它们是"承诺了惰性"的返回值,而"调用方"未必知道。
而"说清返回的是什么",是接口设计里最便宜的一句话。
而这句话的成本,是一个词;不说的成本,是一次线上排查。
所以"命名"与"docstring",在惰性接口上是"功能的一部分"。
这一点常被当成"代码风格",而它实际影响"bug 率"。
8. 自测
- 三种"可迭代"里,哪一类"不能多次遍历"?为什么"视图"能?
- "被消费完"的三种信号里,哪一种最直观?
- "每次重建"与"
tee分流"的代价差别是什么? zip在最短处停止会造成什么"静默"后果?怎么显式处理?- "读 10GB 日志"为什么该用生成器?
readlines()会怎样? - 判断"用迭代器还是物化"的那个问题是什么?