同一个 list 为什么能被两个 for 循环同时遍历?一文讲透可迭代对象与迭代器的真相

「Python 进阶之路」系列 Day07

写在前面

同一个 list,可以同时被两个 for 循环遍历,各自互不干扰------一个循环遍历到第二个元素了,另一个循环依然能从头开始。这背后其实藏着一个经常被忽略的设计:你手里的这个 list,和 for 循环真正用来遍历的东西,根本不是同一个对象。

今天这篇把 for 循环背后的迭代器协议讲透:可迭代对象和迭代器到底是什么关系,为什么要拆成两个概念,以及自己写一个支持遍历的类时最容易踩的坑。


一、是什么:可迭代对象与迭代器的区别

可迭代对象(Iterable) :实现了 __iter__ 方法的对象,调用 __iter__() 能返回一个迭代器。

迭代器(Iterator) :同时实现了 __iter__(返回自身)和 __next__(返回下一个值,取完了抛 StopIteration)的对象。

python 复制代码
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() 都能拿到一个全新的、独立的迭代器:

python 复制代码
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) 都是全新的一个,互相之间没有任何关系。

迭代器一旦耗尽,就永远耗尽了,不会自动重置

python 复制代码
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[结束循环]
  1. 调用 iter(obj),拿到一个迭代器
  2. 反复调用这个迭代器的 next(),把返回值赋给 x,执行循环体
  3. 直到某次 next() 抛出 StopIteration,循环正常结束(这个异常被 for 语句自动捕获,不会传播出来)

用手写代码还原这个过程,效果和真正的 for 循环完全一样:

python 复制代码
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. 手写迭代器:一次性消耗品

python 复制代码
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

python 复制代码
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,互不干扰:

python 复制代码
for a in ci:
    for b in ci:
        print(a, b)
        break
# 1 1
# 2 1
# 3 1

4. 用 collections.abc 判断类型

python 复制代码
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/Iteratorcollections.abc 里的抽象基类,判断一个对象是否遵循对应协议,比自己写 hasattr 检查更规范。

5. iter() 的哨兵用法

iter() 还有一个不太常用但很实用的两参数形式:iter(callable, sentinel)------不断调用 callable(),直到返回值等于 sentinel 就停止(不包含 sentinel 本身):

python 复制代码
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() 抛出 StopIterationfor 语句会自动捕获这个异常并正常结束循环,不会让异常继续传播出来。

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 又是用来解决什么问题的。

相关推荐
王中阳Go1 小时前
用TRAE Work批量优化学员简历,原来2天的活现在2小时就干完了
后端·面试·agent
他们叫我秃子1 小时前
前端开发转 Go 全栈(四):代码写在前面,却要最后执行?我终于搞懂了 defer
前端·后端·go
Csvn2 小时前
📊 SQL 入门 Day 14:聚合窗口函数 — 让 SUM / AVG 也能"滑动"起来
后端·sql
Oneslide2 小时前
K8s NodePort 端口为什么 netstat 查不到?
后端
月走乂山2 小时前
零依赖 GUI:把 OpenCode CLI 变成可视化 AI 任务流水线
python·自动化·ai编程·tkinter·opencode
北冥you鱼2 小时前
Go语言大数(big.Int)比较大小:原理、方法与最佳实践
开发语言·后端·golang
kkkAloha2 小时前
非对称加解密理解
后端
xcLeigh2 小时前
Go入门:零值与变量默认初始化机制
开发语言·后端·golang
孙启超3 小时前
【AI应用开发】Agent 无限 loop(反复调用同一个工具)如何规避?
人工智能·python·算法·llm·agent·loop·ai应用开发