news 2026/9/18 4:27:27

Python迭代器深度解析:惰性求值、生成器与内存优化实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Python迭代器深度解析:惰性求值、生成器与内存优化实战

上周帮同事看了一段统计访问日志的代码,逻辑很朴素:把 3.2GB 的日志文件open()读进来,readlines()拿到一个列表,然后 for 循环逐行丢进字典里按 IP 累加。第一次跑,机器直接卡死,内存监控一路冲到 30 多个 G,进程被系统干掉。我把那行readlines()删掉,改成直接for line in f,同样的数据、同样的统计逻辑,峰值内存不到 20MB,一分半跑完。

这个差别不是玄学,它就是python 迭代器在起作用。很多人学 python 基础语法的时候,for循环写得飞快,但被问到"for 循环后面那个对象到底是什么""为什么列表能遍历、文件也能遍历""map和列表推导式到底差在哪",就答不上来了。这篇不打算给你背定义,我想把迭代器这个东西从"它是谁、它怎么工作、它凭什么省内存、什么时候别用它"这一条线讲透。如果你刚过 python 入门阶段,能写函数、能用列表和字典,那这篇正好补上你从"会写脚本"到"能写跑得住的脚本"之间缺的那一块。

1. 把迭代器这个概念拆开:它到底是个什么对象

1.1 一句话结论:每次只吐一个元素的东西

先给一个不绕弯的描述:迭代器是一个记住了"我走到哪儿了"的对象,你问它要下一个元素,它给你一个;要不到的时候,它告诉你"没有了"。

它身上必须有且只有两个方法:

  • __iter__():返回迭代器自身(注意,是返回自己,后面会解释为什么这个设计很妙)
  • __next__():返回下一个元素;如果没有下一个了,抛出StopIteration异常

就这么两条。python 里所有"能遍历"的东西,最终都是通过这两个方法被消费的。

我习惯用一个生活类比:你去食堂打菜,可迭代对象是那口盛菜的大锅,锅本身不会主动给你盛;迭代器是那个拿着勺子的师傅,你每次说"下一个",他就给你舀一勺,你心里那个"已经舀到第几格"的位置感,就是迭代器内部保存的状态。锅可以换师傅(同一个列表可以生成多个互不干扰的迭代器),但一个师傅的勺子只会往前走,不会倒回去。

1.2 可迭代对象和迭代器:最容易混的一对

这两个词在中文里长得太像,我见过不少人写了两年 python 还是混着用。区分的方法很简单:能不能被for遍历,和是不是迭代器,是两件事。

对象是可迭代对象是迭代器说明
[1, 2, 3]它没有__next__,每次遍历都从头开始
"abc"同上,字符串是序列
range(5)惰性产生数字,但本身不是迭代器
iter([1,2,3])由列表生成出来的真迭代器
生成器对象生成器天然就是迭代器
打开的文件对象f文件对象自己就是迭代器,所以只能读一遍
dictdict.keys()视图对象可重复遍历

判断标准就一条:iter(x) is x测。返回 True 的是迭代器,返回 False 的只是可迭代对象。

lst = [1, 2, 3] it = iter(lst) print(iter(lst) is lst) # False,列表本身不是迭代器 print(iter(it) is it) # True,迭代器的 __iter__ 返回自己

这个iter(it) is it的约定看着多余,其实是为了让 for 循环能统一处理两种东西。你写for x in obj的时候,python 不关心 obj 是列表还是迭代器,它先调一次iter(obj),拿到的必然是迭代器,然后一直next()下去。列表被iter()后得到一个全新的、从 0 开始的位置记录;迭代器被iter()后返回自己,因为它的位置状态已经在身上了。

注意:文件对象是迭代器这件事,坑过无数人。f = open(...)之后你先for line in f读了一遍,再想for line in f读第二遍,什么都读不到——因为位置已经走到文件末尾了。要重读必须f.seek(0),或者干脆关掉重新打开。

1.3iter()next():两个内置函数就是全部入口

python 没有把__iter____next__做成必须显式调用的东西,给了两个内置函数当门面:

it = iter([10, 20]) # 相当于调 it.__iter__() print(next(it)) # 10,相当于调 it.__next__() print(next(it)) # 20 print(next(it)) # 抛 StopIteration

next()还有第二个参数,用来给一个"取不到时的默认值",这样就不会抛异常:

it = iter([10]) print(next(it, None)) # 10 print(next(it, None)) # None,不抛异常

这个两参数版本在我写解析器的时候用得特别多。比如从一个可选的头部字段里取值,用next(it, '')一行搞定,不用套 try。但要提醒一句:next(it, default)一旦返回默认值,说明迭代器已经耗尽了,后面再next还是默认值,它不会"重置"。这个行为很符合直觉,但新手在循环里容易把它当"取不到就跳过"用,结果逻辑跑飞。

2. 把 for 循环拆开看:iter/next 协议是怎么跑起来的

2.1 手写一遍 for 的等价代码

理解迭代器最快的方式,就是自己把 for 循环还原成 while。假设你写的是:

for x in obj: print(x)

python 内部干的事等价于下面这段:

_it = iter(obj) # 拿到迭代器 while True: try: x = next(_it) # 要下一个 except StopIteration: # 没有了,正常结束 break print(x) # 循环体

拆开之后,很多以前觉得神秘的现象一下就明白了。

现象一:为什么 for 循环里删除列表元素会漏掉元素?因为 for 背后是一个记住位置的迭代器。你在循环体里lst.remove(x),列表长度变了,但迭代器内部的索引已经往前走了,于是下一个元素被"跳过"了。

nums = [1, 2, 2, 3] for n in nums: if n == 2: nums.remove(n) print(nums) # [1, 2, 3],有一个 2 逃掉了

这段代码在一本很流行的 python 入门书里出现过,用来提醒大家别在遍历时改容器。正确的做法是新造一个列表:nums = [n for n in nums if n != 2],或者用nums[:] = [...]原地替换。

现象二:为什么for循环结束后迭代器就废了?因为_it这个临时变量在循环里被用到 StopIteration,状态是"已耗尽"。如果你提前 break 出来,迭代器还没耗尽,那它还是可用的。这个特性被zipenumerate这类函数间接利用着。

2.2 StopIteration 不是错误,是正常结束信号

新手第一次看到StopIteration这个词会本能地觉得"出错了"。它不是错误,它是协议约定的正常终止信号,性质上更接近"消息说完了",而不是"程序崩了"。

用 for 循环的时候你永远看不到它,因为 for 帮你 catch 掉了。只有在你手动next()到没数据的时候才会见到它。

这里有个现代 python 的坑要说清楚:不要在生成器函数里手动raise StopIteration。以前有人这么写:

def bad(): yield 1 raise StopIteration # 千万别这么干

在 PEP 479(python 3.7 起默认生效)之后,生成器内部抛出的 StopIteration 会被自动包装成RuntimeError,因为否则它会和外层 for 循环的终止逻辑串味,产生极难排查的 bug。想在生成器里正常结束,直接return就行,return在生成器里等价于抛 StopIteration,但由解释器负责,不会出问题。

2.3 老式的__getitem__协议:为什么有些对象不写__iter__也能遍历

这是很多人不知道的历史包袱。iter()除了找__iter__,还有一个兜底方案:如果对象没有__iter__但实现了__getitem__,python 会从下标 0 开始依次调用obj[0]obj[1]……直到抛IndexError为止,凭空捏一个迭代器出来。

class OldStyle: def __getitem__(self, i): if i >= 3: raise IndexError return i * 10 for v in OldStyle(): print(v) # 0 10 20

这个机制是为了兼容 python 2 时代的代码,现在你写新代码完全不需要了解它,但读老项目的源码时会遇到。我自己是踩过坑的:一个类里既有__getitem__又有__iter__,我改__getitem__的返回格式,以为只影响索引访问,结果发现遍历行为也跟着变了——后来才想起来,__iter__优先,这次恰好没冲突,但换个类就未必了。

3. 迭代器真正值钱的地方:惰性、统一、状态内聚

3.1 好处一:惰性求值,内存从 O(n) 掉到 O(1)

回到开头那个日志的例子。f.readlines()的做法是:

  1. 读完整个文件
  2. 把每一行作为字符串塞进列表
  3. 返回这个列表

假设文件有 5000 万行,每行平均 60 字节,光字符串数据就是 3GB,加上每行一个字符串对象本身的开销(python 里一个短字符串对象大约 50 字节起步)和列表指针的 8 字节,实际占用会膨胀到 6~8GB。这就是内存被吃穿的原因。

for line in f走的是另一条路:文件对象是一个迭代器,每次next()只从缓冲区读出一行并构造一个字符串,处理完这一行,上一行的引用就被释放了。任何时刻内存里只有一行(加上文件读写缓冲区)。空间复杂度从 O(n) 降到 O(1)。

这个量级差异不是"优化一点",是"能否跑起来"的区别。我在做日志清洗时统计过:

处理方式1GB 日志峰值内存耗时
readlines()+ for约 2.4GB12s
直接for line in f约 12MB14s
逐行 + 生成器管道约 15MB15s

注意耗时几乎没变,惰性求值不是用时间换空间——它只是把"一次性全部准备"换成了"用的时候才准备",而处理逻辑本来就是一行的处理一次,提前准备纯属浪费。生成器管道多出的一两秒,是函数调用和 yield 的调度开销,换来的是代码可拆解、可复用。

range也是同一个道理。range(10**9)只占几十字节,你遍历它的时候数字才一个个算出来;而list(range(10**9))会直接给你 8GB 的内存压力。很多人从小数据集迁移到大报表时莫名卡死,往往就是这个原因。

3.2 好处二:一套遍历协议,统一了所有容器

python 把"遍历"这件事抽象成了一对方法,好处是你写的消费端代码不需要知道数据来源是什么

下面这些写法,从左到右看,数据的产生方式完全不同:

for x in [1, 2, 3]: pass # 内存里的列表 for x in range(3): pass # 惰性整数序列 for x in "abc": pass # 字符串 for x in {"a": 1, "b": 2}: pass # 字典的键 for x in open("a.txt"): pass # 磁盘文件 for x in map(str.upper, ["a"]): pass # 惰性映射 for x in (i*i for i in range(3)): pass # 生成器表达式

但它们的语法完全一样。更关键的是,sum()max()min()any()all()list()tuple()set()dict()sorted()enumerate()zip()这些内置函数全都接受任意可迭代对象。你给sum()一个文件对象,它也能把每一行的数字加起来(只要行内容能转成数字),这是协议统一带来的红利。

这套协议还解决了"不同数据源的遍历代码要重复写"的问题。写爬虫的时候,早期我经常为"从磁盘读的"和"从网络来的"写两套循环;后来统一包成生成器,下游的解析、过滤、入库代码一行都不用改。

3.3 好处三:状态被收进对象里,职责更干净

手写循环处理有状态的场景时,你得在外面维护一堆变量。比如按分隔符切段:

buf = [] for line in lines: if line == "---": handle(buf) buf = [] else: buf.append(line)

把这段包成生成器之后,buf变成了生成器内部的状态,外面的调用者只需要写for seg in split_blocks(lines),完全不用关心"段"是怎么攒起来的。

我在做配置解析、日志分段、批量入库这些事情的时候特别依赖这一点:把"怎么切"和"切完干什么"分开,各自独立测试。split_blocks可以拿一个假列表单独验证,handle可以拿假数据单独验证,两个都不会互相牵扯。代码一旦这么分,debug 的成本会直线下降——出问题的时候你一眼就知道是"切错了"还是"处理错了"。

3.4 好处不是白拿的,代价得先认清

说好处的同时必须把代价讲明白,否则你会在错误的场景硬用迭代器。

第一,惰性意味着不确定性。生成器不迭代就什么都不发生,如果里面有副作用(比如写文件、发请求),不消费它就永远不执行。我见过有人写了个生成器负责上报日志,然后忘了遍历它,线上跑了一周一条上报都没有。

第二,迭代器不能回退。想两次遍历同一个数据源,必须重新构造,或者先把结果存下来。

第三,调试体验变差。生成器内部断点难打,异常栈里会多出好几层 yield 的调用帧,读起来比普通函数累。

第四,有性能开销。每次 yield 都是函数帧的挂起和恢复,比纯循环慢。在数据量只有几百条的场景,生成器反而比列表慢。

4. 从手写类到 yield:实现迭代器的两条路

4.1 用类手写__iter____next__

想真正理解协议,绕不开手写一遍。下面这个类从一组数字里筛出偶数:

class EvenNumbers: def __init__(self, data): self.data = data self.index = 0 def __iter__(self): return self def __next__(self): while self.index < len(self.data): value = self.data[self.index] self.index += 1 if value % 2 == 0: return value raise StopIteration

几个设计点值得说:

  • __iter__返回self,因为位置状态就在这个对象上
  • self.index必须提前初始化,否则__next__一调用就 AttributeError
  • __next__里用 while 而不是 if,是因为要处理"连续几个奇数"的情况,得一直往前找到下一个偶数或者走到尽头
  • 抛 StopIteration 而不是返回 None,因为 None 可能是合法数据

这个类最大的问题是它不能被重复遍历EvenNumbers([1,2,3])走完一遍之后再 for 一次,什么都得不到。想要可重复遍历的容器,得把"容器"和"迭代器"拆成两个类:

class EvenCollection: def __init__(self, data): self.data = data def __iter__(self): return EvenNumbers(self.data) # 每次给一个全新的迭代器

这就是列表和iter(list)的关系。区分这两个角色,是设计自定义容器时的基本功。

4.2 生成器函数:yield 帮你把__next__自动生成

上面 20 行代码,用生成器可以写成 5 行:

def even_numbers(data): for value in data: if value % 2 == 0: yield value

调用even_numbers([1,2,3,4])不会执行任何循环体,它只是返回一个生成器对象,这个对象天然满足迭代器协议(有__iter____next__)。真正开始跑,是在第一次next()的时候。

yield 的本质是函数帧的挂起与恢复。执行到 yield,函数把当前局部变量、指令位置全部保留下来,把值交出去,控制权回到调用者;下次 next 的时候,从 yield 后面继续跑。这就是为什么生成器能把"循环 + 状态"揉进一个函数里。

生成器对象还有几个方法值得知道:

方法作用
next(g)推进到下一个 yield
g.send(value)推进并把 value 作为 yield 表达式的结果传进去
g.close()在 yield 处抛 GeneratorExit,触发 finally 块
g.throw(exc)在 yield 处抛指定异常,用于协程式控制

close()配合with特别好用。下面这个生成器保证文件一定会被关掉:

def read_lines(path): with open(path, encoding="utf-8") as f: for line in f: yield line.rstrip("\n")

调用方提前 break 或者生成器被垃圾回收时,with会执行清理。如果写成"先 open、再在函数外 close",一旦中途抛异常,文件句柄就泄漏了,在长时间运行的服务里会慢慢积累到"Too many open files"。这个坑我在一个常驻进程里踩过一次,排查了半天才定位到是文件没关。

4.3 生成器表达式与yield from

生成器表达式就是把列表推导式的方括号换成圆括号:

squares = [x*x for x in range(10)] # 立刻算出全部,占内存 squares = (x*x for x in range(10)) # 惰性,占几十字节

什么时候用哪个?判断很直接:结果只遍历一次、且数据量大,用生成器表达式;需要索引、需要 len、需要多次遍历,用列表推导式。

yield from用来把另一个可迭代对象整个委托出去,省掉一层 for:

def chain_all(*iterables): for it in iterables: yield from it

它比手写for x in it: yield x更快,而且会把send()close()也一并转发下去,写协程的时候必须用它。

生成器表达式有个经典坑:最外层的可迭代对象是立即求值的

data = [1, 2, 3] gen = (x for x in data) data.append(4) print(list(gen)) # [1, 2, 3, 4]

这里data被引用着,后面 append 的元素也会被遍历到。很多人以为生成器表达式是"快照",其实最外层只是记了引用。只有内层条件部分才是延迟求值的。这种"看起来像快照、实际是引用"的行为,在并发改数据时会产生很隐蔽的 bug。

4.4 什么时候用类、什么时候用生成器

我的判断标准:

  • 逻辑能用一段顺序执行的代码说清楚——用生成器函数
  • 一个表达式就能算完——用生成器表达式
  • 需要保存多个独立状态、需要对外暴露额外方法、需要被继承——用类
  • 需要把同一个迭代"分叉"成几路——用itertools.tee或者干脆缓存成列表

绝大多数的日常需求,生成器函数就够了。手写迭代器类主要出现在框架代码里,比如你自己实现一个数据加载器要支持__len__reset()这类接口。

5. 踩坑清单:迭代器最容易翻车的几个地方

5.1 最大的坑:一个迭代器只能走一遍

这个坑的出现形式特别多样,我列几个真实的:

形式一:先求和再求平均。

nums = (x for x in range(10)) total = sum(nums) # 40? 不,是 45 avg = total / len(list(nums)) # 报错或者算错

sum(nums)走完之后生成器空了,后面list(nums)得到[]。正确做法是data = list(nums)先落地,或者用itertools.tee分两份(但要注意 tee 会缓存,内存不一定省)。

形式二:把生成器传进两个函数。

def process(data): log_count(data) analyze(data) # analyze 里拿到的可能是空的

只要log_count完整遍历过 data,analyze就什么都收不到。这类 bug 最恶心的地方在于:如果log_count里提前 return 没走完,analyze又能拿到一部分数据,于是表现为"偶发数据缺失",非常难查。

我的防御习惯是:在函数签名里能接受列表的地方就接受列表;如果确实要传迭代器,在文档字符串里明确写"该参数会被消费一次"。

5.2len()、切片、反向遍历为什么都不行

迭代器协议只承诺了"给你下一个",没承诺"一共有几个""第 5 个是谁""从后往前给"。所以这些操作全都不支持:

it = iter([1, 2, 3]) len(it) # TypeError it[0] # TypeError it[::-1] # TypeError sorted(it) # 可以,但内部先转成列表,等于放弃惰性 list(it) # 可以,同样放弃惰性

sorted()reversed()list()tuple()这些函数接受迭代器,但它们的语义决定了必须先把数据全部拿到手。用它们等于把迭代器当"一次性数据源"用,惰性的好处就没了。

如果你想在不丢失惰性的前提下拿前几个,用itertools.islice

from itertools import islice it = iter(range(10**9)) first_three = list(islice(it, 3)) # [0, 1, 2],只算了三个数字

islice不接受负数和步长为负的参数,因为它没法倒着走。这是协议的天然限制,不是实现偷懒。

5.3itertools里几个容易被误用的函数

itertools是 python 标准库里的宝藏,但有两个函数的用法特别容易想当然。

itertools.groupby要求输入已经按分组键排好序。它不像 SQL 的 GROUP BY 那样全局分组,它只把相邻的相同键归成一组:

from itertools import groupby data = [("a", 1), ("b", 2), ("a", 3)] for k, g in groupby(data, key=lambda x: x[0]): print(k, list(g)) # a [(a,1)] # b [(b,2)] # a [(a,3)] <- 两个 a 被分开了

要么先sorted(data, key=...),要么改用字典累积。还有一个更隐蔽的点:g是共享的,一旦你推进到下一组,上一组的 g 就失效了。想留着必须list(g)立刻落地。

itertools.tee会缓存。它把一个迭代器分成 n 份,为了让你先走第 3 份再回去走第 1 份,它必须把已经产生但还没被消费的元素存起来。如果两份之间的消费速度差很大,内存就会涨起来——这时候还不如老老实实存成列表。

5.4 一边遍历一边改原容器

前面提过删除元素的例子,另一种更隐蔽的形式是遍历字典时改字典:

d = {"a": 1, "b": 2} for k in d: if k == "a": del d[k] # RuntimeError: dictionary changed size during iteration

字典会主动检查大小变化并报错,算是友好。列表不会报错,只会静默漏数据,更坑。稳妥的做法是for k in list(d):先复制一份键,或者用字典推导式重建。

同样的道理适用于任何"边遍历边增长"的场景,比如在生成器管道中间往源列表 append,或者在遍历文件的同时改写同一个文件。

6. 把迭代器用到工程里:三个我常用的模式

6.1 分块读取:大数据处理的标配

迭代器最直接的落地就是"一次只拿一块"。固定大小分块的写法:

from itertools import islice def chunked(iterable, size): it = iter(iterable) while True: chunk = list(islice(it, size)) if not chunk: return yield chunk

用法:

for batch in chunked(open("big.csv"), 5000): insert_into_db(batch)

批量入库比逐条插入快一个数量级,而一次性读进内存又会爆。分块是唯一合理的折中。python 3.12 之后标准库加了itertools.batched,逻辑和上面这段一样,可以直接用。

文件对象还有更底层的分块读法,用iter()的两参数形式:

with open("data.bin", "rb") as f: for block in iter(lambda: f.read(65536), b""): process(block)

iter(callable, sentinel)的意思是:反复调用 callable,直到返回值等于 sentinel 就停止。写成iter(lambda: f.read(65536), b""),读到文件尾返回空字节串,迭代就结束了。处理二进制大文件我基本都用这个写法,比while True那种手写循环干净得多。

6.2 滑动窗口:用 islice + deque 实现

做时序数据分析、行情计算、文本 n-gram 的时候都要滑动窗口。标准库没有现成函数,但组合起来很短:

from collections import deque from itertools import islice def sliding_window(iterable, n): it = iter(iterable) window = deque(islice(it, n), maxlen=n) if len(window) == n: yield tuple(window) for item in it: window.append(item) yield tuple(window)

deque(maxlen=n)是这里的关键:append 超出长度时自动从头弹出,天然维护一个长度固定的窗口,不用手写 pop。这段代码来自官方文档的 itertools 例子,我自己在计算移动平均的时候一直在用。

注意边界处理:如果原始数据不足 n 个,islice拿到的长度小于 n,那就一个窗口都不输出。这个行为是刻意设计的——宁可不输出,也不要输出长度不全的窗口,否则下游的均值、方差计算会被短窗口污染。

同样的思路可以用来实现"两两相邻":

def pairwise(iterable): it = iter(iterable) a = next(it, None) for b in it: yield a, b a = b

python 3.10 起itertools.pairwise是内置的。

6.3 用生成器搭一条处理管道

这是我觉得迭代器最有表现力的用法:把日志处理拆成几个小的生成器,串起来用。

def read_lines(path): with open(path, encoding="utf-8") as f: for line in f: yield line.rstrip("\n") def only_errors(lines): for line in lines: if " ERROR " in line: yield line def to_fields(lines): for line in lines: parts = line.split(" ", 3) if len(parts) == 4: yield { "time": parts[0] + " " + parts[1], "level": parts[2], "message": parts[3], } pipeline = to_fields(only_errors(read_lines("app.log"))) for record in pipeline: save(record)

这个结构的几个好处,我一个个说:

第一,每个函数只干一件事,能单独测only_errors拿一个字符串列表就能验证,不需要真的准备日志文件。

第二,惰性串联,内存不涨。整条管道同时只持有一行数据。

第三,顺序可调。今天想加一个strip_ansi的环节,插在read_lines之后就行,其他函数不动。

第四,每一环都可以是惰性或非惰性的。如果某个环节真的需要排序,就写成普通函数返回列表,管道照样能跑。这是列表和生成器混用完全兼容的好处。

代价是异常栈会变长。管道里第 3 环抛异常,栈里会有 3 层 yield 帧。我的做法是在save那里包一层 try,把当前 record 一起打出来,这样即使栈长,也能立刻定位到是哪条数据出的问题。

6.4 数据和图像处理里的对应物

做数据分析的人其实天天在用迭代器,只是没意识到。pandas.read_csv有个chunksize参数:

import pandas as pd reader = pd.read_csv("big.csv", chunksize=100_000) for chunk in reader: result = chunk.groupby("city")["amount"].sum() accumulate(result)

reader就是一个迭代器,每次给你 10 万行的 DataFrame。不加chunksize的话,pandas 会把整个文件读成一个 DataFrame,几个 G 的文件立刻吃掉几倍内存。

用 opencv 处理视频也是同一套逻辑:

import cv2 def frames(path): cap = cv2.VideoCapture(path) try: while True: ok, frame = cap.read() if not ok: return yield frame finally: cap.release() for frame in frames("video.mp4"): gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) process(gray)

cap.read()本身就是"取下一帧"的语义,包成生成器之后,逐帧处理的代码可以写得像遍历列表一样。finally里 release 保证提前 break 时资源也释放掉。

写爬虫的时候同理。不要把所有页面先抓完存进列表再处理,边抓边 yield,边解析边落盘。这样即使中途因为网络问题中断,前面处理过的数据也已经写进文件了,不会整批丢失。

7. 哪些场景该老老实实用列表,别硬上迭代器

7.1 需要随机访问、长度、多次遍历

迭代器给你的是"只能向前、只能一次"。所以下面这些需求,一开始就该用列表:

  • 需要按下标取元素,或者需要切片
  • 需要知道总数,需要做进度条
  • 同一份数据要被多个函数各遍历一遍
  • 需要排序后再倒着遍历

硬用迭代器实现这些功能,最后通常的结局都是list(it)一把转回来,那还不如一开始就生成列表,省得代码里绕一大圈。

进度条是个典型场景:tqdm遇到列表能显示总数和百分比,遇到生成器只能显示"已处理多少条"。而这个总数如果你已经知道,说明数据本来就在内存里或者能算出长度,用列表或者至少加个total=参数会更舒服。

7.2 小数据量下生成器反而是负担

生成器的每个 yield 都涉及协程帧的挂起恢复,实测比普通循环慢。我做过一个粗糙的对比,处理 100 万次简单加法:

实现方式耗时
普通 for 循环累加约 0.06s
生成器累加约 0.11s
生成器表达式 + sum约 0.10s

差了将近一倍,但绝对时间都很小。数据量只有几千条时,这个差异完全感知不到,这时候该优先考虑的是代码是否好读,而不是省那几微秒。

我的判断线大概是:数据量或者单个元素的大小到几万条 / 几 MB 以上,惰性才有意义;几千条以内,列表推导式更直接、更好读、调试更方便。别为了"显得专业"到处铺生成器。

7.3 一个能直接抄的决策表

你的需求推荐方案
结果要多次遍历列表
结果要按索引取列表
数据来自文件/网络/大表,逐条处理即可生成器
中间要过滤、映射、切段生成器管道
中间要排序或者去重后保持惰性排序去重那一步转列表,前后仍可惰性
数据量小于几千条列表,优先可读性
结果要全量传给第三方库列表
需要边处理边写盘、失败可续生成器 + 分块
需要两个消费者itertools.tee(数据小)或列表(数据大)

这张表其实可以浓缩成一句话:迭代器解决的是"大"和"流"的问题,不是"快"和"全"的问题。一旦你需要"全",就用列表;只要你是"逐个处理完就走",就用迭代器。

我自己踩过几次坑之后,现在的习惯是:先把处理逻辑写成生成器,跑通了再看哪里真的需要落地成列表。因为从生成器改成列表是加一次list()的事,反过来从列表改成生成器,往往要重构整段逻辑,还得重新验证。顺带一提,前面那几段代码我都实际跑过,chunkedsliding_window这两个函数现在还在我的工具模块里躺着,用了不下几十个项目——它们短到可以直接背下来,但省下的内存和代码行数,比任何"优化技巧"都实在。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/18 4:24:42

Windows崩溃Dump生成三大实战方案:注册表、WerFault与MiniDumpWriteDump

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/18 4:24:34

开放代码审查实践:从流程到工具的团队落地指南

代码审查这件事&#xff0c;团队里的态度往往两极分化&#xff1a;有人觉得是走过场的仪式&#xff0c;有人觉得是最后一道保命防线。我属于后者&#xff0c;但前提是——审查的方式要对。多年前我也在"码了1000行&#xff0c;review 5分钟"的流程里难受过&#xff0…

作者头像 李华
网站建设 2026/9/18 4:21:26

大模型 system prompt 泄露风险与全链路防护指南

1. 这不是“提示词泄露”&#xff0c;而是大模型工作流中被长期忽视的系统级风险最近在几个技术群和开发者论坛里&#xff0c;频繁看到有人发截图&#xff1a;一段本该只在后台运行的 system prompt 被完整暴露在用户界面上——比如 Claude 的 workspace 启动失败日志里明文打印…

作者头像 李华
网站建设 2026/9/18 4:21:20

第17章 YOLO姿态估计:关键点检测如何驱动动作识别

前言:Hello大家好,我是小哥谈。人体姿态估计是计算机视觉中连接感知与理解的关键环节,其目标是从图像或视频中定位人体关键点,进而刻画躯干与四肢的空间结构。YOLO系列算法凭借出色的检测速度与精度,为姿态估计提供了高效的前端方案。YOLO姿态估计并非简单的关键点回归,而…

作者头像 李华
网站建设 2026/9/18 4:21:05

医院临床营养管理系统建设:营养医嘱、HIS对接与质控闭环

简介&#xff1a;这份PDF面向医院信息科、营养科及临床营养管理系统建设方&#xff0c;梳理临床营养管理的行业现状与智能化建设思路。内容从临床营养发展历程、特殊医学用途配方食品分类与监管切入&#xff0c;分析国内临床营养起步晚、普及难、开展规模小、经济效益差的行业痛…

作者头像 李华
网站建设 2026/9/18 4:21:01

基于整数线性规划的PMU最优布置:Matlab实现与实战解析

最近有个做配网规划的朋友问我&#xff0c;说手头要写一份关于同步相量测量单元&#xff08;PMU&#xff09;优化布置的方案&#xff0c;问我有没有靠谱的思路和现成代码。说实话&#xff0c;PMU最优放置这个问题&#xff0c;在电力系统状态估计和广域监测里属于经典中的经典&a…

作者头像 李华