2124 字
约 7 分钟
18
S10-A25 · `zip` 与惰性迭代:为什么"第二轮循环没数据"

一段代码把 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. 自测

  1. 三种"可迭代"里,哪一类"不能多次遍历"?为什么"视图"能?
  2. "被消费完"的三种信号里,哪一种最直观?
  3. "每次重建"与"tee 分流"的代价差别是什么?
  4. zip 在最短处停止会造成什么"静默"后果?怎么显式处理?
  5. "读 10GB 日志"为什么该用生成器?readlines() 会怎样?
  6. 判断"用迭代器还是物化"的那个问题是什么?
S10-A25 · `zip` 与惰性迭代:为什么"第二轮循环没数据"
https://www.clxhxhhr.top/posts/6684/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。