可迭代对象(iterable) 能提供迭代器;迭代器(iterator)通过__next__()每次产出一项。含yield的函数会创建一种由Python管理状态的迭代器,也就是生成器。
迭代器通常只能向前消费一次。调试输出、成员测试或第二个消费者都可能提前取走数据,提前退出还可能让生成器持有的资源继续打开。
明确区分可重复迭代的数据源与单遍迭代器,记录谁负责消费和关闭;生成器内部用return结束,并在finally中释放资源。
是什么,为什么存在
迭代器协议(iterator protocol) 是Python逐项读取数据的统一接口。可迭代对象的__iter__()返回一个迭代器,迭代器的__next__()返回下一项;没有下一项时,__next__()抛出StopIteration。for、推导式、sum()、zip()和许多标准库API都建立在这套协议上。
可迭代对象和迭代器不是同义词。列表、元组与range通常可以多次调用iter(),每次得到新的独立迭代器;文件对象、生成器对象和很多流式游标本身就是迭代器,iter(obj) is obj,消费后不会自动回到开头。
生成器(generator)是实现迭代器的一种方式。生成器函数使用yield产出值,Python替你保存暂停位置、局部变量与异常处理状态。调用生成器函数只创建生成器对象,函数体要等到第一次next()才开始执行。
生成器解决的是控制权和状态保存问题。生产者可以在产出一项后暂停,消费者决定何时继续,因此无需先构造完整列表。这种 惰性求值(lazy evaluation) 适合逐行读取、转换管道、分页结果和可能无限的序列,也能让下游在找到结果后提前停止。
惰性不等于一定更快,也不保证内存永远很小。管道中某一步若调用list()、sorted()或需要全量分组,仍会物化所有输入;itertools.tee()的消费者进度相差很大时也要缓存尚未被较慢一方读取的元素。应从数据流的所有权和消费方式判断,而不是把生成器当作通用优化开关。
工作原理
for item in source会先求值source,再调用iter(source)一次。循环反复调用next(iterator)并把结果绑定给item;收到StopIteration时正常结束。循环体自己抛出的其他异常不会被协议吞掉。
迭代器必须让__iter__()返回自身。这样,接收任意可迭代对象的代码可以统一调用iter(),即使传入的已经是迭代器也不会丢失当前位置。自定义容器则通常让__iter__()每次创建新迭代器,以便多个循环互不干扰。
生成器函数的调用与普通函数不同。调用时只创建一个生成器对象;第一次next()执行到首个yield,把右侧值交给调用方并暂停。下一次next()从暂停点之后恢复,其中保存的局部变量仍是上次的状态。
生成器正常执行到函数末尾或执行return时会结束。协议层通过StopIteration表示结束,return value会把value放进该异常的value属性;普通for循环只把它当成结束信号,不会把这个返回值作为一项产出。
| 对象或操作 | 协议含义 | 常见性质 |
|---|---|---|
iterable.__iter__() | 返回一个迭代器 | 可能每次返回新对象 |
iterator.__iter__() | 返回迭代器自身 | 保留当前位置 |
iterator.__next__() | 返回下一项或抛出StopIteration | 会推进状态 |
yield value | 产出值并暂停生成器帧 | 恢复后继续执行 |
return value | 结束生成器 | 值进入StopIteration.value |
生成器也是可关闭的状态机。close()会在暂停点抛入GeneratorExit,因此finally有机会运行;若生成器捕获关闭信号后又产出值,close()会抛出RuntimeError。调用方提前停止时是否关闭生成器,是必须明确的所有权契约。
示例
下面四个示例依次展示协议、惰性执行、委托与提前关闭。输出均来自本地python3对对应文件的实际执行。
单遍倒计时迭代器
Countdown同时是可迭代对象和迭代器。它的当前位置保存在实例中,因此第二次遍历只会看到剩余项,耗尽后则得到空结果。
class Countdown:
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
countdown = Countdown(3)
print(iter(countdown) is countdown)
print(next(countdown))
print(list(countdown))
print(list(countdown))True
3
[2, 1]
[]list(countdown)没有重新创建倒计时,它从当前的2继续消费到结束。最后一次list()再次调用iter(countdown),得到的还是已经耗尽的同一对象。
若希望每次循环都从start重新开始,应把可迭代对象与游标状态分开。容器的__iter__()可以返回新的Countdown,或者直接返回一个生成器;不要在迭代器的__iter__()中悄悄重置状态,那会破坏嵌套循环和部分消费的含义。
看见生成器何时执行
discounted_orders()在创建时不会读取订单。每次消费者请求下一项,它才继续扫描,直到产出一个符合条件的结果或耗尽输入。
def discounted_orders(rows):
print("pipeline started")
for order_id, total in rows:
print(f"reading {order_id}")
if total >= 100:
yield f"{order_id}:{total * 90 // 100}"
source = [("A-17", 120), ("B-02", 80), ("C-99", 200)]
discounts = discounted_orders(source)
print("created")
print(next(discounts))
print(list(discounts))
print(list(discounts))created
pipeline started
reading A-17
A-17:108
reading B-02
reading C-99
['C-99:180']
[]"created"出现在"pipeline started"前面,证明调用生成器函数没有执行函数体。第一次next()只读到足以产出A-17的位置;后面的list()才继续扫描另外两条记录。
这个生成器与输入共享消费进度。若rows本身也是迭代器,生产者和调用方交替读取它会互相取走数据。一个数据源有多个消费者时,应决定是重新创建源、物化快照、显式广播,还是接受单一所有者。
用yield from委托并接收返回值
yield from不只是缩写一层for。它把迭代操作委托给子生成器,并在子生成器结束后取得其return值。
def read_partition(name, values):
count = 0
for value in values:
count += 1
yield f"{name}:{value}"
return count
def export_rows():
west_count = yield from read_partition("west", [10, 20])
east_count = yield from read_partition("east", [30])
return west_count + east_count
rows = export_rows()
while True:
try:
print(next(rows))
except StopIteration as done:
print(f"rows={done.value}")
breakwest:10
west:20
east:30
rows=3外层生成器先原样产出西区的两项,子生成器返回2后才进入东区。最终返回值3只在手动捕获的StopIteration.value中可见;list(export_rows())会得到三条记录,但不会保留计数。
委托还会转发send()、throw()与close()的交互。业务代码若只需拼接普通可迭代对象,itertools.chain()往往更直接;需要子生成器返回值或完整委托语义时,yield from才表达了真正的契约。
提前停止时关闭生成器
生成器可以让资源跨越多个yield保持打开。消费者只取一项时,应显式关闭自己拥有的生成器,让finally立即执行。
from io import StringIO
def read_nonempty(text):
stream = StringIO(text)
print(f"opened={not stream.closed}")
try:
for line in stream:
cleaned = line.strip()
if cleaned:
yield cleaned
finally:
stream.close()
print(f"closed={stream.closed}")
rows = read_nonempty("alpha\n\nbeta\n")
print(next(rows))
rows.close()
print(list(rows))opened=True
alpha
closed=True
[]第一次next()后,生成器暂停在yield cleaned,stream仍然打开。rows.close()把控制权送回暂停点,finally关闭流;已经关闭的生成器保持耗尽,因此随后转换为列表得到[]。
文件读取生成器也应使用同样的结构:在生成器函数内部用with open(...)包住yield所在的循环。若API把关闭责任交给调用方,应在名称、文档和使用方式中说明,并可由调用方使用contextlib.closing()建立清楚的作用域。
陷阱
把迭代器当成可重复集合
修复方法: 若输入必须重复读取,就让API接收能创建新迭代器的可迭代对象或工厂。数据量有界且确实需要多遍时,可以在所有权边界物化一次;不要在深层辅助函数中暗自复制未知大小的输入。
返回已经关闭资源上的生成器表达式
修复方法: 让包含with的函数本身成为生成器,并在with内的循环中逐项yield。这样文件会在首次迭代时打开,在耗尽、异常或显式关闭生成器时离开上下文。
用观察代码意外消费输入
修复方法: 调试单遍数据流时,在生产点记录每一项,或只用itertools.islice()取得明确数量并接受这些项已被消费。测试应同时断言本次结果与剩余输入,不能只看第一次返回值。
在生成器内部抛出StopIteration
修复方法: 生成器要结束时使用return。从另一个迭代器取可选项时,给next()提供默认值或在局部捕获StopIteration,不要让它越过生成器帧边界。
假设break会关闭生成器
修复方法: 消费者拥有生成器并可能提前结束时,在finally中调用close(),或者用contextlib.closing()。不要依赖特定Python实现的即时垃圾回收来保证文件、锁或事务的释放时机。
把tee()当作免费复制
修复方法: 进度接近的短期分支可以使用tee()。消费者速度差异不受控时,应改用有界队列和背压、把数据持久化,或在输入有界时明确物化。
单遍状态与所有权
迭代器的核心是可变位置。调用next()会改变下一次调用看到的结果,即使Python层没有暴露index属性。把迭代器传给函数,相当于把这个推进能力交给函数;调用方不能假设返回后位置不变。
API应说明它接收的是Iterable[T]还是Iterator[T]。前者只保证能取得迭代器,不保证可重复、可求长度或可索引;后者明确表示已有消费状态。静态类型可以暴露这个区别,但仍需文档说明函数是否会消费全部输入、只取前缀,或者保留迭代器供以后使用。
几个操作的消费行为不同,审查时应逐个展开:
| 操作 | 消费范围 | 结束后的状态 |
|---|---|---|
next(it) | 一项 | 指向下一项 |
next(it, default) | 至多一项 | 耗尽时返回默认值 |
value in it | 直到匹配或耗尽 | 匹配项也已被消费 |
a, b = it | 为验证恰好两项,最多读取三项 | 成功时耗尽 |
list(it) | 直到耗尽 | 完全耗尽 |
一个迭代器由多个组件交替消费时,结果取决于调用顺序。若这正是调度设计,应把所有权集中在一个协调器中;若不是,就给每个消费者独立迭代器。仅仅给同一对象换两个变量名不会复制位置。
生成器帧与控制方法
生成器对象持有一个暂停的执行帧。帧中包括局部变量、当前指令位置以及活动的try/finally状态,所以恢复执行不是重新调用函数。生成器耗尽后帧不能重启;需要重新执行时必须再次调用生成器函数。
next(gen)等价于gen.send(None)。send(value)会把值作为暂停处yield表达式的结果送入生成器,但刚创建的生成器还没有暂停点,只能先发送None。这种双向接口可用,却容易增加控制流成本;普通数据生产者通常只需要next()。
| 方法 | 在暂停点发生的事 | 典型用途 |
|---|---|---|
send(value) | 让yield表达式求值得到value | 向状态机输入数据 |
throw(exc) | 在暂停点抛出指定异常 | 注入错误或取消信号 |
close() | 在暂停点抛出GeneratorExit | 请求清理并结束 |
尚未开始的生成器没有执行过try,因此对它调用close()不会进入函数体来获取或释放资源。资源应在生成器首次推进后才获取,或者由生成器外的明确上下文拥有。已经暂停的生成器则可通过close()运行包围暂停点的finally。
yield from的委托边界
手写for item in child: yield item只转发产出的值。yield from child还处理发送值、抛入异常、关闭请求与子生成器返回值,使外层生成器可以把一段协议完整委托出去。PEP 380定义了这些细节。
子生成器执行return result时,委托表达式的结果是result。这适合让子流程在流式产出之外报告摘要,但调用方只有继续驱动外层生成器越过子流程结束点,外层代码才能取得该值。消费者若提前停止,摘要可能根本没有计算完成。
委托不会自动解决资源所有权。关闭外层生成器通常会把关闭请求传给当前子迭代器,但设计仍要说明谁拥有子迭代器,以及它能否被其他代码共享。共享同一个子迭代器再从两条委托链消费,会得到顺序相关的结果。
生成器表达式的求值时机
生成器表达式(generator expression) 创建生成器对象,元素表达式与大多数迭代工作延后到消费时完成。不过,最左侧for的可迭代表达式会在生成器表达式创建时立即求值;这样,构造输入表达式时的错误会出现在定义生成器的位置。
“最左侧表达式已求值”不表示输入项已被消费。生成器表达式创建时也会对最左侧数据源取得迭代器,但要到第一次推进生成器时才请求首项。后续for子句、过滤条件与元素表达式同样按需求执行,因此它们观察到的外部可变状态可能是消费时的状态。
圆括号只描述生成器表达式的语法,不代表任意括号表达式都是生成器。(value)仍是普通分组;单项元组需要(value,)。判断行为时应看是否存在推导式的for子句,而不是看括号形状。
结束、异常与返回值
自定义__next__()抛出StopIteration是协议的正常实现。生成器函数已有return语法表示结束,所以从生成器帧意外逸出的StopIteration会在帧边界转换成RuntimeError。这项规则防止内部辅助迭代器提前耗尽时静默截断外层生成器。
return不带值等价于正常结束,带值时则设置StopIteration.value。yield from会读取这个值;for、list()和大多数消费者只关心已经没有下一项。不要把摘要信息只放在生成器返回值里,却又让调用方通过普通循环消费。
其他异常会从当前next()调用传播给消费者,并通常使生成器结束。调用方捕获异常后再次推进生成器,往往只会收到StopIteration。若业务需要把单条坏记录表示为数据,应显式产出结果对象或在生成器内部按契约处理,而不是依赖恢复失败的生成器。
资源生命周期
生成器中的with和finally可以跨暂停点存在。优点是资源只在真正开始消费时获取,并在正常耗尽或关闭时释放;代价是消费者在两次next()之间可能长时间持有文件、数据库游标或锁。
普通for不拥有它接收的迭代器,因此不能在break时一律关闭。某个迭代器可能还会被调用方继续使用,也可能根本没有close()。需要确定性清理的API应提供上下文边界,或者明确要求拥有者在提前停止时关闭。
不要把__del__或CPython的引用计数当作协议。循环引用、其他Python实现和对象仍被缓存等情况都会推迟回收。测试资源行为时应观察关闭标志、临时文件句柄或测试替身的退出事件,而不是只断言生成器最终不可达。
测试迭代契约
一个自定义迭代器的最小契约测试应覆盖iter(it) is it、逐项顺序、耗尽后的StopIteration,以及再次调用next()仍保持耗尽。若可迭代容器承诺可重复遍历,还要创建两个迭代器并交错推进,确认位置互不影响。
生成器测试要把创建与消费分开。先调用生成器函数并断言尚无副作用,再推进一项,检查只发生了产生该项所需的工作。最后覆盖完整耗尽、业务异常与消费者提前关闭三条路径。
测试惰性管道时,用会记录每次读取的输入迭代器比只比较最终列表更有信息。它能暴露预取过多、日志意外消费和无界物化。无限输入必须配合islice()、明确哨兵或其他有界终止条件,否则测试本身可能挂起。
选择生产者形式
若数据已经完整存在,而且调用方需要长度、索引或重复遍历,就直接返回列表或其他集合。为了显得“惰性”而返回生成器,会把简单的数据所有权换成更脆弱的时序契约。返回类型应反映调用方真正需要的操作。
若生产过程自然分步、输入很大或可能无限,生成器函数通常比手写迭代器类短。需要多个公开操作、可重置游标、检查点或复杂状态转换时,类能把协议和状态说得更清楚;它的__iter__()仍可按需返回生成器。
| 需求 | 通常合适的形式 |
|---|---|
| 有界结果,需要多遍或索引 | 集合 |
| 单遍转换管道 | 生成器函数 |
| 简短的单个表达式转换 | 生成器表达式 |
| 自定义游标带额外操作 | 迭代器类 |
| 组合标准迭代步骤 | itertools工具 |
公开API还应说明空输入、坏记录与提前停止的结果。只标注返回Iterator[T]不能回答异常是立即出现还是消费时出现,也不能说明关闭责任。把这些时序语义写进契约,测试才能落在正确边界上。
延伸阅读
5个问题 · 2道输出预测题 · 1道找错题