「Python 进阶之路」系列 Day07
写在前面
同一个 list,可以同时被两个 for 循环遍历,各自互不干扰——一个循环遍历到第二个元素了,另一个循环依然能从头开始。这背后其实藏着一个经常被忽略的设计:你手里的这个 list,和 for 循环真正用来遍历的东西,根本不是同一个对象。
今天这篇把 for 循环背后的迭代器协议讲透:可迭代对象和迭代器到底是什么关系,为什么要拆成两个概念,以及自己写一个支持遍历的类时最容易踩的坑。
一、是什么:可迭代对象与迭代器的区别
可迭代对象(Iterable):实现了 __iter__ 方法的对象,调用 __iter__() 能返回一个迭代器。
迭代器(Iterator):同时实现了 __iter__(返回自身)和 __next__(返回下一个值,取完了抛 StopIteration)的对象。
lst = [1, 2, 3]
print(hasattr(lst, "__iter__"), hasattr(lst, "__next__")) # True False —— list 只是可迭代对象
it = iter(lst)
print(hasattr(it, "__iter__"), hasattr(it, "__next__")) # True True —— 迭代器两个都有
关键区别在这里:可迭代对象每次调用 iter() 都能拿到一个全新的、独立的迭代器:
it1 = iter(lst)
it2 = iter(lst)
print(it1 is it2) # False —— 两个完全独立的迭代器
print(next(it1)) # 1
print(next(it1)) # 2
print(next(it2)) # 1 —— it2 完全不受 it1 影响,从头开始
这就是开头那个现象的真相:lst 只是可迭代对象,真正负责遍历进度的是从它身上产出的迭代器,每次 iter(lst) 都是全新的一个,互相之间没有任何关系。
迭代器一旦耗尽,就永远耗尽了,不会自动重置:
it3 = iter([1, 2])
next(it3) # 1
next(it3) # 2
next(it3) # StopIteration
next(it3) # 再调用一次,还是 StopIteration,不会回到开头
二、为什么:把"能否遍历"和"遍历到哪了"拆成两个概念
如果一个对象既是可迭代对象又是迭代器(__iter__ 直接 return self),意味着遍历进度直接存在这个对象自己身上——这样设计的后果是,这个对象只能被完整遍历一次,用过一次之后第二次遍历只能拿到空结果,也没办法让两个 for 循环同时独立遍历它。
Python 把"可迭代对象"和"迭代器"拆成两个角色,就是为了避免这个限制:可迭代对象只负责"我能不能被遍历",每次真正要遍历时,都临时产出一个全新的、专属的迭代器来记录"这一次遍历到哪了"。多个遍历互不干扰,同一个容器可以被反复使用,这才是 list/dict/str 这些内置类型的正常使用体验。
三、怎么用
1. for 循环背后的真相
for x in obj: 这行代码,Python 实际做的事情是:
flowchart LR
A[for x in obj] --> B[调用iter obj得到迭代器it]
B --> C[调用next it]
C --> D{是否抛出<br/>StopIteration}
D -->|否| E[把值赋给x<br/>执行循环体]
E --> C
D -->|是| F[结束循环]
- 调用
iter(obj),拿到一个迭代器 - 反复调用这个迭代器的
next(),把返回值赋给x,执行循环体 - 直到某次
next()抛出StopIteration,循环正常结束(这个异常被for语句自动捕获,不会传播出来)
用手写代码还原这个过程,效果和真正的 for 循环完全一样:
def my_for_loop(obj, action):
it = iter(obj)
while True:
try:
item = next(it)
except StopIteration:
break
action(item)
my_for_loop([10, 20, 30], print)
# 10
# 20
# 30
2. 手写迭代器:一次性消耗品
class CountUp:
def __init__(self, limit):
self.limit = limit
self.current = 0
def __iter__(self):
return self # 迭代器的 __iter__ 返回自身,这是协议要求
def __next__(self):
if self.current >= self.limit:
raise StopIteration
self.current += 1
return self.current
c = CountUp(3)
print(list(c)) # [1, 2, 3]
print(list(c)) # [] —— 第二次遍历同一个实例,已经耗尽了,拿不到东西!
这是自己实现迭代器最容易踩的坑:CountUp 本身既是可迭代对象又是迭代器,状态(self.current)保存在实例本身上,一旦耗尽就回不去了——如果这个类打算被多次遍历,这就是个 bug。
3. 手写可迭代对象:支持重复遍历
要支持重复遍历,__iter__ 应该每次都返回一个全新的迭代器实例,而不是 self:
class CountUpIterable:
def __init__(self, limit):
self.limit = limit
def __iter__(self):
return CountUpIterator(self.limit) # 关键:每次都是新实例
class CountUpIterator:
def __init__(self, limit):
self.limit = limit
self.current = 0
def __iter__(self):
return self
def __next__(self):
if self.current >= self.limit:
raise StopIteration
self.current += 1
return self.current
ci = CountUpIterable(3)
print(list(ci)) # [1, 2, 3]
print(list(ci)) # [1, 2, 3] —— 可以重复遍历,因为每次都拿到全新的迭代器
把"可迭代对象"和"迭代器"拆成两个类之后,两个独立的 for 循环可以同时遍历同一个 ci,互不干扰:
for a in ci:
for b in ci:
print(a, b)
break
# 1 1
# 2 1
# 3 1
4. 用 collections.abc 判断类型
from collections.abc import Iterable, Iterator
print(isinstance(lst, Iterable), isinstance(lst, Iterator)) # True False
print(isinstance(it1, Iterable), isinstance(it1, Iterator)) # True True
Iterable/Iterator 是 collections.abc 里的抽象基类,判断一个对象是否遵循对应协议,比自己写 hasattr 检查更规范。
5. iter() 的哨兵用法
iter() 还有一个不太常用但很实用的两参数形式:iter(callable, sentinel)——不断调用 callable(),直到返回值等于 sentinel 就停止(不包含 sentinel 本身):
data = [1, 2, 3, 0, 4, 5]
it_data = iter(data)
def next_val():
return next(it_data)
for value in iter(next_val, 0):
print(value)
# 1
# 2
# 3 —— 遇到 0 就停止,0 和后面的 4、5 都不会被遍历到
这个用法在"不断读取,直到遇到某个特殊值就停止"的场景很好用,比如经典的 iter(file.readline, '') 用来逐行读文件直到空行。
四、面试追问
Q1:可迭代对象和迭代器的区别是什么?
可迭代对象实现了 __iter__ 方法,能产出一个迭代器;迭代器同时实现了 __iter__(返回自身)和 __next__(返回下一个值,取完抛 StopIteration)。可迭代对象每次调用 iter() 都能拿到一个全新的、独立的迭代器,而迭代器本身是一次性的、有状态的。
Q2:for 循环背后到底做了什么?
先调用 iter(obj) 拿到一个迭代器,然后反复调用这个迭代器的 next(),把返回值赋给循环变量并执行循环体,直到某次 next() 抛出 StopIteration,for 语句会自动捕获这个异常并正常结束循环,不会让异常继续传播出来。
Q3:为什么迭代器不能重复使用?
迭代器把遍历进度保存在自身状态里(比如一个记录当前位置的计数器),一旦所有元素都被取完、抛出过一次 StopIteration,这个状态不会自动重置,之后再调用 next() 只会持续抛出同样的异常,没有办法让它"从头再来一遍"。
Q4:为什么可迭代对象的 __iter__ 不应该直接 return self?
如果 __iter__ 直接返回 self,意味着这个对象本身同时兼任了可迭代对象和迭代器两个角色,遍历状态和对象实例本身绑死在一起。这样一来,这个对象只能被完整遍历一次,第二次遍历同一个实例只能拿到空结果,也没办法支持多个独立的 for 循环同时对它遍历——如果这个类确实需要支持重复/并发遍历,__iter__ 应该每次都返回一个全新的迭代器实例。
Q5:怎么判断一个对象是可迭代对象还是迭代器?
用 collections.abc 模块里的 Iterable/Iterator 抽象基类做 isinstance 检查(isinstance(obj, Iterable)/isinstance(obj, Iterator)),这是标准做法,比自己手动检查 hasattr(obj, "__iter__")/hasattr(obj, "__next__") 更规范、更符合鸭子类型的官方约定。
下一篇预告
Day08 讲生成器与 yield——生成器为什么能大幅节省内存,yield from 又是用来解决什么问题的。