
我先说个观点如果你把可迭代对象、迭代器、生成器这三者的关系理顺了yield和生成器表达式就只是同一个体系下的两种写法而已。我这些年看下来Python 里最值得花时间啃的不是某个框架的 API反而是这一组看着绕、用着爽的迭代机制。很多人写了两三年 Python天天用for循环、列表推导式但真被问到“生成器和迭代器什么区别”时还是只能答出一句“好像生成器比较省内存”。这篇文章我就把这几个概念彻底拆开从协议层面讲到实战踩坑再给出一套可以直接抄的代码模板。无论你是刚入门还没理顺还是已经写了几年想补一补底层认识这都适合读一遍。1. 把概念摆到台面上可迭代对象、迭代器到底是谁1.1 先搞清“能被 for 遍历”和“能产生遍历状态”这两个级别很多人第一步就死在这里把“可迭代对象”和“迭代器”当成同一个东西。其实它们是两个层级的东西用一句人话说可迭代对象可以被for遍历的对象比如列表、元组、字典、字符串、文件对象、数据库游标。迭代器负责实际产生遍历结果的对象它内部保存当前位置每次调用next()都往后移动一步。打个比方。可迭代对象是一整叠扑克牌你可以随时重新洗牌、重新发迭代器则是一个正在发牌的荷官他手上有一个“发到哪了”的状态你每要一张他就给你发下一张发完就没了。你当然可以通过重新训练一个荷官调用iter()再从头来但这副牌本身并不是荷官。这个区分不是咬文嚼字它直接决定了你能不能“遍历两次”。列表是可迭代对象它不是迭代器所以两个for循环都能完整遍历lst [1, 2, 3] for x in lst: print(x) # 1 2 3 for x in lst: print(x) # 1 2 3还能再来一次因为每次执行forPython 都会偷偷调用iter(lst)返回一个全新的迭代器。而如果对象本身就是迭代器它内部那个“当前走到哪”的状态就一直留在对象里遍历一轮之后状态就到头了再for一次什么都没有。1.2 迭代协议、iter() 与 next()以及一个容易被忽略的历史兼容机制Python 里所谓“迭代协议”其实只有两条核心规则一个对象实现了__iter__()那它就是可迭代对象调用iter(obj)时返回一个迭代器。一个对象实现了__next__()那它就是迭代器调用next(it)时返回下一个值没有值了就抛StopIteration。现实中有个加分项是迭代器通常也实现__iter__()并且返回自身这样迭代器本身也是可迭代对象能被for直接遍历。还有一个很冷门但实用的兼容机制如果一个对象没有__iter__()但实现了带整数下标的__getitem__()那么iter()会退回到“序列协议”从下标 0 开始依次取obj[0]、obj[1]……直到出现IndexError才结束。这个机制从 Python 2 时代就留下了我见过有人靠它让一个自己写的分页类直接支持for循环连迭代器都不用写class PageRange: def __init__(self, total): self.total total def __getitem__(self, index): if index self.total: raise IndexError return fpage-{index 1} for p in PageRange(3): print(p) # page-1 # page-2 # page-3看到没它没有__iter__、没有__next__依然能遍历。很多老代码能跑靠的就是这套兜底方案。不过我不建议新代码特意这样写知道有这回事遇到老代码时心里有数就够了。1.3 用 isinstance 验证“列表不是迭代器但迭代器一定是可迭代对象”概念是不是只停在嘴上用标准库的 ABC 一验就知道。collections.abc里提供了Iterable和Iterator两个抽象基类可以直接isinstancefrom collections.abc import Iterable, Iterator lst [1, 2, 3] print(isinstance(lst, Iterable)) # True print(isinstance(lst, Iterator)) # False it iter(lst) print(isinstance(it, Iterable)) # True print(isinstance(it, Iterator)) # True这个结果很关键列表是可迭代对象但不是迭代器而iter(lst)得到的东西既是可迭代对象因为迭代器自己也实现了__iter__又是迭代器。所以记结论时不要记成“可迭代对象包含迭代器”应该记成迭代器一定是可迭代对象可迭代对象不一定是迭代器。这个方向一反后面遇到的很多怪问题就都能想通了。2. 生成器yield 让普通函数变成一台可暂停的机器2.1 yield 的本质保存现场交出控制权生成器本质上是“用函数语法写出来的迭代器”。普通函数执行到return就彻底结束了局部变量全部释放而含yield的函数不一样它每次执行到yield时会做两件事把yield后面的值返回给调用方冻结当前函数的所有局部状态等下一次next()调用时从冻结的位置继续执行。所以生成器不是“一次算完”而是“算一步停一步”。我先写一个最典型的倒计时例子def countdown(n): print(函数体还没执行调用 countdown() 本身不会打印这句) while n 0: yield n n - 1 gen countdown(3) print(已经调用了 countdown(3)但上面那句 print 没出现) print(next(gen)) # 3 print(next(gen)) # 2 print(next(gen)) # 1 print(next(gen)) # 抛 StopIteration注意countdown(3)这行代码只是创建了一个生成器对象函数体一行都没运行。真正的执行是从第一次next(gen)开始的这也是“惰性求值”最直白的体现。for循环之所以能遍历生成器就是因为在背后不断调用next()直到StopIteration出现后悄悄吞掉这个异常。整个过程不需要你手动管理异常也正因为如此很多人一直在用生成器却没意识到它到底是什么。2.2 给生成器反向传值send() 的正确启动姿势yield不只是往外吐数据它还能接收数据。生成器有个send()方法可以把值“喂”回到生成器内部这个值是上一次挂起时yield表达式的整体值。写一个累加器来演示def accumulator(): total 0 while True: value yield total if value is None: continue total value acc accumulator() print(next(acc)) # 必须先用 next() 或 send(None) 启动 print(acc.send(10)) # 生成器内部收到 10继续到下一个 yield返回 10 print(acc.send(5)) # 返回 15这里有个非常经典的坑生成器刚创建出来时还没执行到yield此时直接send(10)会抛TypeError: cant send non-None value to a just-started generator。原因是生成器内部根本还没有一个挂起的yield可以去接收值。所以第一次推进必须用next(gen)或者gen.send(None)。send()这种反向通道在实际业务里用得比想象中多。比如一个数据处理管道下游处理完一批数据后想告诉上游“下批发小一点”用send()能把反馈信号传回生成器内部调整它后续的行为。2.3 生成器表达式一行代码写出“懒列表”生成器表达式就是把列表推导式的中括号换成小括号squares (x * x for x in range(5)) print(sum(squares)) # 0 1 4 9 16 30 print(sum(squares)) # 0看到没有第二次sum(squares)得到 0。因为第一次求和已经把生成器里的数据全部消费完了它不会像列表那样保留内容。这就是“一次性”的现实代价。既然是一次性的为什么还要用它因为它不占用内存。列表推导式会立刻算出 500 万个值并全部存在内存里生成器表达式只保存生成器对象本身每个值都是遍历到的时候才算出来用完就丢。后面第三节我会给一组实测数据差距非常直观。还有个语法细节当生成器表达式作为一个函数唯一参数时可以省略一对外层括号。比如sum(x * x for x in range(5))是合法的但如果后面还有别的参数就得补括号写成sum((x * x for x in range(5)), 10)否则语法会报错。这个细节很容易在代码评审时被眼尖的人挑出来。2.4 yield from把多个生成器串成流水线yield from是 Python 3.3 引入的语法作用是委托子生成器。相当于把子生成器里的每个值逐个yield出去同时还能处理两个生成器之间的异常传递和返回值传播。写扁平化嵌套序列非常方便def flatten(layers): for layer in layers: yield from layer result list(flatten([[1, 2], [3, 4], [5, 6]])) print(result) # [1, 2, 3, 4, 5, 6]如果不用yield from你得写成for item in layer: yield item这种“两行变一行的压缩”只是表面好处。真正的价值在于当子生成器是一个很长的管道时yield from能让你把一个生成器直接嵌入另一个生成器组合出“生成器管道”而不增加额外的遍历消费成本。3. 实战手写迭代器、生成器与批处理加载3.1 方案一按迭代器协议写一个倒计时迭代器类为了看清迭代器协议到底要做什么我们先不偷懒手写一个完整的迭代器类。倒计时逻辑每次__next__()返回当前值然后内部计数减一减到 0 以下就抛StopIteration结束。class CountdownIterator: def __init__(self, start): self.current start def __iter__(self): return self def __next__(self): if self.current 0: raise StopIteration value self.current self.current - 1 return value it CountdownIterator(3) for n in it: print(n) # 3 # 2 # 1这个类能跑但它有个大问题它是自迭代器状态全存在自己身上遍历一次就废了。如果把它重新iter()一遍返回的还是同一个对象当前值已经变成 0什么都没了。正确的做法是分成两个类一个负责“描述数据范围”的可迭代对象一个负责“保存遍历状态”的迭代器并且让__iter__()每次返回一个新的迭代器。我直接写在一起class Countdown: def __init__(self, start): self.start start def __iter__(self): return CountdownIterator(self.start) class CountdownIterator: def __init__(self, start): self.current start def __iter__(self): return self def __next__(self): if self.current 0: raise StopIteration value self.current self.current - 1 return value c Countdown(3) print(list(c)) # [3, 2, 1] print(list(c)) # [3, 2, 1]这次还能再来那从协议层面看这样的设计才是“正确的可迭代对象”。不过说实话日常业务里你几乎不需要写这么繁琐的类因为用生成器方案能省掉一半以上的代码状态管理还不会出错。3.2 方案二用生成器重写并让iter返回新生成器用生成器实现同一个倒计时代码量骤降class Countdown: def __init__(self, start): self.start start def __iter__(self): n self.start while n 0: yield n n - 1 c Countdown(3) print(list(c)) # [3, 2, 1] print(list(c)) # [3, 2, 1]注意这里我让__iter__()是一个生成器函数每次for开始时会调用它创建一个全新的生成器所以遍历多次没问题而生成器自身负责保存n这个迭代状态我们完全不需要再写CountdownIterator类。这是我个人在项目里最喜欢的写法当你想给某个类加迭代能力时直接用__iter__配合yield把“迭代状态”交给生成器去管比手写迭代器类省心得多。唯一要注意的是这时候isinstance(c, Iterator)会是 False因为c本身不是迭代器它只是每次调用__iter__时能返回迭代器。但这不影响任何for语义。3.3 真正能落地的场景大文件逐行读取与批量分块纸上谈兵没意思上一个我经常拿来面试又特别现实的例子逐行读取一个大文件不能把整个文件一次性读进内存。最直观的错误写法是def read_all_lines(path): with open(path, r, encodingutf-8) as f: return f.readlines()文件几 GB 时readlines()会把所有行都塞进内存机器直接卡死。改用生成器就非常从容def read_lines(path): with open(path, r, encodingutf-8) as f: for line in f: yield line.rstrip(\n) for line in read_lines(large_log.txt): # 处理一行内存里始终只有当前这一行 pass文件对象本身支持按行迭代底层有缓冲每次只读一部分。生成器在这里的意义不是变魔术而是把“读一行处理一行”的流程封装成一个可迭代对象调用方不需要关心文件指针、缓冲、关闭这些细节。更进阶一点数据量很大时单行处理还好但如果下游接口每次需要一批数据比如一次处理 1000 行该怎么包装我常用这种 batch 生成器def batch_lines(path, batch_size1000): batch [] with open(path, r, encodingutf-8) as f: for line in f: batch.append(line.rstrip(\n)) if len(batch) batch_size: yield batch batch [] if batch: yield batch for chunk in batch_lines(large_log.txt, batch_size1000): process_chunk(chunk)这里的关键是batch这个列表在生成器挂起的时候依然存在于局部变量中下次next()时继续往里追加攒够一批就交出去交完清空。整个流程不会同时持有所有批次内存占用始终被限制在batch_size量级。4. 常见问题与排查技巧实录4.1 生成器只能用一次不是 bug 而是特性这是我在代码评审里见过最多的问题有人把生成器传给一个函数处理后又把它传给第二个函数结果第二个函数拿到的数据是空的。比如data (x for x in range(10)) print(sum(data)) # 45挺好 print(sum(data)) # 0怎么空了因为生成器是个一次性消费的流遍历完就到底了。想让多处使用要么把生成器转成列表要么重新创建生成器。这里没有“回退”按钮也不能像文件一样seek。如果业务逻辑需要两次遍历就在最开始list(generator)物化一次用内存换灵活如果数据量实在太大那就老老实实改成“既能重新遍历、又能按需计算”的可迭代对象方案比如__iter__用生成器的类。4.2 对生成器不要用 len() 和下标生成器不知道自身长度因为它没有预先把数据存下来。直接len(generator)会报TypeError: object of type generator has no len()。想取前 N 个元素也不能写gen[0]因为生成器没有__getitem__。正确姿势是配合itertools.islicefrom itertools import islice gen (x * x for x in range(100)) first10 list(islice(gen, 10)) print(first10)注意islice(gen, 10)会消耗掉生成器的前 10 个值生成器本身继续往后走。如果后面还要用要把这个“取前几条”也当成一次消费来设计。4.3 next() 默认参数StopIteration 的正确打开方式手动调用next()时如果迭代器没有更多值会抛StopIteration。大多数场景你不需要处理它for已经帮你吞掉了。但有些手动消费的代码里我习惯用next(it, default)的写法it iter([1, 2, 3]) print(next(it, None)) # 1 print(next(it, None)) # 2 print(next(it, None)) # 3 print(next(it, None)) # None不会抛异常这个default参数经常被忽略但在轮询、流式处理、解析分片数据时很实用。比如从一个数据流里不断取块取到末尾就返回None而不是一路抛异常。4.4 for 循环内部到底干了什么很多问题的根源是没把for拆开看。for item in obj本质上等价于iterator iter(obj) while True: try: item next(iterator) except StopIteration: break # 循环体看到没任何对象只要iter(obj)能返回迭代器它就能被for遍历。所以for兼容的不只是列表、元组、字典、字符串还包括文件、生成器、zip、map、filter、数据库游标等一大堆东西。这也是理解“为什么map和filter返回值不是列表而是可迭代对象”的钥匙Python 3 里它们本来就被设计成懒计算迭代器想拿到列表就手动list(map(...))。4.5 send() 的首次调用与 yield 表达式的计算顺序前面提过新生成的生成器不能直接send()非None值。再补充一个容易被绕晕的点yield在表达式里到底什么时候取值。def echo(): value yield ready yield value g echo() print(next(g)) # ready print(g.send(hello)) # hello第一次next(g)执行到yield ready把ready返回出来然后挂起此时value还没被赋值。当调用g.send(hello)时hello才作为yield表达式的值被赋给value函数继续走到下一个yield value返回hello。也就是说send()传进去的值不是给当前这个yield的返回值用的而是给上一次挂起的yield整体表达式的值。这个顺序一旦记错看代码就会觉得“怎么反过来”。5. 工具选型与实战数据什么时候必须上生成器5.1 列表推导式 vs 生成器表达式内存相差几个数量级拿生成一千万个平方数来测。列表推导式会立刻算完并全部存进内存生成器表达式只创建一个生成器对象一个值都不算import sys lst [i * i for i in range(10_000_000)] gen (i * i for i in range(10_000_000)) print(sys.getsizeof(lst)) # 大概是 80_000_000 字节级别 print(sys.getsizeof(gen)) # 大概是 200 字节级别我没把具体数字写死因为不同 Python 版本会有些波动但数量级差距非常稳定列表推导式占用几十 MB 到上百 MB生成器表达式只占几百字节。所以在数据量很大、遍历一次就够的场景生成器是绝对的首选。但要注意生成器省的是内存不是时间。因为每取一个值都要恢复和挂起函数帧纯计算场景下它往往比列表推导式还要慢一点。所以不要把它当“性能银弹”它是内存银弹。如果一个小列表只用一次直接列表推导式反而简单如果数据量上百万或来源是文件、网络流那就必须生成器。实际项目里我还常用pandas的chunksize参数它返回的也不是 DataFrame 列表而是一个按块产出 DataFrame 的可迭代对象底层思路和上面batch_lines一脉相承import pandas as pd for chunk in pd.read_csv(huge.csv, chunksize5000): # 每次只处理 5000 行 process(chunk)5.2 itertools 与无限序列把“懒处理”用到极致生成器另一个让人上头的场景是它可以表示无限序列。普通函数根本不可能“返回所有斐波那契数”因为那是无穷的但生成器可以做到“你需要多少我就现算多少”def fibonacci(): a, b 0, 1 while True: yield a a, b b, a b from itertools import islice fib fibonacci() print(list(islice(fib, 10))) # [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]这个模式在生产里非常有用。配合标准库itertools可以把数据处理写成一长串“懒管道”from itertools import count, takewhile, islice naturals count(1) # 无限自然数 squares (x * x for x in naturals) # 无限平方数 limited islice(takewhile(lambda x: x 100, squares), 10) print(list(limited))每一步都不生成完整结果只在最终被list()消费时才往前推进一点点。数据在管道里流动内存占用几乎恒定。如果你用的是 Python 3.12 及以上标准库还直接提供了itertools.batched连自己写batch_lines里的batch列表都可以省掉from itertools import batched for batch in batched(iter([1, 2, 3, 4, 5]), 2): print(batch) # (1, 2) # (3, 4) # (5,)不过要注意它产出的 batch 是元组并且对迭代器也是惰性消费本质上和手动攒列表是一个道理。结尾如果让我给一句实在的建议学习这几个概念不要死背定义直接从for循环的展开写法入手再亲手写一个__iter__yield的类把大文件逐行读取跑一遍。做完这三个小练习你自然就明白为什么 Python 到处都在“懒”。我自己刚开始那段时间也犯过“生成器当列表用”的错在数据处理脚本里把一个生成器传了两个函数第二个函数收到空数据排查了半天才发现是消费顺序问题。后来我在每个生成器变量命名时都加个_gen后缀提醒自己这个方法听起来很笨但很管用。迭代体系这套东西一旦理顺之后看zip、map、filter、pd.read_csv(chunksize...)这些 API 都会有一种“原来都是同一套玩法”的通透感。