057、迭代器协议与自定义迭代器:__iter__、__next__ 与 itertools 混用

057、迭代器协议与自定义迭代器:iternext与 itertools 混用

上周帮同事排查一个数据管道的内存泄漏问题,日志里看到StopIteration异常被吞掉,导致生成器无限循环。翻代码发现他手写了一个迭代器类,__next__方法里忘了处理边界条件——这种坑我踩过不止一次。今天干脆把迭代器协议掰开揉碎讲清楚,顺便聊聊怎么跟itertools配合着玩。

迭代器协议到底是个啥

Python 里迭代器协议就两条规则:对象实现__iter__返回迭代器自身,__next__每次返回下一个元素,没元素时抛StopIteration。别小看这两条,很多新手写自定义迭代器时,要么忘了__iter__返回 self,要么__next__里用return None代替抛异常——后者会导致循环无法正常终止。

看个反面教材,我当年也这么写过:

classBadRange:def__init__(self,n):self.n=n self.i=0def__iter__(self):returnself# 这里没问题def__next__(self):ifself.i<self.n:val=self.i self.i+=1returnval# 别这样写!返回 None 会让 for 循环继续跑# return NoneraiseStopIteration# 必须抛这个

for循环底层就是不断调__next__直到捕获StopIteration。你返回None,循环会认为None是有效值,继续调下一次——死循环就来了。

可迭代对象 vs 迭代器:别搞混

很多教程把这两个概念混着讲,实际区别很关键。可迭代对象(比如列表、字符串)实现了__iter__,返回一个迭代器;迭代器同时实现了__iter____next__。列表本身不是迭代器,你调iter([1,2,3])才拿到迭代器。

为什么要有这个区分?因为迭代器是一次性消耗品,遍历完就废了。可迭代对象可以反复生成新的迭代器。看这个例子:

nums=[1,2,3]it1=iter(nums)it2=iter(nums)# 每次调 iter() 得到新迭代器print(list(it1))# [1, 2, 3]print(list(it1))# [] it1 已经耗尽print(list(it2))# [1, 2, 3] it2 是新的

如果你把列表本身当迭代器用(比如直接给next()),会报TypeError。这里踩过坑:写代码时图省事,直接把列表传给next(),结果运行时炸了。

自定义迭代器的正确姿势

写一个能反复遍历的自定义迭代器,需要把迭代器对象和可迭代对象分开。比如实现一个“斐波那契数列迭代器”:

classFibs:def__init__(self,max_count):self.max_count=max_countdef__iter__(self):returnFibIterator(self.max_count)classFibIterator:def__init__(self,max_count):self.max_count=max_count self.a,self.b=0,1self.count=0def__next__(self):ifself.count>=self.max_count:raiseStopIteration self.count+=1self.a,self.b=self.b,self.a+self.breturnself.a

这样每次for循环都会创建新的FibIterator,可以反复遍历。但说实话,日常开发中很少需要写两个类,用生成器函数更省事:

deffibs(max_count):a,b=0,1for_inrange(max_count):a,b=b,a+byielda

生成器函数自动实现了迭代器协议,yield就是__next__的返回值,函数结束自动抛StopIteration。除非你需要维护复杂的状态或者实现双向迭代,否则别手写迭代器类。

itertools 混用技巧

itertools是迭代器操作的瑞士军刀,但很多人只会用chaincycle。实际工作中,islicetakewhile才是高频利器。

比如从一个大文件里读前100行,用islice切片迭代器,避免一次性加载全部:

fromitertoolsimportislicedefread_large_file(path):withopen(path)asf:# 这里踩过坑:islice 返回的是迭代器,不会立即执行forlineinislice(f,100):process(line)

再比如处理流式数据时,用takewhile按条件截断:

fromitertoolsimporttakewhiledefprocess_stream(stream):# 遇到负数就停止,别用 filter 因为 filter 会遍历全部foritemintakewhile(lambdax:x>=0,stream):do_something(item)

itertools.tee也是个好东西,能把一个迭代器克隆成多个独立副本。但注意:tee会缓存中间数据,如果两个副本遍历速度差距大,内存会暴涨。我见过有人用tee处理百万级数据,结果内存爆了——后来改成用列表缓存才解决。

迭代器与生成器的性能陷阱

生成器虽然省内存,但每次yield都有上下文切换开销。如果你在循环里频繁调生成器函数,性能可能不如列表推导式。实测过:生成100万个整数,生成器比列表慢约30%,但内存占用只有1/10。取舍看场景。

另一个坑:生成器只能遍历一次。如果你需要多次遍历,要么转成列表,要么重新创建生成器。我习惯在函数文档里注明“返回生成器,只能遍历一次”,避免同事误用。

实战:写一个可重置的迭代器

有时候需要迭代器能重置到初始状态,比如分页请求失败后重试。可以用itertools.cycle配合islice实现:

fromitertoolsimportcycle,isliceclassResettableIterator:def__init__(self,data):self.data=data self._cycle=cycle(data)self._pos=0def__iter__(self):returnselfdef__next__(self):ifself._pos>=len(self.data):raiseStopIteration self._pos+=1returnnext(self._cycle)defreset(self):self._cycle=cycle(self.data)self._pos=0

但说实话,这种设计有点别扭。更好的做法是用itertools.tee或者直接存列表。我后来重构时,干脆把数据存成列表,需要迭代时用iter()生成新迭代器——简单可靠。

个人经验建议

  1. 能用生成器就别手写迭代器类。生成器函数自动处理了StopIteration,代码量少一半,可读性高。只有需要实现__next__之外的接口(比如sendthrow)时才考虑类。

  2. 调试迭代器时,用list()转成列表看内容。别直接print(iterator),那只会打印对象地址。我习惯在__next__里加个临时print,确认每次返回的值对不对。

  3. itertoolschainzip配合迭代器时,注意长度不一致的问题zip默认按最短的截断,zip_longest会填充None。选哪个取决于业务逻辑,别想当然。

  4. 永远不要在__next__里捕获StopIteration。如果你在迭代器内部捕获了它,外部循环就永远收不到终止信号。我见过有人为了“优雅处理”在__next__try-except吞掉异常,结果循环跑飞了。

  5. 性能敏感场景,用for循环而不是while+next()for循环底层是 C 实现的迭代协议,比 Python 层手动调next()快一个数量级。除非你需要手动控制迭代节奏(比如跳过某些元素),否则别自己写循环。

迭代器协议看着简单,但实际用起来坑不少。记住核心:__iter__返回迭代器,__next__返回元素或抛异常,for循环帮你处理了所有脏活。下次遇到迭代器相关的问题,先检查这三个点,八成能定位到问题。