我的 Python 函数返回的数据,第一次遍历好好的、第二次却空空如也,我对着生成器只能消费一次这个坑排查了大半天的复盘
那是我写的一个数据处理脚本。一个函数返回一批处理过的记录,调用方先遍历一遍统计总数、打印个进度,然后再遍历一遍把每条写进数据库。逻辑顺得不能再顺,我连测都没细测就跑了。结果诡异的事发生了:日志里清清楚楚显示"共 5000 条",可数据库里,一条都没写进去。我盯着代码反复看:遍历两次的代码一模一样,凭什么第一次能数出 5000 条、第二次就一条都没有?我甚至怀疑是数据库连接的问题,查了半天连接好好的。排查了大半天,最后才撞上 Python 那个对新手极其隐蔽的坑:生成器(generator)只能被消费一次。这篇就把这场"数据凭空消失"的事故,从头复盘一遍。
故障现场:数出 5000 条,写入 0 条
先看现场。问题就藏在那个我以为"返回了一批数据"的函数里:
# 我的函数: 我以为它"返回了一批记录"
def get_records():
for line in open("data.txt"):
record = parse(line)
if record.is_valid():
yield record # ← 用了 yield, 这其实是个【生成器函数】!
# 调用方: 遍历两次
records = get_records() # 我以为 records 是"一批数据"(像 list)
# 第一次遍历: 统计总数
count = sum(1 for _ in records) # ← 这一步把生成器【消费光了】!
print(f"共 {count} 条") # 输出: 共 5000 条 ✓ 看起来没问题
# 第二次遍历: 写入数据库
written = 0
for record in records: # ← 此时 records 已经空了! 一条都不剩
db.insert(record)
written += 1
print(f"写入 {written} 条") # 输出: 写入 0 条 ✗ 一条都没写!
# 现象拼图:
# - get_records() 里用了 yield, 所以它是【生成器函数】,
# 调用它返回的不是 list, 而是一个【生成器对象】。
# - 生成器是"惰性"的: 它不预先把所有数据算出来存着,
# 而是"边遍历边生成"、且【只能从头到尾遍历一次】。
# - 第一次 sum(...) 遍历, 把生成器从头走到尾、消费殆尽。
# - 第二次 for, 生成器已经走到尽头了, 没有"重置/回到开头"这回事,
# 所以直接结束、一条都不产出。
# - 我误把"只能用一次的生成器", 当成了"能反复遍历的 list"。
看清真相后,我哭笑不得。问题的根源,是 get_records() 里用了 yield——这让它变成了一个生成器函数,调用它返回的不是 list,而是一个生成器对象。而生成器有一个致命(对不了解的人而言)的特性:它是"惰性"的,边遍历边生成、且只能从头到尾遍历一次。我的第一次 sum(1 for _ in records),把这个生成器从头走到了尾、消费殆尽;等到第二次 for record in records 时,生成器早已走到尽头,没有"重置回开头"这回事,于是直接结束、一条都不产出。我把一个"只能用一次的生成器",当成了"能反复遍历的 list",这才导致了"数出 5000、写入 0"的诡异现象。
第一件事:搞懂生成器到底是什么、为什么只能用一次
要解决它,得先真正理解生成器(generator)的本质,以及它"只能消费一次"背后的原理。
生成器(generator)详解
# 什么是生成器?
# - 函数里只要有 yield, 它就是"生成器函数"。
# - 调用生成器函数, 不会执行函数体, 而是立刻返回一个"生成器对象"。
# - 生成器是一种【迭代器(iterator)】。
# 生成器的核心特性: 惰性 + 一次性
# 1. 惰性(lazy): 不预先算好所有值, 而是"每次要一个, 才算一个"。
# → 省内存! 处理10亿条数据也不会把它们全装进内存。
# 2. 一次性(单向): 只能从头到尾遍历一次, 遍历完就"耗尽"了。
# → 没有"回到开头"、没有"再来一遍"。耗尽后再遍历, 直接是空。
# 为什么只能一次? —— 它没存数据, 只存"状态"
# - list 是"已经算好的全部数据", 存在内存里, 你想遍历几次都行。
# - 生成器只保存"我执行到哪了"这个状态(一个游标), 不保存产出过的值。
# - 一旦游标走到末尾, 就没有数据可产出了, 也无法倒回去。
# 哪些东西也是"一次性"的迭代器?(同样的坑)
# - 生成器表达式: (x for x in data) ← 注意和列表推导 [x...] 区别!
# - map() / filter() / zip() 的返回(Python 3): 都是一次性迭代器!
# - 文件对象 open(...): 遍历一次就到文件末尾了。
# list vs 生成器:
# - [x for x in data] → 列表: 立即算好全部, 占内存, 可反复遍历。
# - (x for x in data) → 生成器: 惰性, 省内存, 只能遍历一次。
# 核心: 生成器是惰性+一次性的迭代器, 只存"执行状态"不存数据, 遍历一次即耗尽,
# 无法重置或重复遍历; map/filter/zip/文件对象在Python3里也都是一次性的。
原来,生成器的"只能用一次",是它本质决定的。函数里只要有 yield,它就是生成器函数;调用它返回一个生成器对象,而生成器是一种迭代器。它有两个核心特性:惰性(不预先算好所有值,每次要一个才算一个,因此极省内存,处理 10 亿条也不爆)和一次性(只能从头到尾遍历一次,遍历完就耗尽,没有"回到开头")。为什么只能一次?关键在于:它没存数据,只存"状态"——list 是"已算好的全部数据"存在内存里、想遍历几次都行;而生成器只保存"我执行到哪了"这个游标,不保存产出过的值,一旦游标走到末尾,就没数据可产出、也无法倒回。更要警惕的是,很多东西同样是"一次性"的迭代器:生成器表达式 (x for x in data)(注意和列表推导 [x...] 的区别!)、map()/filter()/zip() 在 Python 3 里的返回、文件对象——它们都遍历一次就耗尽。
第二件事:正解——按需求选 list 还是生成器
搞懂了原理,正解就清晰了:要多次遍历就 list() 物化;只遍历一次且数据量大就保留生成器;别把一次性迭代器当可复用集合。
# ====== 正解一: 要多次遍历, 就 list() 物化成列表 ======
records = list(get_records()) # ✓ 立刻把生成器跑完, 结果存进 list
count = len(records) # list 可以 len(), O(1)
print(f"共 {count} 条")
for record in records: # ✓ list 可以反复遍历, 第二次依然有数据
db.insert(record)
# → 数据被实实在在装进了内存 list, 想遍历几次都行。
# ====== 正解二: 只遍历一次 + 数据量大, 才保留生成器(发挥惰性优势)======
# 如果数据有10亿条、只需要处理一遍、不想全装进内存:
total = 0
for record in get_records(): # 边生成边处理, 内存友好
db.insert(record)
total += 1 # 用一个计数器在遍历中统计, 而不是遍历两次
print(f"处理 {total} 条")
# → 这种"大数据 + 单次遍历"场景, 生成器才是正解(别 list, 会爆内存)。
# ====== 正解三: 既要省内存、又要统计, 就在一次遍历里把事都做了 ======
count = 0
total_amount = 0
for record in get_records(): # 只遍历这一次
db.insert(record)
count += 1
total_amount += record.amount # 顺便统计, 不用再遍历
print(f"共{count}条, 总额{total_amount}")
# → 把"多次遍历各做一件事", 合并成"一次遍历做所有事"。
# ====== 正解四: 警惕 map/filter/zip 也是一次性的 ======
result = map(transform, data)
print(len(list(result))) # 想 len 得先 list... 但 list 后 result 就空了
for x in result: # ✗ 空的! 上面 list(result) 已耗尽它
...
# ✓ 修法: 一开始就 result = list(map(transform, data)), 之后随便用。
# ====== 正解五: 想"判断生成器是不是 list"避免误用 ======
# 拿不准一个对象能否反复遍历时:
# - list/tuple/dict/set 可反复遍历(它们是"可迭代对象", 每次iter给新迭代器)
# - 生成器/map/filter/zip/文件 是"迭代器自身", 一次性
# 实在不确定, 接收到就 list() 一下(除非确定是大数据要省内存)。
# 核心: 多次遍历/要len → list()物化; 大数据单次遍历 → 保留生成器(省内存);
# 需要统计就在一次遍历里顺便做; 警惕map/filter/zip/文件也是一次性迭代器。
修复的核心,是"根据'要遍历几次'和'数据量多大',在 list 和生成器之间做对选择"。正解一:要多次遍历就 list() 物化——立刻把生成器跑完、结果存进 list,之后 len()、反复 for 都没问题(这就是我这次该用的解法)。正解二:只遍历一次 + 数据量大,才保留生成器——10 亿条只处理一遍时,生成器的惰性才是正解(list 会爆内存)。正解三:既要省内存又要统计,就在一次遍历里把事都做了——把"多次遍历各做一件事"合并成"一次遍历做所有事"(用计数器顺便统计)。正解四:警惕 map/filter/zip 也是一次性的——list(result) 之后 result 就空了,要么一开始就 list(map(...))。正解五:拿不准能否反复遍历时——list/tuple/dict/set 可反复遍历,生成器/map/filter/zip/文件是一次性;不确定就接收后 list() 一下(除非确定是大数据)。归根结底:多次遍历/要 len 就 list() 物化;大数据单次遍历保留生成器;需要统计就在一次遍历里顺便做;警惕 map/filter/zip/文件也是一次性。
第三件事:可迭代对象 vs 迭代器,这对概念别再混
排查时我才真正分清了两个一直含混的概念:可迭代对象(Iterable)和迭代器(Iterator)。它们的区别正是这个坑的根。
可迭代对象(Iterable) vs 迭代器(Iterator)
# 可迭代对象(Iterable):
# - 能被 for 遍历的东西。实现了 __iter__(), 每次调用返回一个【新的迭代器】。
# - 例: list, tuple, dict, set, str ... 它们本身不是迭代器!
# - 关键: 每次 for 它, 都会 __iter__() 拿一个【全新的迭代器】从头开始,
# 所以可以反复遍历。
# 迭代器(Iterator):
# - 实现了 __next__()(和返回自身的 __iter__())。
# - 它保存"遍历的当前状态", 每次 next() 吐一个值, 直到 StopIteration。
# - 例: 生成器, map/filter/zip 对象, 文件对象, iter(list)的结果。
# - 关键: 它【就是那个游标本身】, 遍历完就到头了, 没有"新的从头开始"。
# 一张图理解为什么 list 能反复遍历、生成器不能:
# list --(每次for, __iter__)--> 新迭代器A(从头) ... 用完丢弃
# --(再次for, __iter__)--> 新迭代器B(从头) ... 又能遍历
# 生成器 它本身就是迭代器, for它 = 用它自己这个游标,
# 用完了, 再for = 游标已在末尾 = 空。
# 验证小实验:
nums = [1, 2, 3]
print(iter(nums) is iter(nums)) # False: list每次给"新"迭代器
gen = (x for x in nums)
print(iter(gen) is iter(gen)) # True: 生成器iter返回的是它自己!
# → 这就是为什么 list 能反复遍历, 生成器只能一次。
# 核心: 可迭代对象(list等)每次for都生成新迭代器, 故可反复遍历;
# 迭代器(生成器/map/filter/文件)本身就是游标, 一次性, 用完即尽。
这一对概念分清后,所有的困惑都烟消云散了。可迭代对象(Iterable):能被 for 遍历、实现了 __iter__(),每次调用都返回一个全新的迭代器(list、tuple、dict、set、str 都是);正因每次 for 都拿一个全新迭代器从头开始,所以能反复遍历。迭代器(Iterator):实现了 __next__()、保存遍历的当前状态(生成器、map/filter/zip、文件对象都是);它就是那个游标本身,遍历完就到头了,没有"新的从头开始"。一个小实验一秒看懂:iter(list) is iter(list) 是 False(list 每次给"新"迭代器),而 iter(gen) is iter(gen) 是 True(生成器的 iter 返回它自己)——这就是 list 能反复遍历、生成器只能一次的根本原因。归根结底:可迭代对象每次 for 都生成新迭代器故可反复遍历;迭代器本身就是游标,一次性、用完即尽。下面这张图,是这次生成器耗尽导致数据消失的成因与解法:
第四件事:常见"一次性迭代器"速查
这次踩坑后,我把 Python 里那些"看着像集合、其实只能用一次"的东西整理成一张表,贴在心里防误用。
| 对象 | 能反复遍历吗 | 类型 | 想复用怎么办 |
|---|---|---|---|
| list / tuple / dict / set | ✓ 可反复 | 可迭代对象 | 本身就能反复用 |
| 生成器函数(含yield)的返回 | ✗ 一次性 | 迭代器 | list() 物化 |
| 生成器表达式 (x for x in ...) | ✗ 一次性 | 迭代器 | 改用 [x for x in ...] |
| map() / filter() | ✗ 一次性(Py3) | 迭代器 | list(map(...)) |
| zip() / enumerate() | ✗ 一次性 | 迭代器 | list(zip(...)) |
| 文件对象 open() | ✗ 一次到文件尾 | 迭代器 | readlines() 或 seek(0) |
| reversed() | ✗ 一次性 | 迭代器 | list(reversed(...)) |
这张表,把"哪些能反复用、哪些只能用一次"一网打尽了。记忆诀窍:用方括号 [] 或本来就是容器的(list/dict/set),能反复遍历;凡是"惰性生成"的(yield、圆括号生成器表达式、map/filter/zip/enumerate/reversed、文件),都是一次性。它给我的启发是:Python 为了"省内存、高效率",在很多地方默默地用了"惰性迭代器"替代"立即求值的列表"(比如 Python 3 把 map/filter/range/dict.keys() 都改成了惰性的)。这个设计很聪明、很高效,但它对使用者有一个隐含的要求:你得知道"你手里这个东西,到底是'已经算好的数据'还是'一个待消费的惰性流'"。一旦把"惰性流"误当成"数据",就会踩这次的坑。所以我现在的习惯是:拿到一个"序列样"的东西,先在心里问一句"它能遍历第二次吗";不确定、且数据不大,就 list() 一下落袋为安。
第五件事:生成器的好处,以及它适合的场景
这次虽然被生成器坑了,但它绝不是"坏东西"。我也梳理了它真正发光的场景,免得因噎废食。
| 场景 | 用 list | 用生成器 |
|---|---|---|
| 处理超大/无限数据流 | ✗ 内存爆炸 | ✓ 惰性,恒定内存 |
| 只需遍历一次 | △ 多占内存 | ✓ 省内存,最合适 |
| 需要多次遍历 | ✓ 可反复用 | ✗ 第二次就空 |
| 需要随机访问/索引/len | ✓ 支持 | ✗ 不支持 |
| 流水线处理(链式转换) | 每步都生成中间list | ✓ 惰性串联,不产中间数据 |
| 提前结束(找到就停) | △ 可能已算全部 | ✓ 惰性,用多少算多少 |
这张表,让我看清了生成器真正的价值。它的高光场景是:处理超大/无限数据流(恒定内存)、只需遍历一次、流水线式链式处理(惰性串联不产中间 list)、提前结束的查找(用多少算多少)。而它不适合的,正是我这次的场景:需要多次遍历、需要随机访问/索引/len——这些就该用 list。它给我的最大启发是:生成器(惰性求值)和 list(立即求值),是一对经典的"空间与灵活性"的权衡:生成器用"只能遍历一次、不能随机访问"的限制,换来了"极省内存、可处理无限流"的能力;list 用"占用全部内存"的代价,换来了"可反复遍历、随机访问"的灵活。没有谁更好,只有"匹配场景"才最好。更深一层,这让我体会到:理解一个特性,不能只记"它怎么用",更要理解"它用什么换什么"(它的设计权衡);因为正是那个"换"(为了省内存而牺牲了可重复遍历),藏着它最容易坑人的地方,也藏着它最适合的舞台。看懂了权衡,才能既用对它的长处、又躲开它的陷阱。
第六件事:拿到一个"序列"时,我现在的判断习惯
现在再拿到一个要遍历的东西,我不再想当然地当 list 用,而是按这张图先判断它的本质:
这张图的精髓,是"先判断它是'一次性迭代器'还是'可反复容器',再决定怎么用"。第一问是 "它从哪来":含 yield 的函数、生成器表达式、map/filter/zip/文件,都是一次性迭代器;list/dict/set 字面量是可反复遍历的容器。如果是一次性的,再问 "要遍历几次":多次/要 len/要索引就 list() 物化;只一次再看数据量,大就保留生成器(顺便在遍历里统计)、不大就 list() 更省心。而最后那一步,是我现在的硬习惯:遍历前确认"我要用的数据真的还在"(尤其当这个对象在别处可能已经被遍历过)。这套习惯,让我处理序列时,从"想当然当 list 用"变成了"先看清它的本质"——核心始终是:遍历一个东西前,先搞清它是"已经算好的数据"还是"只能消费一次的惰性流"。
我立下的几条规矩
这场"数据凭空消失"的事故,换来了我写 Python 时,刻进骨子里的几条铁律:
- 含 yield 的函数返回的是生成器,不是 list。它惰性、只能遍历一次。
- 要多次遍历就 list() 物化。别拿一次性迭代器当可复用集合用。
- 大数据/无限流只遍历一次才保留生成器。发挥惰性省内存的优势,别盲目 list 爆内存。
- 需要统计就在一次遍历里顺便做。别为了数个数再单独遍历一遍(把它耗尽)。
- 警惕 map/filter/zip/文件/reversed 都是一次性的。Python 3 默认惰性。
- 分清可迭代对象与迭代器。前者每次 for 给新迭代器可反复,后者本身是游标用完即尽。
- 拿不准就 list() 落袋为安。除非确定是大数据要省内存。
附:亲手验证"生成器耗尽"的一组实验
口说无凭。下面这段代码,让你亲眼看见生成器第二次遍历为空、以及 list 化后就正常,跑一遍胜过看十遍解释:
# ====== 实验1: 生成器第二次遍历是空的 ======
def gen():
for i in range(3):
yield i
g = gen()
print("第一次遍历:", list(g)) # [0, 1, 2]
print("第二次遍历:", list(g)) # [] ← 空了! 生成器已耗尽
print()
# ====== 实验2: 改用 list, 就能反复遍历 ======
data = list(gen()) # 立刻物化成 [0, 1, 2]
print("第一次:", list(data)) # [0, 1, 2]
print("第二次:", list(data)) # [0, 1, 2] ← 依然有! list 可反复遍历
print()
# ====== 实验3: 复现我的事故 —— 先统计再处理 ======
def get_records():
for i in range(5):
yield f"record-{i}"
records = get_records()
count = sum(1 for _ in records) # 把生成器消费光
print(f"统计到 {count} 条") # 统计到 5 条
written = [r for r in records] # 此时已空
print(f"处理到 {len(written)} 条") # 处理到 0 条 ← 复现"数5写0"!
print()
# ====== 实验4: 正确做法 —— 先 list 物化 ======
records = list(get_records()) # ✓ 先物化
count = len(records)
written = [r for r in records] # 还在!
print(f"统计 {count} 条, 处理 {len(written)} 条") # 统计 5 条, 处理 5 条 ✓
print()
# ====== 实验5: map 也是一次性的(同样的坑)======
m = map(str, range(3))
print("map第一次:", list(m)) # ['0', '1', '2']
print("map第二次:", list(m)) # [] ← map 也耗尽了!
# ====== 实验6: 用 itertools.tee 复制迭代器(需要两次遍历但又想保持惰性)======
import itertools
g1, g2 = itertools.tee(gen(), 2) # 把一个生成器"复制"成两个独立的
print("tee g1:", list(g1)) # [0, 1, 2]
print("tee g2:", list(g2)) # [0, 1, 2] ← 各自独立可遍历
# 核心: 生成器/map第二次遍历为空(已耗尽); list物化后可反复遍历;
# 既要惰性又要遍历两次, 用 itertools.tee 复制。亲手跑一遍, 印象深刻。
这组实验,把"生成器只能用一次"这个抽象的坑,变成了可以亲手运行、亲眼验证的现象。实验 1 直接展示了核心现象:同一个生成器,第一次 list(g) 是 [0,1,2],第二次就成了 [];实验 3 一比一复现了我的事故("统计 5 条、处理 0 条");实验 4 证明 先 list() 物化就能既统计又处理;实验 5 提醒 map 同样是一次性的;实验 6 还给了个进阶招——itertools.tee 能把一个迭代器"复制"成多个独立的,适合"既想保持惰性、又要遍历两次"的场景。这,正是我想用这组实验,留给每个学 Python 的人的最后一课:对于任何"反直觉、难记住、容易栽跟头"的语言特性,与其反复背诵规则,不如动手写一组最小实验,把它的行为亲眼"跑"出来。当我亲眼看着控制台打印出那个刺眼的 [] 时,"生成器只能用一次"这件事,就再也不会从我脑子里溜走了——因为亲手验证过的知识,和读到过的知识,在记忆里的分量完全不同:前者是"我见过它发生",后者只是"我听说过"。把抽象的规则,变成具体的、可复现的实验现象——这是我对抗"那些总也记不住的坑"最有效的办法,也是把别人的经验真正变成自己的本能,最扎实的一步。
写在最后
回头看,这场由"生成器只能消费一次"引发的、数据凭空消失的事故,真正教给我的,远不止"记得 list()"这一个技巧。它让我对一类极其普遍的认知陷阱,有了警觉:很多东西,"看起来像"某种我们熟悉的东西,用起来在某些情况下也确实像,于是我们就想当然地以为它"就是"那种东西,把对那种东西的全部假设,都套了上去。我手里的生成器,"看起来"就像一批数据:我能 for 它、能 sum 它,和用 list 一模一样;于是我理所当然地以为它"就是"一批数据,能反复遍历——直到第二次遍历的空结果,狠狠地提醒我:它"像" list,但它"不是" list;它在"能否重复遍历"这个我没留意的维度上,和 list 有着本质的不同。这让我领悟到一个朴素却深刻的道理:判断一个事物,不能只凭它"表面上像什么""在我试过的场景里表现得像什么",而要去理解它"本质上是什么""它和我以为的那个东西,在哪些维度上其实不同"。"鸭子类型"(看起来像鸭子就当鸭子)在很多时候是便利的,但它的危险,恰恰在于会让我们忽略那些"表面相似、本质不同"的关键差异;而 bug,往往就诞生在这些被忽略的差异里。对任何"我以为我懂"的东西,多问一句"它和我以为的,真的完全一样吗?有没有哪个维度其实不同?"——这份不轻易"想当然"的审慎,是写出可靠代码的底层素养。这,是我用一次"数出 5000、写入 0"的事故,换来的、关于 Python、也关于"看穿表面相似"的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次拿到一个要遍历两次的东西时,先愣一下问问"它能遍历第二次吗",那我对着那个空荡荡的第二次循环熬的这大半天,就值了。
—— 别看了 · 2026