我最早意识到迭代器这东西必须自己动手写,不是在学校学语法的时候,而是在一个深夜排查线上问题的现场。当时我们的服务要批量处理一份十几GB的日志文件,第一版代码特别简单,直接 open(file).readlines() 然后循环处理。结果程序启动没几分钟,内存直接飙到几个G,紧接着整个容器被 OOM Killer 干掉了。后来把读取方式改成逐行迭代,内存占用瞬间降到几十MB,同一个任务从"跑不起来"变成了"轻松跑完"。
那次之后我明白了一个道理:迭代器不是一个语法糖,而是一套关于"按需生产数据"的设计思想。自定义迭代器看起来只是实现 __iter__ 和 __next__ 两个方法,但真正用好的时候,它能让你的代码在内存、性能、可读性上同时受益。这篇文章我想从设计者的视角,把自定义迭代器这件事从头到尾拆开讲清楚,包含协议原理、实战案例、踩坑记录和性能分析,希望能帮你少走一些弯路。
如果你正准备处理大文件、构造数据管道、给自定义容器加遍历能力,或者只是想把 for 循环背后的机制彻底搞明白,这篇文章都适合你。我会用Python为主、JavaScript为辅做对比讲解,因为这两种语言里迭代器都是高频核心机制,而且设计思路高度相似。
1. 一个让内存直接爆掉的需求:为什么非得自己写迭代器
先说个真实场景。我接手过一个数据同步模块,需求是从外部接口拉取用户行为数据,逐条清洗后写入数仓。接口的输出是一个很大的JSON数组,正常情况一次返回几万条记录。第一版代码长这样:
python复制import requests
def fetch_all_users():
response = requests.get("https://api.example.com/user-events", timeout=30)
data = response.json()
return data["items"]
然后主函数里这样做:
python复制def sync():
items = fetch_all_users()
for item in items:
clean_and_write(item)
表面看没问题,接口返回一个数组,循环遍历,一条条处理。但实际上有两个隐患。
隐患一:response.json() 会把整个响应体一次性加载到内存。如果一个接口返回50万条记录、每条2KB,一共就是1GB。再加上解析后Python对象的开销,内存占用会放大到好几倍。服务器只有4G内存的话,直接就是OOM的风险。
隐患二:通过 requests.get 拿到的响应已经全部加载到内存了,哪怕你改成流式读取 stream=True,如果代码里还写着 json(),一样是全部加载。
这个问题的本质是:数据量一大,"先全部拿到再处理"的思路就走不通了。你要的其实是一个一个地拿数据、处理、丢掉,再拿下一个——这个"一次拿一个"的能力,正是迭代器提供的。
如果用自定义迭代器来改造,思路完全不一样。你可以让迭代器内部维护一个游标,每次 next() 只获取一条数据,无论底层数据有多大,内存里始终只有"当前这一条"。
code复制底层数据(几GB文件 / 几万条接口记录)
|
| 迭代器内部按需取数据
v
内存里只有一条
|
v
处理完就丢弃
用代码表示的话,大概是这个骨架:
python复制class UserEventIterator:
def __init__(self, api_client):
self.api_client = api_client
self.cursor = None
self.current_batch = []
self.index = 0
def __iter__(self):
return self
def __next__(self):
if self.index >= len(self.current_batch):
self.current_batch = self.api_client.fetch_next_page(cursor=self.cursor)
self.index = 0
self.cursor = self.current_batch.get("next_cursor")
if not self.current_batch.get("items"):
raise StopIteration
item = self.current_batch["items"][self.index]
self.index += 1
return item
每次 for 循环向它要一个元素时,它只从当前批次里取一条;批次用完了,再去接口拉下一页。这样一来,无论总数据量是1万条还是1亿条,内存占用是固定的。这就是自定义迭代器最朴素也最重要的价值。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 迭代器协议拆解:Python和JavaScript的底层约定
在动手写自定义迭代器之前,得先把协议本身弄清楚。很多人写过迭代器,但说不清协议为什么这么设计。这里我分别讲一下Python和JavaScript两边的约定,然后解释背后的通用逻辑。
2.1 Python的迭代器协议:iter 和 next
Python的迭代器协议由两个方法组成:
__iter__(self):返回迭代器对象自身,或者返回一个新的可迭代对象。for循环一开始会调用这个方法,拿到的返回值就是要用的迭代器。__next__(self):每次循环取一个元素时调用。返回当前元素;如果没元素了,抛出StopIteration异常。
注意一个关键点:for 循环不检查返回值是否为 None,它只依赖异常来结束循环。也就是说,如果没有元素了却还在 __next__ 里返回了一个"空值",循环会永远跑下去。这是个容易踩的坑。
一个最简自定义迭代器长这样:
python复制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
使用的时候:
python复制for num in CountDown(3):
print(num)
# 输出:
# 3
# 2
# 1
为什么 __iter__ 要返回 self?因为 __iter__ 的用途是"给出一个迭代器",而如果这个对象本身就已经是迭代器(有 __next__),那直接返回自己就行。这样同一个对象既能被 for 循环消费,也能传给 iter() 函数。不过要注意,一个对象也可以只实现 __iter__ 而不实现 __next__,这叫做"可迭代对象",而 __iter__ 每次都返回一个全新的迭代器。比如一个列表就是这样,每次 for 都能从头开始遍历。
2.2 JavaScript的迭代器协议:Symbol.iterator 和 next
JavaScript的协议思路和Python完全对得上,只是名字不同。
javascript复制const countDown = {
current: 3,
[Symbol.iterator]() {
return this;
},
next() {
if (this.current <= 0) {
return { done: true, value: undefined };
}
return { done: false, value: this.current-- };
}
};
for (const num of countDown) {
console.log(num);
}
// 输出:3, 2, 1
区别在于,JavaScript的 next() 不通过异常标记结束,而是返回 { done: boolean, value: any },用 done: true 表示没有更多元素。Python里收到一个没有元素信号的方式是异常,收到异常的成本在CPython里相对较高,所以性能敏感时通常用 default 参数或者先判断长度。JavaScript则用普通对象返回,结束信号是常态路径,不是异常路径。
两种设计各有取舍,但核心思路完全一致:迭代器是一个"按需生产元素"的对象,它内部维护着自己的状态,外部通过统一的接口逐个取出元素。
2.3 协议背后的三个核心设计意图
理解了协议之后,我总结一下这种设计背后的逻辑,对后续写自定义迭代器很有帮助:
第一,解耦遍历逻辑和容器结构。 实现迭代器协议后,调用方不需要知道数据是数组、链表、树,还是从网络上拉取的。只要会 for 循环,就能遍历一切。
第二,状态被封装在迭代器内部。 遍历到哪个位置、下一步该取什么,都是迭代器对象自己维护的。外部世界不需要知道游标长什么样。
第三,惰性计算成为可能。 因为迭代器是"你要一个我给一个",所以可以做到"生产一个消费一个",而不是"全部生产完再消费"。这也是上一节那个大文件处理方案能跑通的原因。
3. 一个能用的迭代器长什么样:分页数据读取器的完整实现
协议讲清楚了,现在我从零写一个真实场景下的自定义迭代器——分页数据读取器。这个案例在工程实践中非常典型,几乎每个对接外部API的团队都会碰到。
假定你调用的接口长这样:
code复制GET /api/users?limit=100&cursor=xxx
响应:
{
"items": [...],
"next_cursor": "abc123",
"has_more": true
}
我们的目标是写一个迭代器,让调用方只关心 for user in UserListIterator(client):,而分页、游标、状态管理全部封装在迭代器内部。
3.1 基础版:只做逐页取数
python复制class UserListIterator:
def __init__(self, client, page_size=100):
self.client = client
self.page_size = page_size
self.cursor = None
self.has_more = True
self.current_page = []
self.index = 0
def __iter__(self):
return self
def __next__(self):
if self.index >= len(self.current_page):
if not self.has_more:
raise StopIteration
self._load_next_page()
user = self.current_page[self.index]
self.index += 1
return user
def _load_next_page(self):
response = self.client.get_users(
limit=self.page_size,
cursor=self.cursor
)
self.current_page = response["items"]
self.index = 0
self.cursor = response.get("next_cursor")
self.has_more = response.get("has_more", False)
这个类做对了三件事:
__iter__返回self,说明它是一个迭代器,可以被for直接使用。__next__里先检查当前批次的数据是否消费完了,消耗完再去拉下一页。- 用一个
has_more字段标记是否还有数据,配合StopIteration保证循环能正常终止。
有没有觉得哪里不对劲?仔细看,for user in UserListIterator(client) 每次循环拿到一个 user,但是当第一页拿完100个之后,它会自动去拉第二页、第三页……直到 has_more=False。这整个过程中,调用方完全感知不到"分页"的存在。这就是迭代器封装的威力。
3.2 增强版:加上重试和超时处理
真实接口调用没有一次就成功的,网络抖动、限流、超时都是常态。无脑把异常抛出去会让业务代码很难看。我通常会在迭代器的内部做一定程度的容错。
python复制import time
import logging
logger = logging.getLogger(__name__)
class ResilientUserListIterator:
MAX_RETRIES = 3
BACKOFF_SECONDS = 1.0
def __init__(self, client, page_size=100):
self.client = client
self.page_size = page_size
self.cursor = None
self.has_more = True
self.current_page = []
self.index = 0
def __iter__(self):
return self
def __next__(self):
if self.index >= len(self.current_page):
if not self.has_more:
raise StopIteration
self._load_next_page()
user = self.current_page[self.index]
self.index += 1
return user
def _load_next_page(self):
for attempt in range(self.MAX_RETRIES):
try:
response = self.client.get_users(
limit=self.page_size,
cursor=self.cursor
)
self.current_page = response["items"]
self.index = 0
self.cursor = response.get("next_cursor")
self.has_more = response.get("has_more", False)
return
except Exception as exc:
logger.warning("拉取用户分页失败(游标=%s),重试 %d/%d:%s",
self.cursor, attempt + 1, self.MAX_RETRIES, exc)
if attempt == self.MAX_RETRIES - 1:
raise exc
time.sleep(self.BACKOFF_SECONDS * (2 ** attempt))
这样做的原因是:__next__ 是给 for 循环用的,调用方往往没有能力也没意愿处理每一次传输层的异常。把重试策略放在迭代器内部,业务代码就干净了。当然,重试次数和退避策略要按实际场景调整,并且重试抛出的异常最好统一包装成自定义异常类型,方便上层捕获。
3.3 进阶玩法:让迭代器可重复遍历
很多人使用迭代器时忽略了一个特性:迭代器是一次性消费的。for 循环跑完之后,这个迭代器就报废了,再跑一遍得到的是空结果。但有些场景下,你希望同一份数据可以被反复遍历(比如跑多个统计任务)。
解决办法是:不把迭代器本身当可迭代对象返回,而是在 __iter__ 里创建并返回一个新的迭代器。
python复制class UserList:
def __init__(self, client, page_size=100):
self.client = client
self.page_size = page_size
def __iter__(self):
return UserListIterator(self.client, self.page_size)
现在 UserList 是可迭代对象,每次 for 都会创建一个全新迭代器。遍历完一次后,下次再 for 会重新从第一页开始。
这个模式和标准库里的 range、list 是一致的:它们都是可迭代的,但每次遍历都产生独立迭代器。你需要做的只是把"迭代器"和"可迭代对象"两个概念分开设计。
3.4 扩展:双向迭代器与无限迭代器
迭代器不一定是单向的。你可以在内部维护一个索引和方向标记,实现前向/后向遍历。比如一个历史事件浏览器,既想看新的事件,又想回头看看之前的:
python复制class BidirectionalListIterator:
def __init__(self, data):
self.data = data
self.index = 0
def __iter__(self):
return self
def __next__(self):
if self.index >= len(self.data):
raise StopIteration
value = self.data[self.index]
self.index += 1
return value
def prev(self):
if self.index <= 0:
raise StopIteration
self.index -= 1
return self.data[self.index]
还有无限迭代器。不需要终止条件,依赖外部来中断。最常见的例子是生成一个自增ID流水号:
python复制class InfiniteCounter:
def __init__(self, start=0, step=1):
self.value = start
self.step = step
def __iter__(self):
return self
def __next__(self):
value = self.value
self.value += self.step
return value
使用 itertools.islice 控制数量:
python复制from itertools import islice
counter = InfiniteCounter()
for val in islice(counter, 5):
print(val)
# 0 1 2 3 4
这种模式在某些场景很有用,比如生成测试数据、流式计算、实时日志采集等。但要注意,无限迭代器如果没有外部中断,会导致程序卡死,所以使用时要格外谨慎。
4. 设计和实现迭代器时最容易踩的坑:状态、异常和边界
自定义迭代器写多了之后,你会发现核心逻辑本身并不复杂,难的是边界条件和异常处理。这一节我把这几年踩过的坑集中做一次复盘,每个坑都给出了现象和解决方案。
4.1 坑一:迭代器对象被多个消费者共享
这是所有坑里出现频率最高的。一个迭代器对象同时被两段代码消费,结果就是一段代码推进了游标,另一段代码拿到的数据就变了。典型场景:
python复制class DataStream:
def __init__(self, data):
self.data = data
self.index = 0
def __iter__(self):
return self
def __next__(self):
if self.index >= len(self.data):
raise StopIteration
value = self.data[self.index]
self.index += 1
return value
stream = DataStream([1, 2, 3, 4, 5])
first = [x for x in stream]
second = [x for x in stream]
print(first) # [1, 2, 3, 4, 5]
print(second) # [] <- 第二次取不到数据了
这里的本质问题是 stream 对象自身就是迭代器,它的索引状态是全局的。第二次遍历时,索引已经指向末尾,自然拿不到数据。
解决思路有两个:
- 像前面
UserList案例一样,把迭代器作为独立状态对象,每次__iter__创建新实例。 - 如果确实需要单对象可重复遍历,可以在内部实现重置逻辑(比如
__iter__里重置索引),但这样并发消费时依旧会出问题。
我的经验是,能创建新迭代器就尽量创建新迭代器,不要试图通过复位状态来复用老对象,因为"复位"这件事很容易漏掉某些属性,而且并发场景下根本无法保证正确性。
4.2 坑二:忘记抛出 StopIteration 导致死循环
这个坑在初学者代码里非常常见。假设你写了一个迭代器,条件边界判断错了:
python复制class BadIterator:
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
value = self.current
self.current += 1
return value
for num in BadIterator(3):
print(num)
猜猜会发生什么?current 增长到4、5、6……永远 > limit,但循环永远不结束。因为当 current = 3 时,3 > 3 为 False,还会返回3;然后 current = 4,4 > 3 为 True,抛异常停止了。咦,那这个例子其实不会死循环?对,因为这个边界只错了一位,最终还是能结束。但如果边界错得更多,比如 if self.current != self.limit 这种,就真的可能无限循环了。
这种问题最麻烦的地方在于它不一定每次都能触发,数据一大才出现,复现成本高。我的建议是,迭代器里所有终止条件都写成"显式判断耗尽"的形式,比如:
python复制def __next__(self):
if self.index >= len(self.data):
raise StopIteration
这样即使你逻辑上漏算了某一条数据,循环至少能保证终止,不会把程序吊死。
4.3 坑三:迭代器内抛出普通异常导致数据丢失
假设你的迭代器在处理数据过程中出现了异常。比如你从数据库读记录,某一条数据格式坏了,导致解析异常直接抛出。这时候调用方收到异常,可能会认为"拉取失败",从而重试整个流程。但问题是,这个迭代器对象已经把前面几条数据吐出去了,内部状态已经推进到中间位置。重试的时候如果复用同一个迭代器,你会跳过一部分数据。
应对方案是:迭代器内部做好异常隔离,对于单条数据的错误不要直接往外抛,而是记录日志后跳过或放到失败队列。比如上面的分页读取器,可以在迭代器里加一个 _dirty_items 列表,把解析失败的数据收集起来统一上报,而不是让异常中断整个迭代过程。
当然这个策略要看业务,有的场景单条数据失败就意味着整个任务失败,这时候把异常往上抛也是合理的。关键是要想清楚"数据已经消费了一部分"这个事实,不要让重试覆盖掉已经处理的数据。
4.4 坑四:iter 返回了非迭代器对象
前面说过,__iter__ 应该返回迭代器对象。如果写代码时不小心返回了一个普通对象,而它没有 __next__ 方法,for 循环会直接报 TypeError: iter() returned non-iterator of type '...'。
有个比较容易忽视的场景是:在自定义类里把 __iter__ 写成了生成器函数的调用形式,导致每次 __iter__ 返回一个生成器对象,而不是迭代器本身。这种代码实际上表现正常,因为生成器本身就是迭代器,但它有一个副作用:每次 for 都会重新执行 __iter__ 里的代码,如果你在 __iter__ 里做了一些状态初始化,可能跟预期不符。
更安全的设计原则是:__iter__ 只做一件事——返回一个迭代器。任何状态初始化都放到迭代器构造过程中完成。
4.5 坑五:忘记处理容器被修改的情况
在遍历一个集合的过程中,如果集合被并发修改了,会引发各种诡异问题。标准库里 dict、list 在遍历时被修改,会抛 RuntimeError 或 ConcurrentModificationError。自定义迭代器也需要考虑这种情况。
最简单的处理是快照式迭代,即在 __iter__ 里把数据变成列表:
python复制class SnapshotList:
def __init__(self, data):
self.data = data
def __iter__(self):
return iter(list(self.data))
这种方式牺牲了一定的惰性,但保证了遍历的确定性。如果你的数据量特别大,不便快照,那就需要在迭代器内部记录一个版本号,每次修改容器时递增,迭代器每次 __next__ 检查版本号是否变化,变了就抛异常。
这个机制在数据库的游标实现里很常见,叫做"并发控制"。对于普通业务代码,我个人倾向于快照方案,简单可靠,不会有隐藏的复杂度。
5. 生成器还是手写迭代器?两种方案的取舍标准
很多人在写迭代器之前会犹豫:到底是直接用生成器(yield),还是规规矩矩手写一个类?这是个很实际的问题。我用过两种方案处理过不少场景,各自有明确的适用条件。
5.1 生成器是迭代器的语法糖
Python的生成器函数,只要有 yield 关键字,调用时返回一个生成器对象。这个对象天然实现了迭代器协议,能让代码看起来特别简洁。
比如上面的 CountDown,用生成器写只需要几行:
python复制def count_down(start):
while start > 0:
yield start
start -= 1
如果你不需要保存额外的状态、不需要精细的异常控制、不需要被多个地方共享,那么生成器几乎总是更合适的选择。原因很简单:代码量少,不容易出错。
5.2 手写迭代器的优势场景
但工程里总有一些场景是生成器不太舒服的:
需要复用状态和方法时。迭代器类可以挂载很多辅助方法,比如 reset()、peek()、has_next(),而生成器函数很难在外部操作它内部的状态。
需要在迭代过程中暴露更多元数据时。比如分页读取器,你希望外部能看到"当前页码""还剩多少页""接口响应状态码"。这些元数据如果是生成器,就得通过另一个对象来传递,很别扭。而类实现的话,这些东西就是属性,随便读。
需要支持复杂继承或混合进工厂模式时。类更容易扩展,比如 BaseIterator 定义一个骨架,子类实现具体的取数逻辑。
下面是我自己的经验判断表:
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 快速实现一个惰性计算管道 | 生成器 | 代码量少,表达力强 |
| 需要多次遍历同一份数据 | 手写可迭代类 | 每次生成新迭代器,天然支持重遍历 |
| 需要和外部API交互、维护分页状态 | 手写迭代器类 | 状态和容错逻辑封装更清晰 |
| 需要给外部暴露元数据(页码、游标) | 手写迭代器类 | 属性访问比生成器方便 |
| 只需要一次性遍历 | 生成器 | 简洁,无额外状态管理 |
| 想实现递归数据结构遍历(树、图) | 生成器 | yield from 非常适合递归 |
5.3 混合使用:类中集成生成器
其实两者不是互斥的。一个手写迭代器类里的 __iter__ 方法可以返回生成器,这样利用两边的优点:
python复制class DataLoader:
def __init__(self, source):
self.source = source
def __iter__(self):
for chunk in self._read_chunks():
yield chunk
def _read_chunks(self):
# 内部可以用生成器实现
while True:
chunk = self._read_next()
if not chunk:
break
yield chunk
这个类实现了迭代器协议(因为 __iter__ 返回一个生成器),但本身不是一个迭代器(没有 __next__),外部每次 for 都会生成一个新的迭代器,天然支持重复遍历。这种混合模式在许多框架源码里都能看到。
6. 迭代器设计里的性能账:惰性求值到底省了什么
很多人写迭代器只是觉得"应该这么做",但说不清性能上的收益到底从哪里来。这一节我把这笔账算清楚,顺便聊聊什么时候迭代器可能变得很慢,以及怎么优化。
6.1 内存账:从O(n)到O(1)
最直观的收益是内存。前面提到的读文件场景,一次全部读入(readlines())的时间复杂度是O(n)内存,而逐行迭代是O(1)内存。这在大数据场景下是决定性的区别。
做个简单估算。假设一行日志1KB,处理1亿行日志:
readlines()先读入100GB的字符串对象,再加上每行的Python字符串对象开销(约50~100字节/行),内存占用会超过200GB。普通服务器根本扛不住。- 迭代器逐行读取,内存里只有当前行(1KB)和少量缓冲,峰值占用几MB就算顶天了。
同样的逻辑适用于数据库游标、API分页、超大数组生成等场景。只要数据源本身是"流式"的,迭代器就是天然正确的选择。
6.2 时间账:惰性计算没有"总时间"优势,但有"首元素延迟"优势
有时候人们误以为迭代器会让整个任务跑得更快。其实总CPU时间通常没有下降,甚至因为产生对象、函数调用等开销会略有上升。迭代器真正的时间优势是首元素延迟(time to first element)。
普通一次性加载模式必须先等全部数据传输完成,再开始处理第一条数据;迭代器则传输一条处理一条。对于流式日志、实时推荐、聊天消息等场景,首元素延迟决定了用户体验,这点比总吞吐更重要。
举个例子:一个数据管道要跑三个步骤,A -> B -> C。如果用列表,必须A全部跑完,再B全部跑完,再C全部跑完。用迭代器的话,A处理完第一条,B就可以开始;B处理完第一条,C就可以开始。在多核处理器的流水线里,这种结构能大大提升系统吞吐。实际上,Python的 itertools 库里很多工具函数就是靠迭代器组合来实现高效管道处理的。
6.3 迭代器变慢的场景:重复遍历基础设施
迭代器有一个弱点——如果你需要多次遍历同一份数据,而底层数据源不支持"重复读取",就会导致重复计算。
比如:
python复制data = read_huge_file() # 一个迭代器
for line in data:
count_words(line)
for line in data:
count_lines(line)
第二个for循环什么都拿不到,因为第一个for已经耗尽迭代器了。如果你确实需要多次遍历,有两个办法:
方案一:缓存到临时文件或数据库。把第一次遍历的结果持久化,后续多次读取都从持久化层走。
方案二:在可迭代对象层面设计"重新打开"的能力。比如分页读取器每次 __iter__ 都重新从第一页开始,这样即使同一个对象也能多次遍历。代价是每次遍历都会重新请求接口,反复传输数据。
实际工程中,我会先问自己:同一个数据到底需要遍历几次?如果只遍历一次,迭代器完美;如果遍历次数很多,数据量又大到无法放内存,那可能需要落盘缓存;如果数据量小到能放内存,那就直接 list(data) 最简单。
6.4 迭代器性能的两个实用经验
经验一:尽量用内置迭代函数组合,而不是自己套循环。 itertools 模块里 islice、takewhile、chain、zip 这些函数都是C实现的,比手写循环快很多。比如切片一个迭代器的前100个元素:
python复制from itertools import islice
for item in islice(big_iterator, 100):
process(item)
经验二:避免在__next__中做重活。 如果每取一个元素都要做复杂计算,整个消费流程会被卡住。通用的优化思路是:把耗时的部分放到 __iter__ 里预先创建好一批结果,__next__ 只做简单的取数动作。这和"预取"是一个道理。
7. 一个跨语言视角:自定义迭代器的通用设计模式
前面主要用Python做例子,但其实迭代器的设计模式在所有主流语言里都是相通的。我快速过一下JavaScript、Java、C++里的实现套路,帮你建立通用的心智模型。
7.1 JavaScript:可迭代协议 + 生成器
JavaScript的 Symbol.iterator 协议和Python类似。除了手写 [Symbol.iterator]() 之外,JS也支持生成器函数:
javascript复制function* countDown(start) {
while (start > 0) {
yield start;
start -= 1;
}
}
for (const num of countDown(3)) {
console.log(num);
}
如果你做过前端或者Node.js开发,用迭代器处理流式接口数据(比如 fetch 返回的流、WebSocket推送的消息队列)非常顺手。
7.2 Java:Iterator 接口和 Iterable 接口
Java的迭代器抽象是从JDK 1.2就存在的经典接口。核心是 Iterator<E>,包含 hasNext() 和 next() 两个方法。与Python不同,Java没有用异常标记结束,而是用 hasNext() 方法先行判断。这样设计的好处是调用方可以提前知道是否还有元素,代码意图更明确。
实现一个 Iterable<T> 自定义迭代器在Java里也很常见:
java复制class Range implements Iterable<Integer> {
private final int start;
private final int end;
Range(int start, int end) {
this.start = start;
this.end = end;
}
@Override
public Iterator<Integer> iterator() {
return new Iterator<>() {
private int current = start;
@Override
public boolean hasNext() {
return current < end;
}
@Override
public Integer next() {
if (!hasNext()) {
throw new NoSuchElementException();
}
return current++;
}
};
}
}
Java的 Iterable 和 Iterator 两个接口分离得比较彻底,可迭代对象不承担遍历状态。这也让并发修改检查(fail-fast)更容易实施。
7.3 C++:迭代器四大分类
C++标准库把迭代器分成了五类:输入迭代器、输出迭代器、前向迭代器、双向迭代器、随机访问迭代器。每种分类对应不同的能力集合,比如随机访问迭代器支持 +5、[] 这样的操作,而单向迭代器只能 ++。
这跟Python的迭代器协议有个很大的不同:C++迭代器天生就是"指针"的泛化,用它来遍历元素时,还常常涉及到迭代器的类型属性和复杂度保证。写C++自定义迭代器需要同时满足各种 iterator_traits 要求,门槛比Python高不少,但表达能力也更强。
7.4 语言之间的共性和启发
把这几门语言的迭代器放一起看,能提炼出一些通用设计要点:
共性一:迭代器独立于容器存在。 容器负责存储,迭代器负责遍历。这个分离是迭代器模式的核心。
共性二:状态是迭代器的灵魂。 遍历到哪里、边界在哪、还有多少元素,都得由迭代器自身维护。
共性三:协议设计要不冷不热。 太简单的协议(只有next)容易被误用;太复杂的协议(像C++五类)对新手不友好。Python、JS选择了简单,Java和C++选择了更强的控制,各有取舍。
这些共性说明,迭代器不是某一门语言的专属技巧,而是一种通用设计思维。你需要的不是背语法,而是理解"状态""惰性""按需生产"这几个底层概念。
8. 自定义迭代器的测试思路:核心逻辑验证与边界覆盖
写迭代器只是第一步,接下来得确保它行为正确。迭代器的测试有自己的一套套路,和普通函数的测试不太一样。本节分享一些我实际用过的测试思路。
8.1 用 list() 快速断言全量输出
最直接的方式就是把迭代器转成列表,然后断言列表内容。如果不确定迭代次数,这个办法最直观。
python复制def test_count_down():
assert list(CountDown(3)) == [3, 2, 1]
assert list(CountDown(0)) == []
8.2 测试惰性:确认元素是逐个产生的
有时候你要验证的不是最终结果,而是"确实没有一次性加载所有数据"。可以把加载逻辑换成一个打桩函数,跟踪它被调用的次数:
python复制def test_lazy_loading():
calls = []
class FakeClient:
def get_users(self, limit, cursor):
calls.append(cursor)
return {
"items": [{"id": cursor or 1}, {"id": (cursor or 1) + 1}],
"next_cursor": (cursor or 0) + 2,
"has_more": len(calls) < 3,
}
it = UserListIterator(FakeClient(), page_size=2)
first = next(it)
assert first == {"id": 1}
assert len(calls) == 1 # 只实际拉取了第一页
8.3 测试StopIteration和异常路径
异常路径往往比正常路径更容易藏bug。至少覆盖这三个场景:
- 空数据时第一次
next()就抛StopIteration - 最后一页数据刚好被消费完时,下一次
next()抛StopIteration - 内部接口抛异常时,重试逻辑是否按预期触发
8.4 测试"可重复遍历"
如果你实现的是可迭代对象(而非迭代器),要验证多次遍历结果一致:
python复制def test_reiterable():
data = UserList(FakeClient())
assert [u["id"] for u in data] == [1, 2, 3, 4, 5]
assert [u["id"] for u in data] == [1, 2, 3, 4, 5]
如果第二次遍历和第一次结果不同,基本就能确定是共享状态的问题。
9. 让我重新理解"迭代器"这件事
写到这里,我想把这次的经验总结成几个做项目时很关键的认识。
第一,自定义迭代器的价值不在语法,而在设计。当你把遍历逻辑封装进一个迭代器后,调用方可以完全不关心数据从哪来、怎么产生、何时终止。这种抽象让代码的自然边界变得非常清晰,也让测试和替换实现变得容易。
第二,遇到"一次只处理一个"的诉求,第一时间想到迭代器。处理大文件、查询超大数据库、拉取分页接口、生成无穷序列——这些场景都有一个共同特征:数据量不可预知,但每次只需要一个元素。这正是迭代器的主场。
第三,迭代器与生成器的选择,本质是"简单"和"可控"的权衡。如果你只需要快速表达计算过程,用生成器;如果你需要管理和观察状态,手写类。实际项目里,二者经常混用。
第四,编写迭代器一定要在测试上下足功夫。边界条件、异常路径、可重复遍历,这些都是迭代器最容易出问题的地方。如果你准备在生产代码中使用自定义迭代器,请务必为它写专门的测试用例,而不是只靠主流程顺手跑通。
第五,理解迭代器的本质是理解"惰性"。不是所有问题都需要立刻把所有数据拿到手里。很多场景下,"按需生产、逐条消费"不只是在省内存,更是在重构你思考问题的角度。
自定义迭代器的设计这个话题,说大不大,说小也不小。它藏在一门语言最基础的for循环背后,但真正吃透它的人,写出来的代码往往更加优雅和稳健。希望这篇文章能帮你在自己的项目里,把这一块用起来。
