第 22 章 · 迭代器(Iterator)
一句话:把“怎样一个个拿到元素”装进一个对象,调用方只管要下一个,不用知道元素是在列表里、树上,还是在一页页的接口后面。 解决的痛:翻页、游标、限流写在每个调用方的循环里;平台改一次分页规则,每个循环都要重写,写错一个就多拉或少拉,导出的文件照样生成,看不出错。
22.1 一个场景
Section titled “22.1 一个场景”你在电商公司做财务后台。店铺开在一个第三方平台上,订单存在平台那边,平台只开放了一个查询接口 list_orders(page, size):传页码和每页条数,返回这一页的订单和订单总数。
用到这个接口的脚本有两个,由不同的人维护:
- 对账脚本 reconcile.py:每天把平台上每笔订单的金额和我们自己的账对一遍,对不上的列出来。
- 导出脚本 export.py:把平台上的全部订单导成一个文件,每笔一行,交给财务做报表。
接口一次只给一页,两个脚本都要自己写循环,一页页往下拉。
本章用内存里的列表模拟平台:一共 7 笔订单,每页 3 笔,分 3 页。LEDGER 是我们自己的账,其中 O2 和 O6 两笔金额和平台对不上。全局变量 requests 记录一共请求了平台几次。
22.2 先按直觉写
Section titled “22.2 先按直觉写”每个脚本写一个 while 循环,页码从 1 开始往上加:
from dataclasses import dataclass
@dataclassclass Order: id: str amount: int # 元
# 模拟第三方平台:平台上的全部订单,接口按页码分页PLATFORM_ORDERS = [Order("O1", 120), Order("O2", 199), Order("O3", 58), Order("O4", 300), Order("O5", 75), Order("O6", 410), Order("O7", 66)]LEDGER = {"O1": 120, "O2": 189, "O3": 58, "O4": 300, "O5": 75, "O6": 401, "O7": 66} # 我们自己的账requests = 0 # 一共请求了平台几次
def list_orders(page: int, size: int) -> tuple[list[Order], int]: """平台接口:返回第 page 页(从 1 开始)和订单总数。""" global requests requests += 1 start = (page - 1) * size return PLATFORM_ORDERS[start:start + size], len(PLATFORM_ORDERS)
# reconcile.py:对账,金额和账上对不上的都列出来def reconcile() -> list[str]: problems: list[str] = [] page = 1 while True: items, total = list_orders(page, 3) for o in items: if LEDGER.get(o.id) != o.amount: problems.append(f"{o.id} 平台 {o.amount} 元,账上 {LEDGER.get(o.id)} 元") if page * 3 >= total: # 最后一页 break page += 1 return problems
# export.py:导出全部订单,每笔一行def export() -> list[str]: rows: list[str] = [] page, fetched = 1, 0 while True: items, total = list_orders(page, 3) rows += [f"{o.id},{o.amount}" for o in items] fetched += len(items) if fetched >= total: # 拿够了 break page += 1 return rows
if __name__ == "__main__": for line in reconcile(): print("对账:" + line) rows = export() print(f"导出 {len(rows)} 行:{' '.join(rows)}") print(f"共请求 {requests} 次")
# 输出:# 对账:O2 平台 199 元,账上 189 元# 对账:O6 平台 410 元,账上 401 元# 导出 7 行:O1,120 O2,199 O3,58 O4,300 O5,75 O6,410 O7,66# 共请求 6 次TS 版结构一样,export 是保留字,函数叫 exportOrders:
interface Order { id: string; amount: number; // 元}
// 模拟第三方平台:平台上的全部订单,接口按页码分页const PLATFORM_ORDERS: Order[] = [ { id: "O1", amount: 120 }, { id: "O2", amount: 199 }, { id: "O3", amount: 58 }, { id: "O4", amount: 300 }, { id: "O5", amount: 75 }, { id: "O6", amount: 410 }, { id: "O7", amount: 66 },];const LEDGER = new Map(Object.entries({ O1: 120, O2: 189, O3: 58, O4: 300, O5: 75, O6: 401, O7: 66 })); // 我们自己的账let requests = 0; // 一共请求了平台几次
function listOrders(page: number, size: number): [Order[], number] { // 平台接口:返回第 page 页(从 1 开始)和订单总数 requests += 1; const start = (page - 1) * size; return [PLATFORM_ORDERS.slice(start, start + size), PLATFORM_ORDERS.length];}
// reconcile.ts:对账,金额和账上对不上的都列出来function reconcile(): string[] { const problems: string[] = []; let page = 1; while (true) { const [items, total] = listOrders(page, 3); for (const o of items) { if (LEDGER.get(o.id) !== o.amount) { problems.push(`${o.id} 平台 ${o.amount} 元,账上 ${LEDGER.get(o.id)} 元`); } } if (page * 3 >= total) break; // 最后一页 page += 1; } return problems;}
// export.ts:导出全部订单,每笔一行function exportOrders(): string[] { const rows: string[] = []; let page = 1; let fetched = 0; while (true) { const [items, total] = listOrders(page, 3); rows.push(...items.map((o) => `${o.id},${o.amount}`)); fetched += items.length; if (fetched >= total) break; // 拿够了 page += 1; } return rows;}
for (const line of reconcile()) console.log("对账:" + line);const rows = exportOrders();console.log(`导出 ${rows.length} 行:${rows.join(" ")}`);console.log(`共请求 ${requests} 次`);
// 输出:// 对账:O2 平台 199 元,账上 189 元// 对账:O6 平台 410 元,账上 401 元// 导出 7 行:O1,120 O2,199 O3,58 O4,300 O5,75 O6,410 O7,66// 共请求 6 次两个循环判断“最后一页”的写法不一样:对账看页码乘每页条数够不够总数,导出数自己拿了几条。两种都对。接口规则不变的时候,循环写在用的地方,一眼能看懂,大多数人第一版都这么写。
22.3 需求变了
Section titled “22.3 需求变了”平台发来通知,同时财务也提了要求:平台下线页码接口,改成游标分页:list_orders(cursor, size) 返回这一页和 next_cursor,下一次把它原样传回去,最后一页的 next_cursor 为空;平台开始限流,每秒最多 2 次请求,第 3 次直接拒绝;对账改成找到第一笔对不上的就停,马上告警,不用把剩下的页拉完。
游标(cursor)是平台给的书签,记着上次读到哪,调用方不用懂它。平台这边的模拟先换掉(这是平台的事,不算我们要改的):
class RateLimited(Exception): pass
class Clock: # 假时钟:sleep 只把时间往前拨,不真等 def __init__(self) -> None: self.now = 0
def sleep(self, seconds: int) -> None: self.now += seconds
clock = Clock()calls: list[int] = [] # 平台记下的每次请求时间
def list_orders(cursor: str | None, size: int) -> tuple[list[Order], str | None]: """平台新接口:cursor 为 None 表示从头拉;最后一页的 next_cursor 为 None。""" if calls.count(clock.now) >= 2: # 限流:同一秒第 3 次直接拒绝 raise RateLimited(f"第 {clock.now} 秒已经请求过 2 次") calls.append(clock.now) start = int(cursor or 0) # 真实的游标是一串看不懂的字符,这里用起始位置模拟 end = start + size return PLATFORM_ORDERS[start:end], (str(end) if end < len(PLATFORM_ORDERS) else None)我们这边要改三处:
- 对账脚本的循环整个重写:页码换成游标,“最后一页”换成
next_cursor为空;找到第一笔对不上的就return。 - 导出脚本的循环也整个重写,改的是同样的东西,写的是另一个人。
- 两个脚本各自加限流:数这一秒发了几次,满 2 次就等一秒。同一段逻辑写两遍,位置各不相同。
两人改完是这样:
# reconcile.py:对账,找到第一笔对不上的就停def reconcile() -> str | None: cursor: str | None = None sent = 0 # ③ 这一秒发了几次 while True: if sent == 2: # ③ 限流:这一秒用满了,先等一秒 clock.sleep(1) sent = 0 items, next_cursor = list_orders(cursor, 3) # ① 换成游标 sent += 1 for o in items: if LEDGER.get(o.id) != o.amount: return f"{o.id} 平台 {o.amount} 元,账上 {LEDGER.get(o.id)} 元" # ① 找到第一笔就停 if not next_cursor: # ① 没有下一页 return None cursor = next_cursor
# export.py:导出全部订单,每笔一行def export() -> list[str]: rows: list[str] = [] cursor: str | None = None sent = 0 # ③ 这一秒发了几次 while True: items, next_cursor = list_orders(cursor, 3) # ② 换成游标 sent += 1 rows += [f"{o.id},{o.amount}" for o in items] if sent == 2: # ③ 限流:这一秒用满了,歇一秒再接着拉 clock.sleep(1) sent = 0 continue if not next_cursor: # ② 没有下一页就停 break cursor = next_cursor return rows把这三段换进 before.py(删掉 requests 和旧的 list_orders),入口换成下面这段。两个脚本是分开跑的,中间隔一秒:
if __name__ == "__main__": print(f"对账:{reconcile()},请求 {len(calls)} 次") clock.sleep(1) # 两个脚本分开跑 before = len(calls) rows = export() print(f"导出 {len(rows)} 行(平台上 {len(PLATFORM_ORDERS)} 笔),请求 {len(calls) - before} 次") print(" ".join(rows))运行结果如下(TS 版改的是同样的地方,输出相同):
对账:O2 平台 199 元,账上 189 元,请求 1 次导出 11 行(平台上 7 笔),请求 5 次O1,120 O2,199 O3,58 O4,300 O5,75 O6,410 O4,300 O5,75 O6,410 O7,66 O7,66对账没问题,第 1 页就找到 O2。导出出了事:平台上 7 笔,文件里 11 行,O4 到 O7 都出现了两次。
原因在导出循环里 continue 的位置。限流写在“拉完、写完”之后,满 2 次就歇一秒再 continue,而 continue 跳过了终止判断 if not next_cursor 和挪游标 cursor = next_cursor。游标没挪,下一轮就把刚才那页再拉一遍。第 2、4 次请求正好用满这一秒的 2 次,拉的是第 2 页和最后一页,于是这两页各拉了两次。对账那份把限流放在请求之前,就没事。文件格式完全正常,不报错,财务按它算营业额,O4 到 O7 的 851 元被算了两遍。
问题的形状是:翻页的细节(游标怎么传、什么时候停、限流怎么等)写在每个调用方的循环里,平台改一次规则,每个循环都要重写;写错的那一个不报错,只是多拉或少拉。再加一个用订单的脚本,又是一份循环、一份限流。
图中 ①–③ 就是上面三处,黄色是为新需求加的,浅红是被改写的循环。红色加粗的框是那次事故:导出循环里,终止判断和挪游标写在 continue 后面,被跳过了。
22.4 用迭代器模式重写
Section titled “22.4 用迭代器模式重写”换个思路:别让调用方翻页。把“全部订单”做成能直接 for 的对象 OrderPages,翻页交给它生出来的迭代器 OrderIterator。迭代器记着游标、当前页还没交出去的订单(缓冲区)和“是不是最后一页”:每要一笔就从缓冲区交一笔,缓冲区空了才拉下一页。限流计数放在 OrderPages 上,每次请求前过一遍。
Python 里,对象能被 for 的条件是有 __iter__,返回一个迭代器;迭代器的 __next__ 每调一次给出下一个元素,没有了就抛 StopIteration。for o in orders 就是先调 iter(orders),再反复调 next(),直到 StopIteration。
from collections.abc import Iteratorfrom dataclasses import dataclass
@dataclassclass Order: id: str amount: int # 元
class RateLimited(Exception): pass
class Clock: # 假时钟:sleep 只把时间往前拨,不真等 def __init__(self) -> None: self.now = 0
def sleep(self, seconds: int) -> None: self.now += seconds
# 模拟第三方平台:游标分页,每秒最多 2 次请求PLATFORM_ORDERS = [Order("O1", 120), Order("O2", 199), Order("O3", 58), Order("O4", 300), Order("O5", 75), Order("O6", 410), Order("O7", 66)]LEDGER = {"O1": 120, "O2": 189, "O3": 58, "O4": 300, "O5": 75, "O6": 401, "O7": 66} # 我们自己的账clock = Clock()calls: list[int] = [] # 平台记下的每次请求时间outage: set[str] = set() # 请求这些游标时平台超时,用来演示中途出错
def list_orders(cursor: str | None, size: int) -> tuple[list[Order], str | None]: """平台接口:cursor 为 None 表示从头拉;最后一页的 next_cursor 为 None。""" if calls.count(clock.now) >= 2: # 限流:同一秒第 3 次直接拒绝 raise RateLimited(f"第 {clock.now} 秒已经请求过 2 次") calls.append(clock.now) if cursor in outage: raise TimeoutError(f"平台超时(cursor={cursor})") start = int(cursor or 0) # 真实的游标是一串看不懂的字符,这里用起始位置模拟 end = start + size return PLATFORM_ORDERS[start:end], (str(end) if end < len(PLATFORM_ORDERS) else None)
class OrderPages: # 平台上的全部订单:可迭代对象,每次 for 都从头拉 def __init__(self, size: int = 3, per_second: int = 2) -> None: self.size = size self.per_second = per_second self._second = -1 # 限流计数:当前这一秒 self._sent = 0 # 这一秒已经发了几次
def __iter__(self) -> "OrderIterator": return OrderIterator(self)
def fetch(self, cursor: str | None) -> tuple[list[Order], str | None]: # 限流只写在这里 if clock.now != self._second: self._second, self._sent = clock.now, 0 if self._sent == self.per_second: # 这一秒用满了:等到下一秒 clock.sleep(1) self._second, self._sent = clock.now, 0 self._sent += 1 return list_orders(cursor, self.size)
class OrderIterator: # 迭代器:记住游标和当前页还没交出去的订单 def __init__(self, pages: OrderPages) -> None: self.pages = pages self.cursor: str | None = None self.buffer: list[Order] = [] self.last_page = False
def __iter__(self) -> Iterator[Order]: return self
def __next__(self) -> Order: while not self.buffer: # 这一页交完了 if self.last_page: raise StopIteration # 告诉 for:没有了 self.buffer, self.cursor = self.pages.fetch(self.cursor) # 拉下一页 self.last_page = self.cursor is None return self.buffer.pop(0)
def per_second() -> str: return ",".join(f"第 {s} 秒 {calls.count(s)} 次" for s in sorted(set(calls)))
if __name__ == "__main__": orders = OrderPages() for o in orders: # 对账:找到第一笔对不上的就停 if LEDGER[o.id] != o.amount: print(f"对账:{o.id} 平台 {o.amount} 元,账上 {LEDGER[o.id]} 元,请求 {len(calls)} 次") break
clock.sleep(1) # 两个脚本分开跑 before = len(calls) rows = [f"{o.id},{o.amount}" for o in orders] # 导出 print(f"导出 {len(rows)} 行,请求 {len(calls) - before} 次") print(f"每秒请求:{per_second()}")
clock.sleep(1) outage.add("6") # 平台在第 3 页超时 written: list[str] = [] try: for o in orders: written.append(o.id) except TimeoutError as e: print(f"导出中断:{e},已写入 {len(written)} 行")
# 输出:# 对账:O2 平台 199 元,账上 189 元,请求 1 次# 导出 7 行,请求 3 次# 每秒请求:第 0 秒 1 次,第 1 秒 2 次,第 2 秒 1 次# 导出中断:平台超时(cursor=6),已写入 6 行TS 的迭代协议是同一个意思,换了名字:可迭代对象有 [Symbol.iterator]() 方法,返回一个带 next() 的对象;next() 返回 { done: false, value },没有了返回 { done: true }。for...of 和展开运算符 [...orders] 都靠它:
interface Order { id: string; amount: number; // 元}
class RateLimited extends Error {}
class Clock { // 假时钟:sleep 只把时间往前拨,不真等 now = 0; sleep(seconds: number): void { this.now += seconds; }}
// 模拟第三方平台:游标分页,每秒最多 2 次请求const PLATFORM_ORDERS: Order[] = [ { id: "O1", amount: 120 }, { id: "O2", amount: 199 }, { id: "O3", amount: 58 }, { id: "O4", amount: 300 }, { id: "O5", amount: 75 }, { id: "O6", amount: 410 }, { id: "O7", amount: 66 },];const LEDGER = new Map(Object.entries({ O1: 120, O2: 189, O3: 58, O4: 300, O5: 75, O6: 401, O7: 66 })); // 我们自己的账const clock = new Clock();const calls: number[] = []; // 平台记下的每次请求时间const outage = new Set<string>(); // 请求这些游标时平台超时,用来演示中途出错
function listOrders(cursor: string | null, size: number): [Order[], string | null] { // 平台接口:cursor 为 null 表示从头拉;最后一页的 nextCursor 为 null if (calls.filter((t) => t === clock.now).length >= 2) { // 限流:同一秒第 3 次直接拒绝 throw new RateLimited(`第 ${clock.now} 秒已经请求过 2 次`); } calls.push(clock.now); if (cursor !== null && outage.has(cursor)) throw new Error(`平台超时(cursor=${cursor})`); const start = Number(cursor ?? 0); // 真实的游标是一串看不懂的字符,这里用起始位置模拟 const end = start + size; return [PLATFORM_ORDERS.slice(start, end), end < PLATFORM_ORDERS.length ? String(end) : null];}
class OrderPages implements Iterable<Order> { // 平台上的全部订单:可迭代对象,每次 for 都从头拉 private second = -1; // 限流计数:当前这一秒 private sent = 0; // 这一秒已经发了几次
constructor(readonly size = 3, readonly perSecond = 2) {}
[Symbol.iterator](): OrderIterator { return new OrderIterator(this); }
fetch(cursor: string | null): [Order[], string | null] { // 限流只写在这里 if (clock.now !== this.second) [this.second, this.sent] = [clock.now, 0]; if (this.sent === this.perSecond) { // 这一秒用满了:等到下一秒 clock.sleep(1); [this.second, this.sent] = [clock.now, 0]; } this.sent += 1; return listOrders(cursor, this.size); }}
class OrderIterator implements Iterator<Order> { // 迭代器:记住游标和当前页还没交出去的订单 private cursor: string | null = null; private buffer: Order[] = []; private lastPage = false;
constructor(private readonly pages: OrderPages) {}
next(): IteratorResult<Order> { while (this.buffer.length === 0) { // 这一页交完了 if (this.lastPage) return { done: true, value: undefined }; // 告诉 for:没有了 [this.buffer, this.cursor] = this.pages.fetch(this.cursor); // 拉下一页 this.lastPage = this.cursor === null; } return { done: false, value: this.buffer.shift()! }; }}
function perSecond(): string { return [...new Set(calls)].sort((a, b) => a - b) .map((s) => `第 ${s} 秒 ${calls.filter((t) => t === s).length} 次`).join(",");}
const orders = new OrderPages();for (const o of orders) { // 对账:找到第一笔对不上的就停 if (LEDGER.get(o.id) !== o.amount) { console.log(`对账:${o.id} 平台 ${o.amount} 元,账上 ${LEDGER.get(o.id)} 元,请求 ${calls.length} 次`); break; }}
clock.sleep(1); // 两个脚本分开跑const before = calls.length;const rows = [...orders].map((o) => `${o.id},${o.amount}`); // 导出console.log(`导出 ${rows.length} 行,请求 ${calls.length - before} 次`);console.log(`每秒请求:${perSecond()}`);
clock.sleep(1);outage.add("6"); // 平台在第 3 页超时const written: string[] = [];try { for (const o of orders) written.push(o.id);} catch (e) { console.log(`导出中断:${(e as Error).message},已写入 ${written.length} 行`);}
// 输出:// 对账:O2 平台 199 元,账上 189 元,请求 1 次// 导出 7 行,请求 3 次// 每秒请求:第 0 秒 1 次,第 1 秒 2 次,第 2 秒 1 次// 导出中断:平台超时(cursor=6),已写入 6 行看黄色的两个类:游标、缓冲、限流都在它们里面,调用方那一格只剩一句 for o in orders。
看 next() 和请求的比例:三次 next() 才有一次请求;对账在 O2 处 break,后面的 next() 没被调,第 2、3 页不会被拉。
逐条对照 22.3:
- ① 对账:就是
for加break,请求 1 次,平台上一共 3 页。调用方不再要,迭代器就不再拉。 - ② 导出:一个列表推导式,请求 3 次,7 行。终止判断和挪游标只在
__next__里有一份。 - ③ 限流:只在
OrderPages.fetch里写一次。导出的第 3 次请求遇上第 1 秒已满 2 次,等到第 2 秒再发,平台没有拒绝过。 - 事故:没有调用方自己挪游标,也就没有“跳过挪游标”这回事。
- 平台再改规则,比如每页 100 条、游标换个字段名:只改
OrderPages和OrderIterator,两个脚本不动。再加一个用订单的脚本,也只写for o in orders。
有几件事要说清楚:
- 出错的地方挪进了循环里。以前请求失败,异常从
list_orders(...)那一行抛出来;现在请求藏在next()里,异常从for o in orders这一行抛出来,而且是在循环体已经处理过前面几笔之后:上面最后一段,第 3 页超时,written里已经有 6 行。半截的结果由调用方处理:要么删掉重来,要么续拉。续拉要知道游标,可游标藏在迭代器里,还得另开口子把它交出来。用推导式导出时,异常一抛,rows根本没被赋值,前 6 笔也一起丢了。 for看起来便宜,其实不便宜。看到for o in orders,很容易以为是在遍历内存里的列表,实际上每一圈都可能发请求,遇上限流还会在next()里等一秒。同一个orders写两个for,就从头拉两遍(__iter__每次新建迭代器)。
迭代器换来的是翻页规则只写一份,调用方只写 for,要多少拉多少。代价是请求和失败都藏进了 next():什么时候发请求、失败时手里有几笔,要回到迭代器里才看得清。
22.5 模式定义
Section titled “22.5 模式定义”GoF 对迭代器的定义是:提供一种方法顺序访问一个聚合对象中各个元素,而又不需暴露该对象的内部表示。
换成本章的话:“聚合对象”是平台上的全部订单 OrderPages;“内部表示”是它们分页存放、要靠游标一页页取、每秒只能取两次。调用方只拿到“下一笔”,看不到页、游标和限流。
迭代器分两种。本章这种叫外部迭代器(external iterator):调用方一次次要下一个,想停就停,也能拿着两个迭代器交替走,比如把两份订单按订单号合并。另一种是内部迭代器(internal iterator):集合提供 for_each(callback),自己把每个元素交给你的回调。它写起来省事,可调用方没法中途喊停:JS 数组的 forEach 里写 break 是语法错误,return 只结束这一次回调;要支持提前停,就得约定回调返回某个值表示“停”。本章的对账要找到第一笔就停,外部迭代器正合适。
GoF 原书的迭代器有 first()、next()、isDone()、currentItem() 四个方法。Python 和 TS 把后三个合成一个:__next__() / next() 既前进又交出元素,用 StopIteration 或 done: true 表示完了。first() 没有对应,想从头再来,就再要一个新的迭代器。
| 角色 | 在本章案例里是谁 | 职责 |
|---|---|---|
| 迭代器(Iterator) | Python 的 collections.abc.Iterator(有 __next__),TS 的 Iterator<Order> 接口 |
声明“给我下一个”的方法,并能表示“没有了” |
| 具体迭代器(ConcreteIterator) | OrderIterator |
记住游标、缓冲和是否最后一页;缓冲空了就拉下一页 |
| 聚合(Aggregate) | Python 的 collections.abc.Iterable(有 __iter__),TS 的 Iterable<Order> 接口(有 [Symbol.iterator]()) |
声明“给我一个迭代器”的方法 |
| 具体聚合(ConcreteAggregate) | OrderPages |
每次被要迭代器时新建一个 OrderIterator;本章还顺带管着限流 |
常见场景:
- 分页接口。平台、云服务的列表接口包一层迭代器,调用方只写
for,22.7 的例二就是。 - 树的遍历。想在第 13 章的套餐树外面一个个拿节点,给树一个深度优先或广度优先的迭代器,22.10 的练习就做这个。
- 文件逐行读取。文件可能比内存还大,一行一行地给,22.7 的例一就是。
- 数据库游标。结果有上百万行时,驱动一批批地从服务器取,调用方
for row in cursor逐行处理。Python 的 DB-API 规范(PEP 249)把“游标可迭代”列为可选扩展,标准库的 sqlite3 支持。
22.6 更简洁的写法
Section titled “22.6 更简洁的写法”Python 和 TS 都有生成器(generator):函数里写 yield,调用它不执行函数体,而是得到一个迭代器;每调一次 next(),函数体就跑到下一个 yield,交出一个值后停在那里,局部变量原样保留,下一次 next() 接着走。
import asynciofrom collections.abc import AsyncIterator, Iteratorfrom dataclasses import dataclass
@dataclassclass Order: id: str amount: int # 元
PLATFORM_ORDERS = [Order("O1", 120), Order("O2", 199), Order("O3", 58), Order("O4", 300), Order("O5", 75), Order("O6", 410), Order("O7", 66)]LEDGER = {"O1": 120, "O2": 189, "O3": 58, "O4": 300, "O5": 75, "O6": 401, "O7": 66}calls: list[str | None] = [] # 记下每次请求的游标
def list_orders(cursor: str | None, size: int) -> tuple[list[Order], str | None]: # 同 22.4,省掉了限流 calls.append(cursor) start = int(cursor or 0) end = start + size return PLATFORM_ORDERS[start:end], (str(end) if end < len(PLATFORM_ORDERS) else None)
def iter_orders(size: int = 3) -> Iterator[Order]: # 生成器函数:调用它只得到一个迭代器,一行都不执行 cursor: str | None = None # 局部变量就是 OrderIterator 的字段 while True: items, cursor = list_orders(cursor, size) # 限流写在这一行前面,同样只写一次 yield from items # 交出一笔就暂停在这里,下次 next() 从这里接着走 if cursor is None: return # 函数结束,for 就停
async def alist_orders(cursor: str | None, size: int) -> tuple[list[Order], str | None]: await asyncio.sleep(0) # 模拟网络请求:真实系统里是 await 一个 HTTP 调用 return list_orders(cursor, size)
async def aiter_orders(size: int = 3) -> AsyncIterator[Order]: # 异步生成器:async def 里写 yield cursor: str | None = None while True: items, cursor = await alist_orders(cursor, size) for o in items: yield o if cursor is None: return
async def main() -> None: calls.clear() async for o in aiter_orders(): # async for:每拿一笔都 await 一次 if o.amount > 250: print(f"异步:第一笔超过 250 元的是 {o.id},请求 {len(calls)} 次") break
if __name__ == "__main__": orders = iter_orders() print(f"生成器建好了,请求 {len(calls)} 次") for o in orders: if LEDGER[o.id] != o.amount: print(f"对账:{o.id} 对不上,请求 {len(calls)} 次") break calls.clear() print(f"导出 {len(list(iter_orders()))} 行,请求 {len(calls)} 次") asyncio.run(main())
# 输出:# 生成器建好了,请求 0 次# 对账:O2 对不上,请求 1 次# 导出 7 行,请求 3 次# 异步:第一笔超过 250 元的是 O4,请求 2 次TS 的生成器函数写成 function*,yield* 把一个数组里的元素逐个交出;异步的写成 async function*,用 for await 消费:
interface Order { id: string; amount: number; // 元}
const PLATFORM_ORDERS: Order[] = [ { id: "O1", amount: 120 }, { id: "O2", amount: 199 }, { id: "O3", amount: 58 }, { id: "O4", amount: 300 }, { id: "O5", amount: 75 }, { id: "O6", amount: 410 }, { id: "O7", amount: 66 },];const LEDGER = new Map(Object.entries({ O1: 120, O2: 189, O3: 58, O4: 300, O5: 75, O6: 401, O7: 66 }));const calls: (string | null)[] = []; // 记下每次请求的游标
function listOrders(cursor: string | null, size: number): [Order[], string | null] { // 同 22.4,省掉了限流 calls.push(cursor); const start = Number(cursor ?? 0); const end = start + size; return [PLATFORM_ORDERS.slice(start, end), end < PLATFORM_ORDERS.length ? String(end) : null];}
function* iterOrders(size = 3): Generator<Order> { // 生成器函数:调用它只得到一个迭代器,一行都不执行 let cursor: string | null = null; // 局部变量就是 OrderIterator 的字段 while (true) { let items: Order[]; [items, cursor] = listOrders(cursor, size); // 限流写在这一行前面,同样只写一次 yield* items; // 交出一笔就暂停在这里,下次 next() 从这里接着走 if (cursor === null) return; // 函数结束,for 就停 }}
async function listOrdersAsync(cursor: string | null, size: number): Promise<[Order[], string | null]> { await Promise.resolve(); // 模拟网络请求:真实系统里是 await 一个 HTTP 调用 return listOrders(cursor, size);}
async function* iterOrdersAsync(size = 3): AsyncGenerator<Order> { // 异步生成器:async function* 里写 yield let cursor: string | null = null; while (true) { let items: Order[]; [items, cursor] = await listOrdersAsync(cursor, size); for (const o of items) yield o; if (cursor === null) return; }}
async function main(): Promise<void> { calls.length = 0; for await (const o of iterOrdersAsync()) { // for await:每拿一笔都 await 一次 if (o.amount > 250) { console.log(`异步:第一笔超过 250 元的是 ${o.id},请求 ${calls.length} 次`); break; } }}
const orders = iterOrders();console.log(`生成器建好了,请求 ${calls.length} 次`);for (const o of orders) { if (LEDGER.get(o.id) !== o.amount) { console.log(`对账:${o.id} 对不上,请求 ${calls.length} 次`); break; }}calls.length = 0;console.log(`导出 ${[...iterOrders()].length} 行,请求 ${calls.length} 次`);void main();
// 输出:// 生成器建好了,请求 0 次// 对账:O2 对不上,请求 1 次// 导出 7 行,请求 3 次// 异步:第一笔超过 250 元的是 O4,请求 2 次生成器就是编译器替你写好的迭代器类。OrderIterator 的三个字段在这里各有去处:cursor 还是局部变量 cursor,buffer 成了 items 加上 yield from 走到第几个,last_page 成了 if cursor is None: return 这一句;__next__ 里“缓冲空了没有、是不是最后一页”的判断,变成了函数体从上往下的顺序,停在哪个 yield 就是状态。结构没变:第一行输出说明建好生成器时一次请求都没发,对账 break 以后同样不再请求。
真实接口的请求是异步的,要 await,普通的 __next__ 里没法等。async def 里写 yield(TS 是 async function*)得到异步迭代器,每取一个都要 await,消费的一方写 async for(TS 是 for await)。最后一行到第 2 页的 O4 就停了,第 3 页没有请求。
语言替你省掉的是 OrderIterator 这个类和它的状态判断。要注意生成器对象只能走一遍:orders = iter_orders() 以后再对 orders 写第二个 for,接着上次停下的地方走,走完了就什么也拿不到。要能反复遍历,就保留 OrderPages 这样每次 __iter__ 返回新生成器的类。语言特性怎样替掉各个模式,第 29 章统一归纳。
22.7 真实系统里的例子
Section titled “22.7 真实系统里的例子”例一:文件对象和 csv.reader(Python)
Section titled “例一:文件对象和 csv.reader(Python)”Python 里打开的文本文件本身就是迭代器:for line in f 每次给一行。它不会一上来把整个文件读进内存,而是一块块从磁盘读(io.DEFAULT_BUFFER_SIZE 常见是 8192 字节),从块里切出一行交一行,切完再读下一块,大致如此。csv.reader(f) 也是迭代器,它对传进来的对象一次次调 next(),拿到一行就解析成列表,所以几个 G 的 CSV 也能边读边处理。下面模拟一个按块读、按行交的文件对象,再交给真正的 csv.reader:
# 模拟 Python 的文本文件对象:它本身就是迭代器,next() 一次给一行,底下按块读磁盘。# 简化:真实的文件对象有缓冲区(io.DEFAULT_BUFFER_SIZE,常见是 8192 字节)和编码解码,这里一块 16 个字符。import csvimport io
class ChunkedLineReader: def __init__(self, raw: io.StringIO, chunk_size: int = 16) -> None: self.raw = raw # 底下的"磁盘" self.chunk_size = chunk_size self.pending = "" # 读进来还没交出去的字符 self.reads = 0 # 读了几块
def __iter__(self) -> "ChunkedLineReader": return self
def __next__(self) -> str: # 对应文件对象的 __next__:交出一行 while "\n" not in self.pending: chunk = self.raw.read(self.chunk_size) self.reads += 1 if not chunk: # 读到文件尾 break self.pending += chunk if not self.pending: raise StopIteration line, sep, self.pending = self.pending.partition("\n") return line + sep
DATA = "id,amount\nO1,120\nO2,199\nO3,58\nO4,300\nO5,75\nO6,410\nO7,66\n" # 56 个字符f = ChunkedLineReader(io.StringIO(DATA))for row in csv.reader(f): # 真的 csv.reader:它只管对传进来的对象一次次调 next() if row[1] != "amount" and int(row[1]) > 250: print(f"第一笔超过 250 元:{row[0]},读了 {f.reads} 块") break
whole = ChunkedLineReader(io.StringIO(DATA))print(f"读完整个文件:{sum(1 for _ in csv.reader(whole))} 行,读了 {whole.reads} 块")
# 输出:# 第一笔超过 250 元:O4,读了 3 块# 读完整个文件:8 行,读了 5 块ChunkedLineReader 和本章的 OrderIterator 是同一个形状:块对应页,pending 对应缓冲,缓冲里切不出一行了才去读下一块。找 O4 只读了 3 块,读完全部要 5 块(最后一次读到文件尾)。csv.reader 是叠在上面的第二个迭代器,不关心底下是文件、网络还是这个模拟类,这就是“不暴露内部表示”。
例二:AWS SDK v3 的分页器(TypeScript)
Section titled “例二:AWS SDK v3 的分页器(TypeScript)”AWS 的 S3 存储里,列出一个桶(bucket)里的对象用 ListObjectsV2,一次最多返回 1000 个,后面的要带上次返回的 NextContinuationToken 再请求,和本章的游标是一回事。AWS SDK for JavaScript v3 给这类接口配了分页器,比如 paginateListObjectsV2({ client }, { Bucket }),返回一个异步可迭代对象,用 for await 一页页取,分页器自己传续页令牌,没有令牌了就停,大致如此。下面用 async function* 模拟这个分页器:
// 模拟 AWS SDK v3 的 paginateListObjectsV2:返回异步可迭代对象,每次 for await 拿到一页 ListObjectsV2 的结果。// 简化:真实的分页器还接受 pageSize、startingToken 等配置,客户端要 send(new ListObjectsV2Command(...)),大致如此。interface S3Object { Key: string; Size: number }interface ListObjectsV2Input { Bucket: string; MaxKeys?: number; ContinuationToken?: string }interface ListObjectsV2Output { Contents: S3Object[]; IsTruncated: boolean; NextContinuationToken?: string }
class FakeS3Client { // 一个桶里 10 个对象,每次最多返回 MaxKeys 个 requests = 0; private objects = Array.from({ length: 10 }, (_, i) => ({ Key: `orders/2026-10-${String(i + 1).padStart(2, "0")}.csv`, Size: 100 * (i + 1) }));
async listObjectsV2(input: ListObjectsV2Input): Promise<ListObjectsV2Output> { this.requests += 1; await Promise.resolve(); // 模拟网络请求 const start = Number(input.ContinuationToken ?? 0); const end = start + (input.MaxKeys ?? 1000); const more = end < this.objects.length; return { Contents: this.objects.slice(start, end), IsTruncated: more, NextContinuationToken: more ? String(end) : undefined }; }}
async function* paginateListObjectsV2(config: { client: FakeS3Client }, input: ListObjectsV2Input): AsyncGenerator<ListObjectsV2Output> { let token: string | undefined = input.ContinuationToken; do { const page = await config.client.listObjectsV2({ ...input, ContinuationToken: token }); yield page; // 一次交出一页 token = page.NextContinuationToken; } while (token !== undefined);}
async function main(): Promise<void> { const client = new FakeS3Client(); search: for await (const page of paginateListObjectsV2({ client }, { Bucket: "exports", MaxKeys: 3 })) { for (const obj of page.Contents) { if (obj.Size > 400) { console.log(`第一个超过 400 字节的:${obj.Key},请求 ${client.requests} 次`); break search; // 跳出外层 for await:分页器被关掉,不再请求 } } } let total = 0; for await (const page of paginateListObjectsV2({ client }, { Bucket: "exports", MaxKeys: 3 })) total += page.Contents.length; console.log(`列完整个桶:${total} 个对象,累计请求 ${client.requests} 次`);}
void main();
// 输出:// 第一个超过 400 字节的:orders/2026-10-05.csv,请求 2 次// 列完整个桶:10 个对象,累计请求 6 次分页器就是 22.6 的 iterOrdersAsync,只是一次交出一整页而不是一笔。break search 跳出 for await 时,运行时会调异步生成器的 return(),生成器就此结束,第 3、4 页没有请求;列完整个桶要 4 次,累计 6 次。
22.8 什么时候别用
Section titled “22.8 什么时候别用”- 数据就在内存里的一个列表。订单已经是
list[Order],直接for o in orders,列表自带迭代器。再包一个类、写一个迭代器类,读代码的人要跳过两个类才发现底下就是列表,还丢了len()、下标和切片。 - 需要总数、随机访问或者往回走。迭代器只会往前走,也不知道一共有多少。财务报表要在第一行写“共 N 笔”,用
OrderPages就只能先全部拉下来再数,等于没用上“要多少拉多少”;要按金额排序、翻到第 5 页看,也都得先变成列表。 - 遍历过程中改底下的集合。迭代器记的是“走到第几个”,集合在它脚下变了,它不知道。下面这段想删掉金额为 0 的订单:
orders = [("O1", 0), ("O2", 0), ("O3", 58), ("O4", 0)]for o in orders: if o[1] == 0: orders.remove(o)print(orders) # [('O2', 0), ('O3', 58)]删掉 O1 以后,后面的元素整体前移一格,O2 挪到了第 0 个,而迭代器下一步去看第 1 个,O2 就被跳过了,金额为 0 的 O2 留在了结果里。TS 里在 for...of 中对数组 splice 也是同样的结果。Python 的字典更严格,遍历时增删键会直接抛 RuntimeError: dictionary changed size during iteration。按页码翻页也有同样的事:翻页途中平台删掉了第 1 页的一笔,后面整体前移,第 2 页的第一笔掉进第 1 页,被跳过;真实平台的游标一般记“上次读到哪一笔”,不受影响(本章模拟的游标只是偏移量,会受影响)。要边遍历边删,就遍历一份副本(for o in list(orders)),或者用推导式生成新列表。
22.9 容易混淆的模式
Section titled “22.9 容易混淆的模式”| 模式 | 意图 | 区别 |
|---|---|---|
| 迭代器(Iterator) | 不暴露内部结构地逐个访问元素 | 只管“下一个是谁”,对每个元素做什么由调用方在循环里决定 |
| 组合(Composite) | 把对象组织成树,单个和整体一样对待 | 组合是结构:定义树的形状,以及每个节点怎样回答问题(price()、weight());迭代器是遍历这个结构的一种方式。见第 13 章 |
| 访问者(Visitor) | 不改节点类,给一组节点类加新操作 | 也是“对树上每个节点做事”,但要对不同类型的节点做不同的事(单品算税、套餐算折扣),由节点回调访问者里对应的方法。见第 26 章 |
两组都能用一个问题当场判断:
- 迭代器还是组合:问“要的是一个个节点,还是一个答案”。第 13 章的
Bundle.weight()让每个节点自己往下调,汇总成一个数,这是组合。22.10 把套餐树的节点按顺序一个个交出来,拿它们做什么写在外面的循环里,这是迭代器。两者常一起用:组合搭好树,迭代器走树。 - 迭代器还是访问者:问“对每个节点做的事,要不要按节点类型分开写”。逐个拿出来统一处理,或者循环里
isinstance一两下就够,用迭代器。节点类型多、操作也多,循环里的类型判断越来越长,就该看第 26 章的访问者。
22.10 练一练
Section titled “22.10 练一练”给第 13 章的套餐树写一个深度优先迭代器:年货礼盒里有坚果套装(碧根果、夏威夷果、巴旦木)、茶叶套装(龙井、普洱)和一张贺卡。只需要 Product(name) 和 Bundle(name, items) 两个类。
写一个类 DepthFirstIterator(root),实现 Python 的迭代协议(TS 的 next()),按深度优先的顺序产出 (层级, 名称):礼盒是第 0 层,两个套装和贺卡是第 1 层,单品是第 2 层。用它把整棵树按层级缩进打印出来。然后用生成器写一个更短的版本 walk(node, level),验证两者产出的序列相同;再用生成器只取前 3 个节点,取到就 break。Python 和 TS 各写一版。
提示:类的版本没法递归,因为 __next__ 交一个就得返回。用一个显式的栈,放 (层级, 节点):弹出一个,是套餐就把孩子倒序压进栈,第一个孩子才会最先弹出。生成器版直接递归,对每个孩子 yield from walk(child, level + 1)(TS 是 yield*)。想要广度优先,把栈换成队列。
22.11 小结
Section titled “22.11 小结”- 迭代器把“怎样一个个拿到元素”装进一个对象:调用方只要下一个,不知道底下是列表、树,还是分页接口。
- 游标、缓冲、什么时候停、限流只在迭代器里写一份,平台改规则只改它;要一个给一个,提前
break就不再请求。 - 代价是请求和失败都藏进了
next():异常从for那一行抛出,手里可能已有半截结果;for看着便宜,每一圈都可能在发请求、等限流。 - 生成器是编译器替你写好的迭代器类,局部变量就是状态;异步分页用异步生成器和
async for、for await。生成器只能走一遍,要反复遍历就让__iter__每次返回新的。 - 和组合的区别:组合定义树的结构,迭代器是走它的方式;对不同类型的节点要做不同的事,看第 26 章的访问者。