尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Python生成器对象与enumerate全解析:理解惰性求值与迭代协议

Python生成器对象与enumerate全解析:理解惰性求值与迭代协议 1. 先确认一件事print 出generator object ...到底是啥1.1 别急着删代码先看它是不是错误的外观我记得在不少技术群里见过这样的求助截图有人写完一个生成器表达式print 了一下终端里冒出一行generator object genexpr at 0x7f8a2c3b4e50配文是这是不是报错了环境是不是坏了。其实我第一次遇到时也愣了一下因为屏幕上既有尖括号又有十六进制地址怎么看都像是某种异常抛出的讯息。先说结论这不是报错它只是一个对象的标识文本。Python 里的每一个对象都可以被打印出来打印出来的样子由这个对象的__repr__或__str__决定。当一个对象没有定义面向普通用户的字符串表现时解释器就会用默认方案——给出它的类型名和内存地址再用尖括号包起来。generator object genexpr at 0x7f8a2c3b4e50翻译过来就是这里有一个生成器对象它的类型是 generator它由某个生成器表达式创建内存地址是 0x7f8a2c3b4e50。所以看到这种输出程序没有挂异常没有被抛出你只是看了一眼这个生成器本身而不是看了它里面装的数据。打个比方你打开冰箱看到一张便利贴写着食物在抽屉里便利贴不是食物本身但它也不是冰箱报警。想要拿到食物你得按着提示去抽屉里拿想要拿到生成器里的数据你得去消费它。这就是接下来要展开的核心思路。1.2 这串文本从哪来Python 对象的身份证要理解这串文本就得稍微提一下 Python 的对象表示机制。任何一个对象当你用 print 输出它或者直接在交互式环境里敲下变量名回车解释器都会调用它的repr()方法。repr()的目的是给开发者一个可识别、能调试的文本。对于 int、str、list 这些常见类型repr()返回的内容很直观比如repr(3)得到字符串3。但生成器属于惰性对象它内部存储的是一段未执行的逻辑而不是一堆现成的数据所以它没有一个自然的、人类直接可读的文本形态。于是 Python 选择了用generator object ...这种格式来告诉你我是一个生成器我还没开始干活。这里面有两个信息值得留意。一个是生成器的出处标注比如genexpr表示它来自生成器表达式如果它来自生成器函数那么标注会是函数名比如generator object my_gen at 0x...。另一个是at 0x...后面那串十六进制它代表对象在内存中的地址。这个地址对普通业务代码基本没有意义但在排查是否创建了大量未释放的对象时会有用——你可以通过地址判断两个变量是不是同一个对象。还有一个非常容易混淆的点是generator object ...和Traceback完全不同。真正的报错会有Traceback (most recent call last)开头然后跟着文件名、行号、异常类型和错误信息。如果你看到的输出仅仅是generator object ...那么它连Warning都不算。我在后面第 4 节会专门讲怎么快速区分这两者。1.3 同样画风的兄弟姐妹们其实不只是生成器Python 里很多惰性迭代器打印出来都是这副模样。常见的有表达式输出示例含义(x * 2 for x in range(5))generator object genexpr at 0x...生成器表达式def f(): yield 1然后f()generator object f at 0x...生成器函数调用结果enumerate([a, b])enumerate object at 0x...enumerate 对象zip([1,2], [3,4])zip object at 0x...zip 对象map(str, [1,2])map object at 0x...map 对象iter([1,2])list_iterator object at 0x...列表迭代器open(file.txt)的只读句柄_io.TextIOWrapper namefile.txt ...文件对象发现规律没有凡是你看到一个尖括号包着的类型名基本都可以断定这是一个还未被消费的可迭代对象。它不是错误也不是一个放好了数据的容器它是一个等待被遍历的流。我记得有次在讲生成器时有学生突然恍然大悟那 enumerate 打印出来不也是enumerate object at 0x...吗对这就是我写这篇文章的另一个原因——enumerate 本质上也是惰性对象它和你害怕的 generator object 是同一个物种。搞懂一个另一个也就通了。2. 生成器的运行机制惰性求值、yield 与 next 的配合2.1 生成器怎么来的函数里有了 yield它就变性了生成器有两种主要创建方式。第一种是生成器表达式就是你把列表推导式的中括号换成圆括号squares (x * x for x in range(10)) print(type(squares)) # class generator第二种是生成器函数只要函数体里出现yield关键字这个函数就不叫函数了它叫生成器函数。当你调用它时函数体不会立刻执行而是返回一个生成器对象def count_down(n): print(开始倒计时) while n 0: yield n n - 1 gen count_down(3) print(gen) # generator object count_down at 0x... print(这行会先执行因为函数体还没跑)这里最反直觉的一点是count_down(3)这一行调用并没有执行print(开始倒计时)。只有当你第一次next(gen)或开始 for 循环时函数体才会真正执行直到遇到第一个yield然后暂停把yield后面的值返回给调用方。我之前在教这门概念时总喜欢用一个类比生成器函数像是一个按脚本演出的演员yield是定格按钮。你一喊开始调用 next演员就跑起来跑到某个yield处立刻定格把当前手上拿的东西递给你你再次喊开始演员从定格的地方继续往下跑到下一个yield再定格。直到函数自然结束演出就算完了你也会收到一个StopIteration异常——这个异常在 for 循环里会被自动吞掉在手动 next 时就需要你自己处理。2.2 拉一下才走一步next() 与惰性求值手动拉取生成器的内建函数是next()。它能让你精确控制每次取一个元素的节奏这在某些流式场景中尤其重要。比如你从一个传感器或日志流里拿数据数据是不断产生的你不能一次性把所有数据拿完因为根本没有所有这个概念你只能一个接一个地拿。这才是生成器真正存在的意义。def read_sensor(): for i in range(1, 4): yield fsensor-{i} gen read_sensor() print(next(gen)) # sensor-1 print(next(gen)) # sensor-2 print(next(gen)) # sensor-3 # print(next(gen)) # 如果继续调用会抛 StopIteration不过在实际开发中手动调next()的场景并不多因为 for 循环本身就帮你做完了反复 next 直到 StopIteration这件事。所以你要记住的关键点其实是生成器是惰性的。什么叫惰性就是你不找它要它不算你问它要一个它只算一个。列表推导式[x * x for x in range(100)]会一次性算完 100 个数然后把整个列表存在内存里。生成器表达式(x * x for x in range(100))只是把算平方这个配方存了下来等你遍历时一个数一个数地算。2.3 为什么说生成器一次吃饱吃完就空这一节是我认为新手最容易忽略的。生成器是**一次性single-pass**的。你可以理解为它是一条往前滚的传送带你从上面取走一个箱子它不会退回来。当你把传送带上的箱子全取完这个生成器就空了你再想遍历它什么都拿不到。gen (x for x in range(3)) print(list(gen)) # [0, 1, 2] print(list(gen)) # []因为 gen 已经被消费完了这个特性会带来一个经典误判你写了一段代码先判断if x in gen某个值在不在生成器里然后后面又去遍历for item in gen结果发现遍历出来是空的。原因就在你执行x in gen时Python 会从头开始逐个 next 去比较只要找到就停在当前位置哪怕找到了生成器的指针也已经移动到了那个位置之后。后面再遍历自然只剩下一半甚至没有数据。这不算 bug而是生成器的设计如此。遇到需要重复遍历的场景要么把数据先转成 list/tuple 存起来要么干脆重新创建一个生成器对象。2.4 内存优势不是玄学是一个真实对比很多人知道生成器省内存但不知道到底省了多少。拿一个直观的例子来说如果你跑sum([x * x for x in range(10**8)])列表推导式会先试图创建一个包含一亿个平方数的列表这个列表占用的内存非常可观可能直接把你的机器拖垮。而sum(x * x for x in range(10**8))并不会生成一亿个数据的实体它让每一个平方数算出来之后立刻进入求和随即被丢弃。这就好比一边是把所有货物堆满仓库再盘点另一边是每来一箱货就当场清点完再处理掉。数据量小的时候两者没差别数据量一旦上来差距就是能不能跑起来的区别。但省内存不是免费的。生成器的劣势在于它没有随机访问能力不能通过下标取值它不知道自己的长度你不能对生成器直接调用len()它只能顺序消费不能回头。所以在做技术选型时我的建议是数据规模小、需要多次访问时用列表数据规模大、或数据是实时产生时考虑用生成器。3. enumerate 不是加索引的工具那么简单3.1 enumerate 返回的也是惰性对象终于可以正面聊enumerate了。这个内建函数的作用是给可迭代对象中的每个元素配上序号——更准确地说它把一个可迭代对象包装成产出(索引, 元素)元组的迭代器。 enumerate(abc) enumerate object at 0x...看到没有这和我们前面聊的generator object ...是不是一模一样地吓人很多初学者在这里又一次误判为什么用 print 打出来不是[(0, a), (1, b), (2, c)]答案依然是——enumerate 是惰性的。它不会预先算好所有带序号的数据而是在你遍历时一组一组地吐出(索引, 元素)对。换句话说enumerate与生成器在消费方式上完全一致。正因为如此你在第 1 节里看到的尖括号加类型名的规律可以直接复用看到enumerate object at 0x...把它理解成一个等待遍历的索引包装器即可。不想看到这种输出就把它放进list()或for循环里去消费。3.2 手写索引 vs enumerate代码量对比在没有 enumerate 的世界里要给列表里的元素配上序号通常是这么写的fruits [apple, banana, cherry] i 0 for fruit in fruits: print(i, fruit) i 1这种代码最大的问题是你手动管理了一个i变量在循环体一长、逻辑一复杂的时候很容易漏掉i 1或者不小心在某个分支里写错。于是我见过有人用 range 方案for i in range(len(fruits)): print(i, fruits[i])这个方案写起来短一点但老实说并不优雅因为fruits[i]这种下标访问既多打几个字又在只想要元素的场景里显得多余。而enumerate的写法既不需要维护变量也不需要依赖下标for i, fruit in enumerate(fruits): print(i, fruit)我最初其实是抱着不就是少写几行代码的心态去用 enumerate 的但时间久了发现它的真正价值在于它强制你按索引和数据这种解构组合来思考遍历过程代码的意图表达得更清晰也彻底消灭了手写i 1这一类低级 bug。对于一个有多年维护经验的程序员来说少出 bug比少打字值钱得多。3.3 start 参数与等价实现enumerate还有一个容易被忽略的start参数。默认情况下索引从 0 开始如果你希望从 1 开始可以写成enumerate(items, start1)。这在很多业务场景里非常实用比如导出 Excel 序号、展示第几行从表头下一行开始计数等等。如果你想理解 enumerate 在底层到底做了什么其实可以自己写一个完全等价的小生成器def my_enumerate(iterable, start0): for item in iterable: yield start, item start 1我把这个等价实现拿给学生看的时候很多人终于想明白了一个点enumerate 返回的对象本质上就是一个生成器功能的实例。它用 yield 产出元组保持惰性也是一次性消费。官方文档里虽然没有把它直接称为 generator但它的行为和生成器几乎一样。所以你在第 1 节看到的enumerate object at 0x...与generator object ...有同款外观不是巧合而是它们在底层的气质就很相似。3.4 和 zip、range(len(x)) 的对比再扩展一下enumerate经常和另外两个内建函数一起被拿出来比较zip和range(len(x))。range(len(x))方案我上面提过能用但不够直观。如果你确实只需要索引而不需要元素那么直接range(len(x))没问题如果两者都要优先enumerate。而zip解决的则是另一个问题平行遍历多个序列。比如for idx, (name, age) in enumerate(zip(names, ages)):这种写法就是既要给多个序列并行取元素又要一个全局序号的典型场景。它把 enumerate 的序号功能和 zip 的配对功能组合到一个循环里可读性相当高。还有一个很妙的对比是enumerate和zip一样都返回惰性迭代器都能接收任意可迭代对象都不提前创建大的中间列表。它们和生成器一起构成了 Python 迭代协议里最常用的一套工具链。我倾向于把它们看作同一套哲学下的不同功能件——想遍历序列拿到序号时用 enumerate想按位拼接多个序列时用 zip想按需生成大量数据时用生成器。4. 实战中常见的误判与排查思路4.1 报错长什么样先学会区分异常和对象表示这一节我想认真地讲一点排查方法论因为很多人对是不是报错的判断依赖直觉而这恰恰最容易出错。在 Python 里真正的异常输出有一个非常固定的结构Traceback (most recent call last) 文件路径和行号 异常类型如TypeError、ValueError、NameError 具体的错误描述。你只要看到Traceback这个单词才意味着程序真的出问题了。而generator object ...、enumerate object at 0x...、zip object at 0x...这些它们没有Traceback也没有异常类型它只是 repr 的产物。所以当你看到一行尖括号文本出现在屏幕上时第一反应不应该是报错了而是这是一个还没有被遍历的对象。这个转变非常关键因为它直接影响下一步操作要是你把它当成报错你会去乱改代码但要是你识别出它只是对象的身份证你就会很自然地追问——我该怎么消费它我提供一个几乎不会错的方法把这个对象丢进list()里再看一眼。gen (x * x for x in range(5)) print(list(gen)) # [0, 1, 4, 9, 16]如果转成列表后看到的是正常数据那就彻底放心了。不过要留意这一转之后生成器就被消费完了后面不能再拿它做二次遍历。4.2 把生成器当列表用的连锁反应这是我在实际代码 review 和答疑中遇到最多的一类问题大家把生成器当成了列表于是出现了len(gen)报错、gen[0]报错、for循环走了第二次结果为空等一连串连锁反应。TypeError: object of type generator has no len()是新手最容易撞上的错。这个错误的本质是生成器没有长度这种静态属性它只有当前是否能继续产出的动态状态。你没有办法在不全部消费的情况下知道它里面还有多少数据。如果你想知道一个生成器的元素数量唯一的办法就是全部遍历计数或者把数据存进列表后对列表求 len。两者都需要消费掉它。再比如gen[0]这更是直接没法用因为生成器根本没有__getitem__方法。遇到这种需求说明你不该用生成器或者你应该先用itertools.islice(gen, n)取出前 n 个元素转成列表再做随机访问。我给读者的建议是看到生成器时先把它是一个列表这个念头从脑子里删掉。它在使用规则上更像文件句柄——打开一个文件你只能从头往后读读完就到底了想重读就要重新打开。理解了这个心智模型生成器的很多奇怪报错都变得顺理成章。4.3 一个 enumerate 相关的坑在循环里修改可迭代对象enumerate 也有自己的坑。我在写一个去重并标记行号的脚本时踩到过一个有点隐蔽的问题我先构建了一个列表然后用 enumerate 遍历它在循环体里对列表做了 remove 操作。结果行号虽然没报错但出现了跳过一个元素的现象。原因是enumerate遍历依赖的是迭代器的位置指针。当你在遍历过程中删除列表某个元素时列表整体会向前移位而迭代器的当前位置并不知道于是下一个 next 拿到的就是刚刚被挤过来的元素原来应该被访问的元素就被直接跳过了。data [1, 2, 3, 2, 4] for i, v in enumerate(data): if v 2: data.remove(v) # 不要这样做会跳过元素在遍历可变序列的同时修改它是几乎所有语言里都容易踩的坑。正确的做法通常是先收集要删除的条件或者创建一个新的列表/生成器。这段经历给我的体会是enumerate 本身不是 bug 的源头不懂得迭代器实时反映容器状态这个特性才是。迭代器不是容器在某一时刻的快照它更像是跟着容器变动的一个游标。你既要遍历又要修改时请格外小心。4.4 排查工具包islice、list()、type()、dir()这里分享一套我平时排查惰性对象问题时常用的工具可以算是一个小工具箱。type(x)确认它到底是什么类型。看到class generator就明白是生成器看到class enumerate就明白是 enumerate 对象。list(x)把惰性对象整体消费掉变成列表用于查看全部内容。注意会清空原对象。itertools.islice(x, n)只消费前 n 个元素适合在大规模流式数据中采样查看前几项。dir(x)查看对象上有哪些方法比如你会看到生成器有__next__和sendenumerate 对象有__next__但通常没有 send。str(x)或repr(x)直接看对象的文本表示也是确认是普通对象还是惰性对象的最快方式。举个组合使用的例子你手上有一个非常大的生成器想知道它前 5 个值长什么样但又不想把一千万个数据全部取到内存里。from itertools import islice gen (i for i in range(10_000_000)) for item in islice(gen, 5): print(item) # 0 1 2 3 4这样既看到了前几项又不会让原来的生成器失去作用。5. 从看懂对象到用好迭代协议5.1 理解这三个概念帮你省下大量踩坑时间写到这里我想把思路再往上提一层。你现在已经知道generator object ...不是报错也知道 enumerate 返回的enumerate object at 0x...是同类现象。但如果只停留在会辨认、不慌张这个层面还不够。真正有点价值的是把背后的三个概念串起来可迭代对象iterable、迭代器iterator、惰性求值lazy evaluation。列表、元组、字符串、字典、集合都是可迭代对象但它们在是否一次性消费上和生成器不同。当你调用for x in a_list时Python 会先调用iter(a_list)拿到一个迭代器然后反复让它 next列表本身不会因为你遍历一遍就变空。但生成器本身就是迭代器它没有再来一次的能力遍历完就没了。你可以这样理解可迭代对象是提供数据的工厂迭代器是顺着流水线取件的抓手。列表这个工厂比较傻它把生产的所有货物都摆在仓库里等你拿所以它可以反复参观生成器这个工厂比较精打细算它只在你伸手要的时候生产下一件而且传送带不可倒转。enumerate、zip、map 这些内建函数返回的对象通通属于后者。一旦建立起这个心智模型你就能预判很多行为了为什么list(enumerate(a))能正常显示数据因为消费了。为什么for i, v in enumerate(a)之后再打印 enumerate(a) 的变量会为空因为被消费了。为什么生成器里的文件句柄在 for 结束后自动关闭这种事也可以发生因为迭代器协议退场时会调用 close 之类的清理逻辑。这些都不是死记硬背的规则而是从模型中自然推导出来的结论。5.2 一个综合应用日志文件里找关键词并输出行号理论讲了不少我们来做一个贴近实际的小任务看看生成器和 enumerate 怎么搭档。假设你有一个很大的日志文件想在文件里找出所有包含ERROR的行并输出行号和这一行内容。比较直接的写法是with open(app.log, r, encodingutf-8) as f: for line_no, line in enumerate(f, start1): if ERROR in line: print(line_no, line.strip())这个例子看起来很简单但里面其实藏着不少值得品的细节。文件对象f本身就是一个可迭代对象它被enumerate包装成产出(行号, 行内容)的迭代器start1让行号从 1 开始符合人类阅读习惯。更重要的是文件对象是按行惰性读取的你不会把整份文件一次性载入内存即使文件有几个 GB这个循环也能平稳跑完。如果想再进一步把符合条件的内容做成一个生成器方便后续流水线处理def error_lines(path): with open(path, r, encodingutf-8) as f: for line_no, line in enumerate(f, start1): if ERROR in line: yield line_no, line.strip()在调用方你就可以做各种衍生操作了统计错误总数用sum(1 for _ in error_lines(app.log))只取前 10 条错误用islice(error_lines(app.log), 10)。这个场景把生成器函数、enumerate、islice 三个东西撮合到了一起而最底层的运转逻辑依然是你前面已经理解的迭代协议。它不是魔法只是一套设计得很统一的机制。5.3 我个人的一点习惯和收尾建议根据这些年在项目里的经验我慢慢养成了几个小习惯写在这里供你参考第一看到任何尖括号加类型名的输出先条件反射地确认它是不是惰性对象而不是先怀疑程序报错。第二写循环时只要需要计数我就直接用enumerate不再手动维护索引变量哪怕只需要遍历一个很短的列表。第三处理可能很大的数据流时优先考虑用生成器函数包装流程把取数据和处理数据解耦。第四调试时要看一个惰性对象的内容用list()或islice()但一定要清楚这会消费掉它必要时提前复制一份再做检查。说回enumerate本身我常觉得它是被很多人低估的一个函数。它确实简单但简单并不代表没有价值。enumerate教会我们的一件很重要的事是在 Python 的迭代协议里索引和元素是可以被同时产出的它们不是割裂的而是可以被统一封装成一个个元组流。你可以在生成器中用yield做出和它行为一致的自定义实现也可以把它与文件、zip、生成器无缝组合。一个熟手和初学者的差别很多时候不在于知道多少冷门函数而在于能不能把一个简单概念放到更庞大的迭代机制里去理解它——enumerate 就是这样一个很好的切入点。
返回列表