十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Python迭代器与生成器:深入理解for循环的惰性求值机制

Python迭代器与生成器:深入理解for循环的惰性求值机制 在 Python 的学习路上几乎每个人都写过 for 循环。但如果你仔细观察for i in range(10**8)这样的代码会不会冒出一个疑问这个循环真的会生成一个包含一亿个整数的列表吗如果会为什么我的电脑没有被撑爆我第一次认真思考这个问题是在一次面试被问到“range(108) 和 list(range(108)) 的内存占用差多少”的时候当场愣了一下。后来我把迭代器Iterator协议彻底翻了一遍才发现 for 循环远不止是一个语法糖它背后藏着一套相当精巧的设计。这篇文章就来聊聊 for 循环背后那套被大多数人忽略的故事。我会从iter()和next()这两个函数讲起带你手写一个迭代器再用生成器把代码写得更优雅最后分享几段我在项目里踩过的迭代器相关的坑。无论你是刚学完 Python 基础、想真正理解循环本质的新手还是准备面试、想补齐这块知识点的同学这篇都应该能帮到你。1. for 循环的三件套iter()、next() 与 StopIteration很多 Python 学习者写了很久的 for 循环却不知道 for 循环内部真正做了什么。其实 for 循环并不是什么高深莫测的魔法你完全可以用 while 循环把它模拟出来。理解了这个等价过程你就掌握了迭代器协议的入口。1.1 用 while 手搓一个 for 循环假设我们有一个列表想要逐个打印里面的元素。常规写法是lst [1, 2, 3] for item in lst: print(item)但这段代码在解释器内部做的事情其实可以拆解成下面这三步lst [1, 2, 3] it iter(lst) # 第一步调用 iter() 拿到迭代器 while True: try: value next(it) # 第二步反复调用 next() 取下一个元素 except StopIteration: # 第三步耗尽时捕获异常结束循环 break print(value)看到这里很多读者会有一个疑惑为什么循环结束要依靠一个异常每次取元素之前判断一下“还有没有下一个”不行吗这恰恰是 Python 风格和 C 风格之间非常典型的一个差异。Python 的设计哲学更偏向“请求原谅比请求许可更容易”EAFP与其每次小心翼翼地去查询状态不如直接取值取不到就抛一个StopIteration循环自然结束。for 循环内部把这一整套 try/except 封装得干干净净你看到的就是一个简单的for item in obj。真正理解了这段等价代码很多问题就迎刃而解了。比如为什么 for 循环能遍历文件对象、能遍历生成器、能遍历字典的 key却不能在整数上循环因为整型没有实现迭代协议iter(3)会直接抛出TypeError: int object is not iterable。1.2 可迭代对象和迭代器真的是两回事这里要区分两个高频概念可迭代对象Iterable和迭代器Iterator。可迭代对象能被iter()函数处理、能返回一个迭代器的对象。比如列表、元组、字符串、字典、集合、range。迭代器实现了__next__()方法的对象调用next()会返回下一个元素没有元素时抛StopIteration。概念有点绕我用一张表帮你理清对比项可迭代对象迭代器常见代表list、tuple、str、dict、set、range文件对象、生成器、iter(lst)的返回值是否支持iter()是是是否支持next()否是能否重复遍历能每次生成新的迭代器不能消费完就没了是否保存所有数据看类型list 保存range 不保存通常惰性生成不保存全部数据判断标准也特别简单直接对它调用next()能正常工作它就是迭代器不能它大概率只是可迭代对象。这里要特别提醒一句我见过不少同学在写函数参数时把 list 当迭代器传或者反过来把迭代器当列表用结果程序运行到一半才发现类型对不上。这个区分不是考试知识点是日常写代码一定会遇到的。1.3 细节iter() 对迭代器调用返回的还是它自己还有一个容易被忽略的细节如果你对一个迭代器再次调用iter()返回的是它自己。换句话说迭代器是自己的迭代器。lst [1, 2, 3] it iter(lst) print(it is iter(it)) # True这个特性保证了 for 循环在语法上的统一无论你传入的是一个列表还是一个迭代器for 循环内部执行iter(obj)时都能稳当地拿到一个迭代器来迭代。这也是为什么很多标准库函数直接接受“可迭代对象”作为参数而不用关心你传的到底是哪种类型。理解这几个概念只是热身。要真正说清楚迭代器为什么被设计成这样还得从内存效率讲起。下一节我用 range 和大文件两个场景来实测一下。2. 惰性求值迭代器最值得说的一项设计2.1 “取一个、吐一个”绝不提前加班迭代器最核心的工作方式是惰性求值lazy evaluation。通俗讲就是你不找它要它就不给你你每要一个元素它才临时算一个出来算完就忘绝不囤货。这跟现实里“食堂打饭”很像你走到窗口前阿姨才给你舀一勺而不是提前把全校几千人的饭都盛好放在窗口等你。如果真把所有元素一次性生成出来再遍历内存压力会非常大尤其是处理大文件、数据库查询结果这类数据源时。也许有读者会想惰性求值会不会让代码变慢实际上绝大多数业务场景下逐次计算的开销远小于一次性生成大量临时对象的开销而且省下的内存往往比省下的那点 CPU 时间更值钱。尤其是当你的服务器内存本身就紧张的时候惰性求值是救命级别的设计。2.2 实测 range(10**8) 的内存占用我们用 Python 内置的sys.getsizeof做一个直观的对比实验import sys big_list list(range(10**8)) print(sys.getsizeof(big_list)) # list 版本的内存占用 print(sys.getsizeof(range(10**8))) # range 对象的内存占用range 对象保存的只是 start、stop、step 这几个参数需要取值时现场计算所以只占几十个字节。而 list 版本在 64 位 Python 中光是指针数组就已经是 8 字节乘以一亿差不多 800MB这还没算整数对象本身的开销。也就是说range(10**8)本身轻如鸿毛但如果你手贱在外面包了一层 list内存就彻底爆了。注意list(range(10**8))在内存小的机器上很可能直接导致内存不足建议不要在本地随便试。真要遍历这么大的范围直接for i in range(10**8)就好。这就是为什么 Python 3 中 range 不再像 Python 2 那样真的构造一个巨型列表。语言层面已经把迭代器思想内化到了内置对象中只是很多初学者根本没有意识到。2.3 大文件读取readlines 和逐行 for 的差别再看一个更贴近日常工作的工程场景。处理文本日志时新手很容易写出这样的代码with open(app.log, r) as f: lines f.readlines() # 一次性把所有行读进内存 for line in lines: process(line)如果文件只有几 MB问题不大。但生产环境里单日日志动辄几个 GBreadlines()会一次性把整个文件读入内存很容易把服务器内存吃满甚至直接把定时任务跑挂。我有段时间负责清理线上日志就亲眼见过一个几 GB 的 Nginx 日志文件把同事写的readlines()脚本活活卡死。正确姿势是利用文件对象本身就是迭代器这一点直接with open(app.log, r) as f: for line in f: process(line)这两种写法在结果上几乎一致但内存表现天差地别。逐行遍历时文件迭代器每次只从缓冲区拉回一行处理完上一行后它占用的内存就能被回收再利用。实际线上环境里我用这个方法处理过几个 GB 的日志文件内存峰值始终控制在几十 MB 级别。类似地数据库查询也可以用.fetchmany()分批取出而不是一次性把几百万行全装进内存再处理。对这些场景而言迭代器不是“锦上添花”而是“不做就会出事”。3. 从 0 到 1手写你自己的迭代器理解了迭代器协议之后就可以自己实现一个迭代器了。这在封装自定义数据结构、做数据管道的时候非常有用。虽然日常开发中直接手写迭代器的频率不算高但把这个过程走一遍你会对协议的理解上一个台阶。3.1 规范iter和next缺一不可在 Python 里一个对象要成为迭代器必须实现两个方法__iter__(self)返回迭代器自身。这样iter()调用它时就能拿到一个迭代器。__next__(self)返回下一个元素没有更多元素时抛StopIteration。如果你只想让一个对象“可被迭代”而不是让它成为迭代器那只需要实现__iter__返回一个迭代器对象即可。但如果这个对象自己就是迭代器比如下面这个斐波那契例子那__iter__通常就返回self。3.2 实例可定长的斐波那契迭代器斐波那契数列是特别适合练手迭代器的例子因为它天然是“按需生成”根本不需要预先算好所有项。class FibonacciIterator: 生成斐波那契数列的迭代器最多生成 max_count 个。 def __init__(self, max_countNone): self.max_count max_count self.count 0 self.a, self.b 0, 1 def __iter__(self): return self def __next__(self): if self.max_count is not None and self.count self.max_count: raise StopIteration result self.a self.a, self.b self.b, self.a self.b self.count 1 return result调用方式fib FibonacciIterator(10) for num in fib: print(num) # 0 1 1 2 3 5 8 13 21 34注意__next__里最先做的是判断是否到上限到了就抛StopIteration这样 for 循环才会正常结束。如果不设max_count这个迭代器理论上可以无限生成使用时就要靠外部条件控制何时跳出。3.3 实例大日志文件的分块读取迭代器斐波那契偏向教学下面这个更贴近实际需求。假设你有一个超大文本文件想按固定行数分块读取而不是一次性读入。这个需求在日志分析、批量导入场景里很常见class ChunkReader: 按指定的块行数读取文本文件。 def __init__(self, file_path, chunk_size1000): self.file_path file_path self.chunk_size chunk_size self._file None def __iter__(self): self._file open(self.file_path, r, encodingutf-8) return self def __next__(self): if self._file is None: raise StopIteration lines [] for _ in range(self.chunk_size): line self._file.readline() if line : break lines.append(line) if not lines: self._file.close() self._file None raise StopIteration return lines使用起来非常干净for chunk in ChunkReader(huge.log, chunk_size2000): process(chunk)这里的__iter__在每次重新迭代时打开文件__next__每次收集 2000 行直到文件读完了关掉文件并抛StopIteration。调用方完全不关心文件内部怎么读只需要用 for 循环去消费 chunk 就行数据管道写起来非常舒服。提示如果文件行数恰好是 chunk_size 的整数倍最后一次循环会在读完最后一批后再次调用__next__此时 lines 为空所以必须正确关闭文件并抛StopIteration。这个边界一定要处理否则会出现死循环或读到空块。写到这里相信你已经发现手写迭代器并不难但代码量确实不少。如果只是想在函数里临时生成一个可迭代序列Python 还提供了一种更轻量的写法——生成器。4. 生成器更轻量、更优雅的迭代器工厂4.1 yield 一出现函数就“变质”了在 Python 里任何包含yield关键字的函数调用后并不会立刻执行函数体而是返回一个生成器对象。生成器本身就是一个迭代器它把上一节手写的状态保存、StopIteration 处理等底层细节全部自动搞定。用生成器重写斐波那契def fibonacci(max_countNone): a, b 0, 1 count 0 while max_count is None or count max_count: yield a a, b b, a b count 1 for num in fibonacci(10): print(num)对比手写类版本代码量几乎砍了一半而且可读性更强每次执行到yield就会暂停把a的值返回给调用方下一次 for 循环继续时从暂停的地方往下走a、b、count这些局部变量都还在。理解到这一层再看那些用生成器写的代码就不会觉得是魔法了。4.2 生成器表达式列表推导式的“内存友好版”列表推导式大家很熟悉squares [x * x for x in range(1000000)] # 一次性生成 100 万个元素如果只是遍历一次完全可以用生成器表达式把方括号换成圆括号squares (x * x for x in range(1000000)) # 惰性生成的生成器两者的内存差异非常大。前者立即生成一个装满 100 万元素的列表后者只是一个生成器对象等遍历时再逐个计算。我经常在需要把数据传给sum()、min()、max()等聚合函数时使用生成器表达式既能减少临时列表代码也不难读。比如计算 1 到 1 亿的平方和用生成器表达式就不会把一个上亿元素的列表放进内存。4.3 yield from摊平嵌套迭代如果生成器内部还要再迭代另一个可迭代对象可以用yield from简写def flatten(nested): for sublist in nested: yield from sublist for x in flatten([[1, 2], [3, 4], [5]]): print(x) # 依次输出 1 2 3 4 5它的效果和下面的代码等价def flatten(nested): for sublist in nested: for item in sublist: yield itemyield from省掉了一层 for 嵌套在递归处理树形结构、流式解析嵌套数据时特别方便。比如从多层嵌套的 JSON 结构中提取所有叶子节点用yield from递归生成器几乎是最简洁的写法。4.4 生成器不只是能取值send、throw、close生成器还可以接收外部传入的值这就是send()。它的使用场景不如常规迭代那么频繁但在写协作式任务、状态机时非常有用。简单示例def echo(): while True: received yield print(收到:, received) gen echo() next(gen) # 启动生成器走到第一个 yield gen.send(hello) # 输出: 收到: hellothrow()可以在生成器内部抛出异常close()则用来手动结束生成器。这些方法在基础的 for 循环里用不到但如果你理解迭代器协议再看它们就会觉得很自然本质上都是调用方和生成器之间的通信与控制。面试中聊到这里往往能体现出你对 Python 的掌握程度不止于增删改查。5. 迭代器不只是 for 循环的陪衬三个值得主动使用的场景5.1 无限序列 islice按需求切片生成器本身没有长度。当你拥有一个无限序列时不能直接for i in infinite_seq那样会永远跑下去。而itertools.islice正好是迭代器的“切片工具”可以从迭代器中精准取出前 N 个元素from itertools import islice def natural_numbers(): n 1 while True: yield n n 1 first_ten list(islice(natural_numbers(), 10)) print(first_ten) # [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]这个组合在数据流处理里非常实用。比如你有一个不断产生数据的传感器接口或者一个持续增长的日志流用islice定期取一截出来分析而不用把整个流全部加载进来。5.2 itertools 组合拳chain、count、cycle标准库itertools里几乎全是针对迭代器的高频工具chain(*iterables)把多个可迭代对象串成一个避免写嵌套循环。count(start, step)无限计数配合islice做等差数列。cycle(iterable)无限循环某个序列做轮询调度时很好用。from itertools import chain, cycle, islice merged list(chain([1, 2], [a, b])) # [1, 2, a, b] jobs list(islice(cycle([worker_a, worker_b]), 4)) # [worker_a, worker_b, worker_a, worker_b]这些小工具写业务代码时不一定天天用得上但一旦遇到“把多个序列串起来遍历”、“轮流分配任务”这类需求它们比手写一堆 for 循环和临时变量优雅得多。5.3 zip 与 enumerate 的“一次性”提醒zip和enumerate的返回值在 Python 3 中都是可迭代对象有些还是迭代器。最常见的坑是把zip的结果重复遍历第一次能打印第二次却什么也没有。pairs zip([a, b], [1, 2]) print(list(pairs)) # [(a, 1), (b, 2)] print(list(pairs)) # [] - 第二次已经空了解决办法很简单如果需要反复使用就显式转成 list如果只遍历一次用迭代器反而更省内存。另外一个工程细节是当两个列表很长时不要用for i in range(len(a))再通过下标去取b[i]直接用zip(a, b)或enumerate(a)会更清晰也避免了下标越界问题。这也是把迭代器思想应用到日常编码中的体现。6. 我在项目里踩过的三个“迭代器”坑6.1 迭代器被消费后不会回到原点这是我见过最多的错误。很多人在第一次遍历生成器后以为还可以像列表一样再遍历一次numbers (x for x in range(5)) print(sum(numbers)) # 10 print(sum(numbers)) # 0sum()内部会遍历并消费整个生成器第二次调用sum()时它已经空了所以结果是 0。如果业务逻辑里确实需要多次求和或多次遍历请用列表保存数据源如果数据太大不能转列表就重新创建生成器。这个坑在写数据处理流水线时尤其容易踩因为你可能在一个函数里把迭代器传进去又传出来绕了一圈它已经被上一个调用者“榨干”了。6.2 遍历字典时千万不要改它的大小在 for 循环遍历 dict 的同时如果往字典里添加或删除键会触发一个RuntimeError: dictionary changed size during iteration。这是因为字典迭代器在迭代时会检查字典的结构版本号。如果确实需要“边遍历边过滤”有两种常见做法一是先拷贝 key 列表再循环二是用字典推导式这在绝大多数场景下更优雅data {a: 1, b: 2, c: 3} filtered {k: v for k, v in data.items() if v 1}顺带一提修改字典中已有键对应的值是可以的因为这不改变字典大小不会触发运行时错误但最好也统一用更新方式处理避免依赖这种隐式行为。6.3 别动不动就把迭代器转成 list把迭代器转成 list 虽然方便调试但会破坏惰性求值带来的红利。一些新手在拿到文件对象、数据库游标后第一件事就是list(...)结果大文件照样把内存吃满。调试时可以配合itertools.islice先取一小部分看看from itertools import islice first_5 list(islice(huge_sequence, 5)) print(first_5)这样既看到了数据又没有一键加载全部内容。我个人的习惯是迭代器一般都保持它的惰性只有在确实需要随机访问、反复遍历时才转成 list。6.4 判断对象是否可迭代的正确姿势很多初学者用type(obj) list来判断能不能 for 遍历。实际上只要实现了__iter__或__getitem__的对象都能被 for 循环使用。更稳的判断方式是用collections.abcfrom collections.abc import Iterable, Iterator print(isinstance([1, 2, 3], Iterable)) # True print(isinstance([1, 2, 3], Iterator)) # False it iter([1, 2, 3]) print(isinstance(it, Iterator)) # True注意 list 是Iterable但不是Iterator这个判断方法能把概念区分得清清楚楚。配合第一节讲的“能不能next()”来判断基本就不会搞混了。写代码这些年我越来越觉得迭代器看似基础却贯穿了 Python 的风格约定协议、惰性求值、轻量优雅。花点时间把 for 循环里的隐式行为拆开看一遍之后读源码、写自定义数据结构、优化数据处理流程都会顺畅很多。最后再分享一个小技巧如果你在阅读源码时看到某处返回了一个生成器不要急着把它转换成列表先想想调用方到底需要什么很多时候保持惰性才是对内存和性能最好的尊重。
返回列表