Python 的 itertools 模块有哪些常用工具?为什么说它省内存?
简化版
**itertools 是标准库里的「迭代器工具箱」——它提供的每个函数返回的都是「惰性迭代器」(用一个吐一个、不预先把结果都算出来放进列表),所以处理大数据或无限序列时内存是 O(1) 而不是 O(n)。**常用的三类:① 无限迭代器:count(start, step)(无限计数)、cycle(iterable)(无限循环)、repeat(obj, n)(重复),必须配 islice 或 takewhile 之类截断,否则死循环;② 组合/切分现有序列:chain(把多个序列首尾拼成一个,不复制数据)、islice(对迭代器切片)、takewhile / dropwhile(按条件截取/跳过)、compress(按布尔掩码过滤)、accumulate(累计求和/累计最值)、groupby(相邻相同键分组)、pairwise(3.10+,相邻两两配对);③ 组合学:product(笛卡尔积,替代多层嵌套 for)、permutations(排列)、combinations(组合)。最大的坑:groupby 只按「相邻」分组,没排序就直接 groupby 会把同一个键切成好几段;其次是迭代器「一次性」——遍历完就空了,想复用得 tee 或转成列表。核心记忆:itertools = 惰性迭代器工具箱,省内存靠「用一个算一个」,groupby 必须先排序。
详细版
常用工具速查:
| 分类 | 函数 | 作用 | 例子 |
|---|---|---|---|
| 无限 | count(10, 2) | 10,12,14,… | 配 islice 截断 |
| 无限 | cycle('AB') | A,B,A,B,… | 轮询/循环分配 |
| 无限 | repeat(0, 3) | 0,0,0 | 补位、配 map |
| 连接 | chain(a, b) | 首尾拼接、不复制 | 遍历多个列表 |
| 连接 | chain.from_iterable(ll) | 展开二维 | 打平嵌套列表 |
| 切分 | islice(it, 2, 10, 2) | 迭代器切片 | 大文件取第 N 行 |
| 条件 | takewhile / dropwhile | 满足条件时取/丢 | 已排序数据提前停 |
| 条件 | compress(data, mask) | 按布尔掩码选 | 掩码过滤 |
| 累计 | accumulate(xs, func) | 前缀和/前缀最值 | 累计营收 |
| 分组 | groupby(xs, key) | 相邻同键分组 | 必须先排序 |
| 配对 | pairwise(xs)(3.10+) | (x0,x1),(x1,x2)… | 求差分 |
| 组合学 | product / permutations / combinations | 笛卡尔积/排列/组合 | 参数网格、抽样 |
from itertools import (count, cycle, islice, chain, accumulate,
groupby, takewhile, product, combinations, tee)
# ① 无限迭代器必须截断
print(list(islice(count(10, 2), 5))) # [10, 12, 14, 16, 18]
print(list(islice(cycle('AB'), 5))) # ['A','B','A','B','A']
# ② chain 不复制数据,只是"接着遍历下一个"
a, b = [1, 2], [3, 4]
print(list(chain(a, b))) # [1, 2, 3, 4](没有新建大列表存中间结果)
# ③ accumulate 前缀和 / 前缀最大值
print(list(accumulate([3, 1, 4, 1, 5]))) # [3, 4, 8, 9, 14]
print(list(accumulate([3, 1, 4, 1, 5], max))) # [3, 3, 4, 4, 5]
# ④ groupby 的头号坑:只分"相邻"
data = [('a', 1), ('b', 2), ('a', 3)]
print({k: [v for _, v in g] for k, g in groupby(data, key=lambda x: x[0])})
# {'a': [3], 'b': [2]} ← 'a' 被切成两段,后一段覆盖了前一段!
data.sort(key=lambda x: x[0]) # 必须先按同一个 key 排序
print({k: [v for _, v in g] for k, g in groupby(data, key=lambda x: x[0])})
# {'a': [1, 3], 'b': [2]} ← 正确
# ⑤ takewhile 在已排序数据上提前停(比 filter 扫全表快)
print(list(takewhile(lambda x: x < 4, [1, 2, 3, 9, 1]))) # [1, 2, 3](遇到 9 就停)
# ⑥ product 替代多层嵌套 for(做参数网格)
print(list(product([0.1, 0.01], ['adam', 'sgd'])))
# [(0.1,'adam'), (0.1,'sgd'), (0.01,'adam'), (0.01,'sgd')]
print(list(combinations('ABC', 2))) # [('A','B'), ('A','C'), ('B','C')]
# ⑦ 迭代器是一次性的
g = (x for x in range(3))
print(list(g), list(g)) # [0, 1, 2] [] ← 第二次是空的
⚠️ 两个必须记住的机制:① 惰性 = 省内存但「一次性」。
chain、islice、accumulate返回的都是迭代器,不是列表——它们只在你for的时候一个一个吐值,中间不落地,所以处理 10GB 日志内存也不涨;代价是遍历一次就耗尽,第二次遍历得到空,也不能len()和索引。想反复用要么list(...)落地,要么tee复制。②groupby是「压缩相邻重复」而不是「SQL 的 GROUP BY」。它内部只记住「当前键」,键一变就开新组,所以同一个键分散在序列各处时会被切成多段。正确用法永远是xs.sort(key=f)之后再groupby(xs, key=f)(两个 key 必须是同一个);如果只是想做 SQL 式聚合、且不在乎顺序,用collections.defaultdict(list)一次遍历更直观也更不容易错。另外groupby吐出的分组g也是共享底层迭代器的惰性对象,一旦推进到下一组,上一组的g就失效了——所以要在组内立刻list(g),别把g攒起来事后再用。
完整版教学
一、为什么要有 itertools:惰性 vs 落地
同一件事的两种写法(读 100 万行日志、取前 10 条 ERROR):
写法 A(落地):
lines = f.readlines() # 100 万行全进内存 → 几百 MB
errors = [l for l in lines if 'ERROR' in l] # 又建一个大列表
top10 = errors[:10] # 只要 10 条,却算了全部
峰值内存 ≈ 原始数据 + 中间列表
写法 B(惰性,itertools 思路):
errors = (l for l in f if 'ERROR' in l) # 生成器,不算
top10 = list(islice(errors, 10)) # 拿够 10 条就停
峰值内存 ≈ 一行的大小(O(1))
实际扫描 ≈ 扫到第 10 条 ERROR 为止,不是全表
核心差别:
落地:先把所有中间结果算出来放内存 → 内存 O(n)、计算 O(n)
惰性:需要一个才算一个、随时可中断 → 内存 O(1)、计算"按需"
itertools 的每个函数都遵守两条规矩:
① 接受"可迭代对象"(列表、生成器、文件、另一个 itertools 结果)
② 返回"迭代器"(不是列表)→ 可以任意串联、串联不落地
→ 所以能拼成流水线:chain → filter → islice → map,全程只走一遍数据
itertools 的设计动机就一句话:把「先把中间结果全算出来放进列表」变成「需要一个才算一个」。上面的算例里,写法 A 为了拿 10 条 ERROR,付出了「100 万行全进内存 + 建两个大列表 + 遍历全表」的代价;写法 B 的峰值内存只有一行的大小,而且扫到第 10 条就停了。这个差别在数据量小时看不出来,数据量一大就是「能跑」和「OOM」的区别。更关键的是可组合性:因为每个函数都是「吃迭代器、吐迭代器」,你可以把 chain → 生成器过滤 → islice → map 串成一条流水线,数据从头到尾只走一遍、中间一个临时列表都不产生——这就是 Python 版的「流式处理」。
二、无限迭代器:count / cycle / repeat
count(start=0, step=1) → start, start+step, start+2*step, ... 永不停止
cycle(iterable) → 把序列无限循环(内部会缓存一份原序列!)
repeat(obj, times=None) → 重复 obj,times 省略就是无限
典型用法(都必须有"刹车"):
islice(count(1), 5) → 1,2,3,4,5 (按个数刹车)
takewhile(lambda x: x < 20, count(1, 3)) → 1,4,7,10,13,16 (按条件刹车)
zip(count(1), ['a','b','c']) → (1,'a'),(2,'b'),(3,'c') (被短的一方刹车)
cycle(['worker1','worker2','worker3']) → 轮询分配任务
map(pow, range(5), repeat(2)) → 0,1,4,9,16 (repeat 当"常量参数列")
两个要注意的地方:
① cycle 会在内部保存一份原序列的副本 → 原序列很大时它并不省内存
cycle(一个 100 万元素的生成器) → 内存里躺着 100 万个元素
② 忘记刹车 = 死循环 + 内存爆炸
list(count(1)) # ✗ 程序卡死,内存持续增长直到 MemoryError
for i in count(): # ✗ 循环体里必须有 break
无限迭代器解决的是「我不知道要多少个,边生成边判断」的场景:分页拉接口时用 count(1) 生成页码、直到某页返回空才 break;轮询多个下游节点用 cycle(nodes);给 map 补一列常量参数用 repeat(2)。它们本身不占内存(count 只存当前值和步长),但必须有刹车——islice 按个数刹、takewhile 按条件刹、zip 靠另一边的短序列刹。唯一的例外是 cycle:它为了能「循环第二遍」,必须在内部缓存一份原序列,所以对超大序列用 cycle 并不省内存,这点常被忽略。
三、流水线工具:chain / islice / takewhile / compress / accumulate
chain(a, b, c) 首尾相接(不复制、不合并成新列表)
chain.from_iterable(lol) 展开一层嵌套:[[1,2],[3]] → 1,2,3
islice(it, stop) / islice(it, start, stop, step)
迭代器的"切片",但 ★不支持负数索引★(因为不能回头)
takewhile(pred, it) 从头取,第一次 pred 为假就停(后面全不要)
dropwhile(pred, it) 从头丢,第一次 pred 为假开始全要
compress(data, selectors) 按布尔掩码挑:compress('ABCD',[1,0,1,0]) → A,C
accumulate(it, func=add) 前缀累计:不断把"上一个结果"和"当前值"喂给 func
takewhile vs filter 的关键区别(在已排序数据上差别巨大):
数据 = [1, 2, 3, 100, 4, 5](假设已按时间排序,要"小于 10 的前缀")
filter(lambda x: x<10, 数据) → 1,2,3,4,5 扫完全部 6 个元素
takewhile(lambda x: x<10, 数据) → 1,2,3 扫到 100 就停(只看 4 个)
→ 在 1000 万行、只需要开头一小段时,takewhile 是"提前退出",filter 是"全表扫描"
accumulate 的算例:
accumulate([3, 1, 4, 1, 5]) → 3, 4, 8, 9, 14 (前缀和)
accumulate([3, 1, 4, 1, 5], max) → 3, 3, 4, 4, 5 (前缀最大值)
accumulate([1,2,3], func=lambda a,b: a*b) → 1, 2, 6 (前缀积)
accumulate([1,2,3], initial=100) → 100, 101, 103, 106 (3.8+ 指定初值)
这一组是「流水线」的主力。chain 常被误以为会合并出一个新列表,其实它只是记住「当前在第几个序列的第几个位置」,遍历完 a 就接着遍历 b,所以拼接 10 个百万级列表也是 O(1) 内存。islice 是迭代器版的切片,注意它不支持负数索引——迭代器不能回头,islice(it, -1) 直接报错,想取最后 N 个得用 collections.deque(it, maxlen=N)。takewhile 和 filter 的区别值得单独记:filter 会扫完全部元素,takewhile 遇到第一个不满足就停,在「已排序、只要前缀」的场景(按时间排序的日志取今天之前的部分)能把 O(n) 变成 O(命中数)。accumulate 则是把「循环里维护一个累计变量」这个模式一行化,换个 func 就从前缀和变成前缀最值、前缀积。
四、groupby:为什么必须先排序
groupby 的真实语义 = "压缩连续重复段"(像 Unix 的 uniq -c),不是 SQL 的 GROUP BY
内部逻辑(伪代码):
当前键 = 哨兵
for 元素 in 序列:
k = key(元素)
if k != 当前键: ← 只和"上一个"比,不查历史
开一个新组
当前键 = k
把元素放进当前组
算例(销售记录按城市分组):
data = [('北京',10), ('上海',20), ('北京',30)]
✗ 直接 groupby:
北京 → [10] 第 1 条
上海 → [20] 第 2 条(键变了,开新组)
北京 → [30] 第 3 条(键又变了,★又开一个新组★)
结果被切成 3 组;如果你用 dict 收集,后面的 '北京' 还会覆盖前面的 → 得到 {'北京':[30],'上海':[20]},数据丢了
✓ 先排序(用同一个 key):
data.sort(key=lambda x: x[0])
→ [('上海',20), ('北京',10), ('北京',30)]
上海 → [20]
北京 → [10, 30] ✓ 正确
第二个坑:分组对象 g 是"共享底层迭代器"的惰性视图
groups = [(k, g) for k, g in groupby(data, key=f)] # ✗ 存起来事后用
→ 循环推进到下一组时,上一组的 g 已经被"消费掉"了,事后 list(g) 得到空
✓ 组内立刻落地:[(k, list(g)) for k, g in groupby(data, key=f)]
什么时候该用 groupby、什么时候用 defaultdict:
已排序 / 想按"连续段"切(如按状态切时间段、压缩连续重复)→ groupby(O(n)、不用额外内存)
只想按键聚合、数据没排序 → defaultdict(list) 一遍搞定(O(n),比"排序 O(n log n) + groupby"更快也更不容易错)
groupby 是 itertools 里最容易写出 bug 的函数,因为名字让人想到 SQL 的 GROUP BY,实际语义却是 Unix 的 uniq——它只和「上一个元素的键」比较,键一变就开新组,从不回头查这个键之前出没出现过。上面的算例里,三条记录被切成了三组,用字典收集时后面的「北京」还会覆盖前面的,数据静悄悄地丢了,且不报错。所以铁律是:先 sort(key=f),再 groupby(xs, key=f),两个 f 必须是同一个。第二个坑是分组对象 g 的惰性:它和外层共享同一个底层迭代器,外层一往前走、内层就失效,因此必须在组内立刻 list(g)。反过来说,如果你的需求本来就是「按连续段切分」(把日志按状态切成若干时间段、把连续重复值压缩),那 groupby 正是最合适的工具,而且不需要排序。
五、组合学工具:product / permutations / combinations
product(A, B) 笛卡尔积,等价于两层嵌套 for(元素可来自同一位置重复)
product(A, repeat=3) A×A×A,等价于三层嵌套 for
permutations(A, r) 排列:顺序敏感,(A,B) 和 (B,A) 算两个
combinations(A, r) 组合:顺序无关,只保留 (A,B)
combinations_with_replacement(A, r) 组合且允许重复取同一个元素
数量对照(记住这个表,能防"跑不完的循环"):
n=5, r=3 时
┌───────────────────────────┬──────────────┬──────┐
│ 函数 │ 公式 │ 个数 │
├───────────────────────────┼──────────────┼──────┤
│ product(A, repeat=3) │ n^r = 5³ │ 125 │
│ permutations(A, 3) │ n!/(n-r)! │ 60 │
│ combinations(A, 3) │ C(n,r) │ 10 │
│ combinations_with_repl. │ C(n+r-1, r) │ 35 │
└───────────────────────────┴──────────────┴──────┘
规模爆炸的现实感:permutations(range(12)) 有 4.79 亿个 → 别 list() 它
惰性帮你的是"内存",帮不了你"时间":迭代器不会替你减少组合总数
实用例子:超参数网格搜索(替代三层嵌套 for)
for lr, bs, opt in product([1e-2, 1e-3], [16, 32], ['adam', 'sgd']):
train(lr, bs, opt) # 2×2×2 = 8 组,一层循环写完
★ product 的一个隐藏行为:它会先把每个输入"物化"成元组
(因为要反复重来一遍),所以传生成器给它并不省内存
这三个函数把「多层嵌套 for」和「手写递归枚举」压成一行。选择很简单:看顺序算不算数、能不能重复取——顺序算数用 permutations,不算数用 combinations,跨多个集合取一个用 product。上表要记住,因为组合数增长极快:permutations(range(12)) 有 4.79 亿项,即使是惰性的也要跑到天荒地老——惰性省的是内存,不是时间,这是新手最常见的误解。另外 product 为了能反复遍历后面的维度,会先把每个输入参数物化成元组,所以给它传生成器并不能省内存,这点和 chain、islice 不同。
六、tee 的陷阱与「一次性」的代价
迭代器是一次性的:
it = iter([1,2,3])
list(it) → [1,2,3]
list(it) → [] ← 已耗尽
想遍历两遍怎么办?
① 数据不大 → 直接 list(...) 落地,最简单
② 数据大、两个消费者"步调一致" → tee
③ 数据是文件 → f.seek(0) 重新读
tee(it, 2) 的内存陷阱:
a, b = tee(it, 2)
tee 内部维护一个"共享缓冲区":先走的那个读过的元素,
必须留着给后走的那个 → 两者进度差多少,缓冲区就存多少
list(a) # a 一口气读完 100 万条
for x in b: ... # 此时缓冲区里已经攒了 100 万条!
→ 内存和"全部 list 出来"一样,没省到
正确姿势:两个消费者交替前进(进度差小),缓冲区才小
★ 还有一条铁律:tee 之后就不要再碰原来的 it 了(会让两个分支都丢数据)
其他"一次性"的连带限制:
len(迭代器) ✗ TypeError(不知道长度)
迭代器[0] ✗ TypeError(不支持索引,用 next() 或 islice)
if 迭代器: ⚠ 永远为真(空迭代器也是真值!判空要用 next(it, 哨兵))
惰性的代价就是「一次性」,由此衍生出一串限制:不能 len()、不能索引、不能用 if it: 判空(空迭代器也是真值,这个坑会静默地让分支走错),正确判空是 next(it, None) is None。要遍历两遍时,先问「数据大不大」:不大就直接 list() 落地,别炫技;大且两个消费者步调一致才用 tee。tee 的原理是维护一个共享缓冲区,先跑的消费者读过的元素必须留着给后跑的,所以一旦一个跑到底、另一个还没开始,缓冲区里就攒了全部数据——内存和落地成列表一模一样,白折腾。另外 tee 之后原迭代器就不能再单独使用了,否则两个分支都会莫名其妙缺数据。
记忆钩子:「itertools = 惰性迭代器工具箱,一句话记三件事:①『吃迭代器、吐迭代器』所以能串成流水线、全程只走一遍数据、内存 O(1);②无限的(count/cycle/repeat)必须配刹车(islice 按个数、takewhile 按条件、zip 靠短边);③groupby 是 uniq 不是 SQL GROUP BY——只切相邻,必须先 sort(key=f) 再 groupby(key=f),且组内要立刻 list(g)。代价是『一次性』:不能 len、不能索引、
if it恒为真、遍历完就空;要复用先想 list() 落地,tee 只在两个消费者步调一致时才真的省内存。最后记住:惰性省的是内存不是时间,permutations(range(12)) 有 4.79 亿项,迭代器不会替你少算一个。」
七、常见误区与追问
- 误区:
groupby就是 SQL 的 GROUP BY,直接用就能按键聚合。 它的真实语义是「压缩连续重复段」(等价于 Unix 的uniq)——内部只拿当前元素的键和上一个元素的键比较,一旦不同就开新组,从不回头查这个键之前是否出现过;所以[('北京',10),('上海',20),('北京',30)]会被切成三组,用字典收集时后面的「北京」还会覆盖前面的,数据丢了却不报错。正确写法永远是先data.sort(key=f)再groupby(data, key=f)(两个f必须是同一个函数);如果只是想做键聚合、不在乎顺序,collections.defaultdict(list)一次遍历 O(n) 更快也更不容易错(排序是 O(n log n))。 - 误区:itertools 是惰性的,所以
permutations(range(12))也能轻松跑完。 惰性省的是内存,不是时间——permutations(range(12))共 12! = 479001600 项,迭代器只是让你不必把这 4.79 亿个元组同时放内存里,但你要一个个走完它们,仍然是 4.79 亿次循环;组合学函数的规模是阶乘/指数级增长的,用之前先按n^r、n!/(n-r)!、C(n,r)估一下量级,必要时用islice只取前若干个、或改用采样/剪枝算法。 - 误区:
chain和cycle一样都不占内存。chain确实不占——它只记录「当前在第几个输入的第几个位置」,遍历完一个接着下一个,拼接 10 个百万级列表也是 O(1) 附加内存;但cycle必须能「循环第二遍」,所以它会在内部缓存一份原序列的完整副本,对一个百万元素的生成器用cycle,内存里就实实在在躺着一百万个元素。同理product也会把每个输入参数先物化成元组(因为后面的维度要反复重来),所以给product传生成器同样不省内存。 - 误区:用
if it:可以判断迭代器是不是空的。 迭代器没有定义__bool__和__len__,真值测试会退化成「对象存在即为真」,空迭代器也是True,这会让if it: 处理(it) else: 兜底()这样的代码静默走错分支且极难排查。判空的正确写法是取一个哨兵值:first = next(it, None),如果是None说明空;如果不空、后面还要用这个元素,记得用chain([first], it)把它接回去。同理迭代器也不能len()和索引,需要长度就只能落地成列表(或自己计数)。 - 误区:想遍历两遍就用
tee,它比list()省内存。 不一定——tee的原理是维护一个共享缓冲区:先跑的那个分支读过的元素,必须一直留着给后跑的分支用,所以两个分支的进度差多少,缓冲区就存多少。如果你list(a)一口气把 a 读完再遍历 b,缓冲区里就攒满了全部数据,内存和直接list()落地完全一样,还多了一层封装的开销。tee只在「两个消费者交替前进、进度差很小」时才有意义;另外tee之后绝对不要再单独推进原迭代器,否则两个分支都会丢数据。 - 追问:
takewhile和filter到底差在哪,为什么说前者能「提前退出」?filter会遍历完整个序列,对每个元素判断一次,保留所有为真的;takewhile只取开头连续满足条件的一段,一旦遇到第一个为假的元素就立刻停止,后面的元素连看都不看(哪怕后面还有满足条件的)。对[1,2,3,100,4,5]求「小于 10」,filter给出1,2,3,4,5(扫 6 个元素),takewhile给出1,2,3(扫 4 个就停)。差别在有序数据上最有价值:一个按时间倒序排列的千万行日志,要「最近一小时的记录」,用takewhile扫到第一条超过一小时的就停(可能只读几百行),用filter要把千万行全读一遍。对应地还有dropwhile(丢掉开头满足条件的一段,之后全要,同样只判断到第一次为假为止)。 - 追问:
accumulate和functools.reduce有什么区别? 两者都是「把二元函数依次作用到序列上」,区别是输出:reduce只返回最终那一个结果(reduce(add, [3,1,4])→8),accumulate返回每一步的中间结果组成的迭代器(accumulate([3,1,4])→3, 4, 8)。所以求总和、求最终乘积用reduce(或直接sum/math.prod);要「前缀和」「累计营收曲线」「历史最高水位线」这类每一步都要的场景用accumulate。另外accumulate默认的函数是加法(reduce必须显式传),且 3.8 起支持initial=指定初值(此时输出会比输入多一项,因为初值本身也是第一个输出)。 - 追问:
groupby吐出的分组对象为什么不能存起来事后再用? 因为groupby全程只维护一个底层迭代器,它吐给你的(key, group)里的group不是一个装好数据的列表,而是一个「共享底层迭代器、只负责在键不变时继续吐值」的惰性视图。当外层循环推进到下一组时,groupby必须先把当前组剩下的元素跳过去才能找到下一组的开头,于是上一组的group就被消费掉了、变成空的。所以[(k, g) for k, g in groupby(...)]事后遍历会全是空组,正确写法是在组内立刻落地:[(k, list(g)) for k, g in groupby(...)];同理也不能先把所有组收集起来再按需处理,要处理就在当前组内处理完。
八、加强记忆
itertools 是标准库的「惰性迭代器工具箱」,所有函数都遵守「吃可迭代对象、吐迭代器」的规矩,因此能串成流水线:chain → 过滤 → islice → map 全程数据只走一遍、中间不落地、内存 O(1)。按用途分三组记:无限型 count/cycle/repeat(必须配刹车:islice 按个数、takewhile 按条件、zip 靠短边;注意 cycle 会缓存整份原序列所以并不省内存);流水线型 chain(拼接不复制)、islice(迭代器切片、不支持负索引)、takewhile/dropwhile(有序数据上能提前退出,比 filter 全表扫描快)、compress(布尔掩码)、accumulate(前缀和/前缀最值,reduce 只给最终值、它给每一步)、groupby、pairwise;组合学型 product/permutations/combinations(一行替代多层嵌套 for,但规模是指数/阶乘级,permutations(range(12)) 就有 4.79 亿项)。两条最容易踩的红线:① groupby 是 uniq 不是 SQL 的 GROUP BY,只切相邻同键段,必须 sort(key=f) 后再 groupby(key=f),且分组对象要在组内立刻 list(g)(它和外层共享底层迭代器,外层一走它就失效);② 惰性的代价是一次性——遍历完就空、不能 len()、不能索引、if it: 恒为真(判空要 next(it, None)),要复用先想 list() 落地,tee 只在两个消费者步调一致时才真省内存。最后记牢一句:惰性省的是内存,不是时间。