Python 迭代器被预检查消耗:any()、重复遍历与 tee() 的缓存边界
一、预检查也可能读取数据
把一个生成器交给函数之前,先用 any() 看看有没有满足条件的记录,再遍历它处理数据,看起来只是多做了一次判断。但对同一个迭代器来说,这次判断本身已经取走了一部分元素,后面的循环会从剩余位置继续。
列表保存一组元素,每次遍历通常会建立新的列表迭代器。生成器对象自己就是迭代器,iter(g) is g;再次写 for,不会让它从函数开头重新执行。一个符合迭代器协议的对象在耗尽后应持续报告耗尽,重新调用 iter() 也不等于重置。
这里的关键是对象及其当前消费位置,而不是变量名叫 rows 还是 data。生成器函数可以再次调用来创建新对象,已经创建的那个生成器不会因此恢复。下面只用有限整数和内存列表观察行为,不读取文件或操作外部服务。
二、any() 停在命中项之后
seen = []
def source():
for value in range(5):
seen.append(value)
yield value
rows = source()
found = any(value >= 2 for value in rows)
assert found is True
assert seen == [0, 1, 2]
assert list(rows) == [3, 4]
assert list(rows) == []
print("预检查消费位置验证通过")
any() 为了判断条件,依次取出了 0、1、2。命中 2 时返回真,所以不会继续读取 3、4;但命中项也已经离开源迭代器,下一轮不会再次拿到它。短路意味着提前停止,不能把它理解成完全没有消费。
如果将条件改为 value < 0,在这个有限且正常完成的输入上,函数必须检查全部元素后才能返回假,后面就没有剩余元素。若迭代或条件计算抛出异常,调用可能提前失败;若输入无限且始终不命中,也可能一直不返回。这些情况不能按“返回假便已读完”的普通有限流程套用。
any(rows) 还不能用来判断是否非空。非空输入 [0, False, None, ""] 的每个元素都为假,结果仍是假。另一方面,普通生成器对象即使已经耗尽,bool(g) 仍为真;它判断的是对象真值,并不试着取下一项。需要先确定业务要问“有没有元素”,还是“有没有满足条件的元素”。
三、先决定是否允许重新读取
输入有限、规模可控,而且确实需要多轮处理时,可以在任何预检查之前执行 rows = list(source()),让后续检查和处理都遍历这份列表。代价是提前读取全部输入并持有元素引用;无限输入不能直接完整列表化,大输入也需要评估容量。若先消费再转列表,保留下来的仍只是余下部分。
若源可以安全地重新创建,可以约定函数接收一个工厂:检查时调用一次工厂,处理时再调用一次。它会重新执行读取和计算,不是免费回放;数据源变化、随机性或副作用都可能让两轮结果不同。示例中可重复的整数生成器不能替真实外部数据源作保证。
如果需求只是顺序处理并顺便统计,通常可以把判断和处理放在同一轮循环中。若需求是“发现任一不合格项时,整批都不能处理”,一边处理一边验证可能已经产生部分结果,此时应明确采用缓冲、可重读输入或其他业务机制,而不是仅把两段循环合并。
四、tee() 保留多个位置,也需要缓存
itertools.tee() 可以从同一输入建立多个迭代分支。下面通过记录源被取走的值,观察一个分支落后时另一个分支做了什么。
from itertools import tee
pulled = []
def source():
for value in range(4):
pulled.append(value)
yield value
fast, slow = tee(source())
assert pulled == []
assert next(fast) == 0
assert next(fast) == 1
assert pulled == [0, 1]
assert next(slow) == 0
assert pulled == [0, 1]
assert list(slow) == [1, 2, 3]
assert pulled == [0, 1, 2, 3]
assert list(fast) == [2, 3]
assert pulled == [0, 1, 2, 3]
print("tee 分支与源读取验证通过")
创建分支时,这个生成器还没有产出元素。快分支先取两项后,慢分支取 0 没有再次触发源,说明它拿到了为落后分支保留的值。慢分支随后读完,快分支仍能取得自己未读的 2、3。这里记录的是源推进次数,没有测量内部缓存的字节数。
当快分支领先而慢分支仍可能读取旧值时,tee() 需要保留相应数据。领先距离和元素大小都会影响存储需求,不能因为接口返回的是迭代器,就推断它始终只占少量固定内存。若一个分支先读完大部分甚至全部数据,另一个才开始,往往更适合直接用列表明确保存;Python 文档也给出了这一使用建议。
因此,先让检查分支用 any() 搜索,再让处理分支从头开始,能够保留数据,却不能保证节省内存。命中很靠后或根本不命中时,处理分支尚未消费,缓存可能已经积累到接近整批输入。
五、分支独立不等于元素被复制
tee() 从创建分支时输入的当前位置开始,无法找回此前已消费的前缀。分流后应只消费返回的分支,不再调用原输入迭代器的 next();否则源会在分支不知情时前进,造成缺项。
它也不为每个分支深拷贝元素。如果源产生一个字典,两个分支会取得同一字典对象;一个分支修改它,另一个可能看到修改后的内容。需要隔离修改时,应由应用按数据结构和成本选择复制策略。共享同一次 tee() 的分支也不保证线程安全,不能直接当作并发队列使用。
需要检查空输入且保留首项时,可以用独立哨兵对象接收 next(iterator, sentinel),命中哨兵便是已耗尽;否则用 itertools.chain((first,), iterator) 把首项接回,再只消费返回的新迭代器。这只保留一个首项,但仍会实际推进源一次;也不能解决整批预验证与多轮回放的需求。
六、让消费规则成为接口的一部分
接收输入的函数应说明它是否会推进迭代器、是否需要多轮遍历,以及是否会保留元素。调用方则要检查预检查、调试输出、统计和后续处理是否共享同一个对象。排查时记录源实际产出的元素,比仅观察循环次数更容易找到第一次消费发生的位置。
本文两个代码块已在独立 Python 3.9.6 进程运行,另有十二项标准库断言检查对象身份、短路位置、耗尽、列表化、工厂重建、分支复用、落后读取及可变元素共享等行为。全部输入有限且只存在于内存;这些实验没有测量大数据吞吐或精确缓存占用,也未覆盖并发消费与外部数据源。
参考:Python 官方文档 Built-in Functions 的 any()、iter()、next();Built-in Types 的 Iterator Types 与 Truth Value Testing;Glossary 的 generator iterator、iterable、iterator;itertools.tee() 的使用约束。
- 点赞
- 收藏
- 关注作者
评论(0)