资讯动态

迭代器与生成器:从内存爆炸到惰性求值的实战指南

发布时间:2026/10/17 12:10:29 来源:尧图企业网站定制
日志追到一半内存先炸了——这是我第一次在真实项目里被“非迭代器写法”狠狠教育。当时处理一批百万级的业务日志图省事直接readlines()一次性读进内存结果进程当场 OOM旁边的老同事瞟了一眼说换成迭代器一行行读别一口气吞。那会儿我还觉得迭代器就是“能 for 循环的东西”直到自己动手把整套解析逻辑改成生成器版本才真正明白这两个概念背后的分量。这篇就聊聊迭代器与生成器——它们到底是什么、为什么能省内存、在真实场景里怎么用、以及我踩过的那些坑给初学阶段的朋友一份能直接上手的参考。1. 迭代器与生成器的本质区别从“序列”到“流”的思维转变1.1 迭代器的底层契约不是“有数据”而是“能一个接一个给数据”很多教材喜欢把迭代器定义为“实现了迭代协议的对象”这个说法对但不够直觉。我的理解是迭代器是一种“服务承诺”——它不承诺把所有数据一次性摆在你面前只承诺每次调用next()时给你下一个元素给完了就抛异常告诉你“结束了”。以 Python 为例迭代器协议要求实现__iter__和__next__两个方法。__iter__返回迭代器自身__next__返回下一个元素没有元素时抛StopIteration。这段逻辑用代码看最直白class CounterIterator: def __init__(self, limit): self.limit limit self.current 0 def __iter__(self): return self def __next__(self): if self.current self.limit: self.current 1 return self.current raise StopIteration counter CounterIterator(3) print(next(counter)) # 1 print(next(counter)) # 2 print(next(counter)) # 3 # print(next(counter)) # 这里会抛 StopIteration这个类本身没有任何“容器”属性它不存 1、2、3 这三个数它只是在__next__被调用时“算出”下一个数。这就是迭代器与列表最本质的区别列表是“存储”迭代器是“生产”。你拿到的不是一个数据快照而是一个数据通道。C 里的迭代器也是同一个逻辑只不过它用运算符重载来表达——begin()和end()定义区间operator移动指针operator*解引用取当前值。Java 则是IteratorT接口hasNext()判断还有没有next()取下一个。不同语言的实现细节各不相同但底层思想完全一致迭代器定义了一套“逐个访问”的协议把“容器长什么样”和“怎么遍历它”两件事彻底解耦了。1.2 生成器是“带状态的函数”不是“偷懒的列表”生成器常被误解为“一个不怎么占内存的列表”这个类比方便但有误导性。生成器本质是一个“暂停计算的函数”——它不是一个数据结构而是一个控制流机制。看最经典的斐波那契生成器def fibonacci(): a, b 0, 1 while True: yield a a, b b, a b fib fibonacci() for _ in range(7): print(next(fib), end ) # 0 1 1 2 3 5 8函数从while True开始执行遇到yield a时“定住”——当前所有局部变量a、b的值都被保存在函数帧里执行权交还给调用者。下一次调用next(fib)时不是从头执行这个函数而是从yield那一行接着往下走再回到循环顶部再遇到yield又暂停。这就是生成器最反直觉的地方它看起来是一个函数但执行是分段的。你可以把它看作一个“协程的雏形”——不仅能产出数据还能在函数内部保留状态。这个“状态保持”能力在写状态机、流式解析、增量计算时特别好用。JavaScript 的生成器同样基于这个模型function*yield而且它还有一个 Python 生成器没有的能力yield表达式可以接收外部传进来的值实现双向通信。后面我会单独用案例讲。1.3 一句话分清两者关系严格来说生成器是迭代器的一个子集——所有生成器都是迭代器但迭代器不一定是生成器。一个手写的迭代器类需要自己管理状态self.current这种而生成器把状态管理交给了函数帧写起来更简洁。但更重要的区分是我个人在实际开发中的体会迭代器是“契约”生成器是“实现方式”之一。你可以用类实现迭代器也可以用生成器实现迭代器还可以用第三方库实现。关键掌握的是“惰性求值”这个思想——管它这个数据是算出来的、读出来的、还是从网上下载下来的只要它能一个接一个地给它就是迭代器。2. 惰性求值的经济账内存占用、执行时机与性能权衡2.1 一个百万行日志文件的对比实验只看概念永远体会不到“省内存”三个字的分量。我模拟了一个场景一个 CSV 文件有 100 万行每行 30 个字段约 150MB。分别用“一次性读入”和“迭代器逐行读”两种方式统计内存峰值和执行时间。import csv import os import tracemalloc import time # 生成一个 100 万行的测试文件 with open(test.csv, w, newline) as f: writer csv.writer(f) for i in range(1_000_000): writer.writerow([fuser_{i}, i % 100, i * 1.5, fnote_{i}]) print(文件大小, round(os.path.getsize(test.csv) / 1024 / 1024, 1), MB) # 方式一readlines 一次性读入 tracemalloc.start() start time.time() with open(test.csv) as f: lines f.readlines() total sum(len(line) for line in lines) print(方法一 耗时:, round(time.time() - start, 2), s) print(方法一 峰值内存:, round(tracemalloc.get_traced_memory()[1] / 1024 / 1024, 1), MB) tracemalloc.stop() # 方式二迭代逐行读 tracemalloc.start() start time.time() total 0 with open(test.csv) as f: for line in f: total len(line) print(方法二 耗时:, round(time.time() - start, 2), s) print(方法二 峰值内存:, round(tracemalloc.get_traced_memory()[1] / 1024 / 1024, 1), MB)实测结果很有代表性方式峰值内存耗时readlines 一次性读入约 230MB0.42sfor row in f 逐行迭代约 3.5MB0.45s内存差了将近 70 倍耗时几乎一样。原因不复杂readlines()把每行内容都创建成独立的字符串对象放进列表一亿多个字符加上每个字符串对象的开销每个约 50 字节直接撑爆小内存机器而迭代器方式每一行用完就丢当前内存里最多只有一行数据。2.2 惰性求值的另一面执行时机后移惰性求值带来的不只是内存节省还有一个隐含特性——计算被推迟到真正需要的时候。这既是优点也是陷阱。优点很明显可以构建“无限序列”。传统列表必须预先知道所有元素而生成器不需要因为它根本不“预先”做任何事每次都是现算现给。像斐波那契、素数筛选、按需生成行这种场景没有惰性求值根本写不出来。陷阱则藏在副作用里。惰性意味着yield之前的代码不会立刻执行def lazy_reader(): print(打开文件) with open(big.txt) as f: for line in f: yield line.strip() gen lazy_reader() print(生成器已创建但还没打开文件) # 只有第一次 next(gen) 时才会输出 打开文件这个特性在调试时容易误导人——你以为函数已经执行了其实它还停在原地没动。后面讲陷阱时我会再展开。2.3 性能折衷生成器不是万能加速器生成器的主要收益在内存不在速度。如果数据本来就可以被紧凑地放在内存里比如一个不大不小的列表map/列表推导式往往比生成器表达式更快因为省去了逐次next()调用的函数帧跳转开销。我做了个简单测试对 100 万个数字做平方运算。方式耗时列表推导 [i*i for i in range(1000000)]0.06s生成器表达式 (i*i for i in range(1000000)) 配合 sum0.11s自定义生成器函数配合 for 循环0.16s差距不算大但在高频率、大规模数值计算里这个差异会积累。所以我的选型原则是吃内存的场景无脑迭代器吃 CPU 的场景优先列表推导如果两者都要省再考虑迭代器配合批量处理。3. 真实战场中的生成器模式分块读取、无限序列与状态机3.1 分块读取大文件的完整实现“一行行读”解决了单行内存问题但很多场景按“行”处理效率低——比如解析一个 5GB 的 JSON 数组文件按行读会发现一行可能几百MB直接爆掉。更好的方案是按“块”读自定义一个分块生成器def read_chunks(file_path, chunk_size64 * 1024): with open(file_path, rb) as f: while True: chunk f.read(chunk_size) if not chunk: break yield chunk # 使用逐块计算整体哈希 import hashlib hasher hashlib.md5() for chunk in read_chunks(bigfile.bin): hasher.update(chunk) print(hasher.hexdigest())这段代码的意义在于你不需要关心文件多大不需要把整个文件载入内存只需要循环取“下一块”。流式哈希、流式压缩、边下边写都能用这套模式。3.2 用无限序列做“按需计算”素数判断的经典写法生成器配合“已知素数表”做边生成边筛选。不用列表推导因为列表推导要求预先知道全部元素而素数是无限序列只能生成器来承载。def primes(): known_primes [] n 2 while True: for p in known_primes: if n % p 0: break else: known_primes.append(n) yield n n 1 prime_gen primes() print([next(prime_gen) for _ in range(10)]) # [2, 3, 5, 7, 11, 13, 17, 19, 23, 29]注意这里利用了一个易被忽视的语法for...else。当循环没有被break中断时执行else块。这正是“判断 n 是否被任何已知素数整除”的简洁写法。另一个更实际的场景指数退避重试。你需要生成一组延迟时间1s、2s、4s、8s……可以无限生成但在真实代码里可能只需要前 5 个def backoff_timings(base1, factor2, max_value60): current base while current max_value: yield current current * factor for wait in backoff_timings(): print(f等待 {wait}s 后重试) # 模拟重试逻辑 if wait 4: print(第4次重试该放弃了吗不继续)3.3 生成器当状态机从“状态变量”到“状态位置”写解析器时最头疼的是状态管理。传统写法用一个state变量加一堆 if/elif 判断当前状态逻辑稍微复杂一点就变成一锅粥。生成器方案最大好处是函数执行到的位置本身就是状态。举个例子解析一段文本需要提取所有“被双引号包裹的字符串”但字符串内部可能出现转义引号。用普通循环写要仔细维护in_string和escaped两个标志位用生成器写则清晰很多def extract_quoted(text): i 0 n len(text) while i n: # 找左引号 if text[i] : i 1 result [] # 进入字符串内部状态 while i n: char text[i] if char \: if i 1 n: result.append(text[i 1]) i 2 continue elif char : i 1 break result.append(char) i 1 yield .join(result) else: i 1 text rhello world and escaped \ quote done print(list(extract_quoted(text))) # [world, escaped quote]这个函数每一次yield都意味着“当前读取进度”被保存下次继续从刚才断掉的地方接上。状态没有存在变量里而是存在“代码执行到哪一行”里。这种写法在处理流式协议解析比如逐行解析 HTTP 头、日志格式时非常有用。3.4 双向通信JavaScript 生成器与 Python 的差异对照Python 的生成器是“单向管道”——函数向调用者推送数据。JavaScript 的生成器升级为“双向通道”yield表达式能接收调用者传入的值function* interact() { const name yield 你叫什么名字; const age yield 你好 ${name}你多大了; yield ${name} 你已经 ${age} 岁了; } const gen interact(); console.log(gen.next().value); // 你叫什么名字 console.log(gen.next(小明).value); // 你好 小明你多大了 console.log(gen.next(18).value); // 小明 你已经 18 岁了第一次next()启动函数执行到第一个yield返回问题第二次next(小明)把“小明”作为第一个yield表达式的返回值并继续执行到第二个yield。这个特性让生成器可以很自然地做“讨论式”的交互实现复杂协议处理时能写出“两端对话”的效果。Python 里模拟这种双向通信需要使用generator.send()方法语法上不如 JS 直观但这个机制本身是通用的。4. 不同语言下的同与不同Python、JavaScript、Java 与 C 的对照4.1 语法形态与协议差异总览不同语言实现迭代器和生成器的方式差别很大理解这些对比能帮你换语言时不至于重新学两遍语言迭代器协议生成器关键字惰性内建容器Python__iter__/__next__yieldrange,zip,map等JavaScriptSymbol.iterator/next()function*/yieldGenerator对象JavaIteratorE接口无原生生成器Stream惰性中间操作C迭代器类型 运算符重载C20 协程 / 手写生成器类视图views::最核心的差异集中在“协议如何表达”上。Python 用下划线魔法方法JavaScript 用SymbolJava 用接口C 用模板迭代器。但本质上都是三件事怎么开始iter 方法、怎么前进next 方法、怎么结束异常或特殊返回值。我个人的建议是先在一门语言里吃透“为什么”再迁移到其他语言时就很顺。协议细节可能变设计思想不变。4.2 Python 的 range 与迭代器一个“常被白学”的工具Python 里有个最常用但最容易被低估的迭代器——range。新手往往把它当“返回列表的函数”其实range返回的range对象是一个惰性序列print(type(range(10))) # class range # range 对象支持 len 和索引因为它实现了序列协议 r range(10) print(len(r)) # 10 print(r[3]) # 3 # 但它的底层并不直接存储所有元素 # range(1_000_000_000) 依旧瞬间创建它支持索引和len说明它不是“纯迭代器”而是“可迭代序列”。但它的内存占用不随范围增大而增大因为它只存start、stop、step三个值取元素时现算。这常常被忽略却是“惰性求值”最成功的平民化案例。4.3 C 与 Java 的“反向”入门视角C 的迭代器模式是 STL 的灵魂。标准容器的begin()和end()返回迭代器算法库通过迭代器操作容器这种解耦使得同一套算法可以同时作用于vector、list、deque甚至自定义容器。#include algorithm #include iostream #include vector int main() { std::vectorint data{5, 2, 8, 1, 9}; // 范围 for 循环底层就是迭代器语法糖 for (auto x : data) { std::cout x ; } std::cout \n; // 排序算法作用于迭代器区间 std::sort(data.begin(), data.end()); for (auto x : data) { std::cout x ; } return 0; }C 里没有“生成器”内建概念C20 引入协程后才有关键字支持但在此之前大量库使用“自定义迭代器”模拟生成器。这告诉我一个道理迭代器协议是先于关键词存在的生成器不过是“一种方便地创建迭代器的方式”。Java 的IteratorE接口只有三个方法hasNext()、next()、remove()默认已弃用。它比 Python 更显式——没有StopIteration你自己先用hasNext()问一下。Java 8 之后加入的StreamAPI 则提供了函数式的惰性处理能力中间操作如filter、map是惰性的只有遇到终止操作如collect才会真正执行。本质上Stream 就是 Java 世界里的“迭代器 惰性计算”。5. 新手最容易踩的五个迭代陷阱从“一次用完”到“隐式缓存”5.1 一次性的迭代器复用了就“全没了”迭代器大多数是“一次性”的——遍历完就没有了。特别容易踩坑的场景是用同一个生成器做两次遍历gen (x * x for x in range(5)) print(sum(gen)) # 30第一次遍历正常 print(sum(gen)) # 0第二次遍历结果为 0因为生成器已经耗尽 # 用同样来源创建两个生成器就没问题 my_list [1, 2, 3, 4, 5] gen1 (x * x for x in my_list) gen2 (x * x for x in my_list) print(sum(gen1)) # 55 print(sum(gen2)) # 55如果你不确定一个对象是可复用序列还是一般迭代器可以检查它是否有__iter__之外的序列属性。更保险的做法是需要多次遍历时直接用列表用迭代器时明确“只消费一次”。5.2 生成器函数不会“先干活”调试时容易始料未及前面提到过生成器函数被调用时函数体不会立即执行而是返回生成器对象。真正开始执行发生在第一次next()或for循环开始时。这影响最大的场景是“事务型”代码def process_items(items): print(开始处理) # 假设这里有重要的初始化操作 for item in items: yield process(item) # 你以为调用 process_items 就会开始处理其实没有 processor process_items([1, 2, 3]) print(初始化了吗还没) # 只有 for 循环触发时才真正执行 for result in processor: print(result)调试时如果你在“调用生成器函数”那行打断点会“看不到”函数体里的内容因为还没执行。我给新手的建议是把生成器函数想象成配方而不是食物调用它是拿到配方不是吃到食物。5.3 持有迭代器引用导致内存无法释放迭代器看起来“轻”但如果你持有它的引用且它内部持有大数据对象的引用垃圾回收可能做不到你想的那么及时。典型场景迭代器包着一个大列表的引用迭代结束后你以为可以释放了但如果生成器对象还没被 GC大列表就还在。big_data [i for i in range(10_000_000)] # 大列表 def iter_big(): for x in big_data: yield x # 用完迭代器后把生成器对象赋值为 None gen iter_big() for x in gen: pass gen None # 主动释放真正的问题在于iter_big函数内部的big_data引用。如果生成器对象停在某个状态比如被异常打断它会一直持有big_data内存释放只能等 GC。所以用完生成器后赋None或者用contextlib.closing显式关闭都是不错的习惯。5.4 在列表推导式里“存了整个无限序列”生成器表达式转列表本质上是终结惰性。有人为了调试方便写list(gen)如果这个 gen 是无限序列程序直接死循环。def infinite_numbers(): n 0 while True: yield n n 1 # 危险list(infinite_numbers()) 会直接卡死 # 正确做法用 itertools.islice 截断 import itertools first_ten list(itertools.islice(infinite_numbers(), 10)) print(first_ten) # [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]解决方案就是itertools.islice(gen, n)。这个函数本身也是惰性的截取前 n 个之后就会停止消费不会把无限序列拉满。5.5 生成器嵌套时的“串行”陷阱当多个生成器级联时一个常见误解是“它们会并行执行”。实际上 Python 的生成器没有并行它们是严格串行的——内层生成器每产出一个元素就暂停外层生成器拿到后处理再暂停如此循环。def double(xs): for x in xs: yield x * 2 def plus_one(xs): for x in xs: yield x 1 # 数据流源数据 → plus_one → double nums [1, 2, 3] result double(plus_one(num for num in nums)) print(list(result)) # [4, 6, 8]执行流程是double向plus_one要一个元素plus_one向源迭代器要一个元素源给出 1plus_one产出 2double乘 2 得 4。然后继续。这个过程是推拉结合的“下单式”数据流——需要才生产但一次只生产一个。理解这个串行模型才能避免在生成器链里放入耗时操作导致“卡顿”的错觉。6. 写在后面用迭代器和生成器的正确姿势每次带新人看代码发现他们最容易陷入两个极端要么能列表推导就列表推导绝不写生成器要么为了省内存强行把所有数据都改成迭代器结果代码晦涩难懂调试成本大增。我个人的选型标准实用性强于理论正确性第一数据规模超过可用内存的百分之一或者无法预估总量数据从网络流、外部设备读入这种情况必须用迭代器。省的是内存治的是“假性能”——你用 readlines 看着快其实只是把内存爆掉的时间提前了而已。第二小规模数据几百几千条直接用列表更合适。列表可以随机访问、切片、复用、调试时一目了然这些都是迭代器难以替代的。一个小技巧是可以先写出列表版本等确认数据量成为瓶颈后再改成生成器版本提前“优化”往往只是给未发生的灾难做准备。第三写库或者设计接口时要尽量让接口接受“任何可迭代对象”而不是强制要求列表。这样调用方能自由选择传列表还是传生成器灵活性全在调用方那里。这个设计原则在写数据处理管道、加载器、会话处理器时特别好用。最后分享一个我常用的调试技巧在写不熟的生成器逻辑时可以在重要的yield前后加print输出“进入/退出”标记观察执行顺序是否符合预期。因为生成器的惰性这种带标记的调试方式比断点调试更直观。实践过一轮之后你会发现迭代器和生成器不再是一堆概念名词而是解决问题时顺手就来的工具。就像我开头说的真正被内存问题教育过一次之后你会在写每一行数据处理代码前先问自己“这里要的是快照还是流”——想清楚了代码的格局就打开了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑