跳转到内容

第 22 章 · 迭代器(Iterator)

一句话:把“怎样一个个拿到元素”装进一个对象,调用方只管要下一个,不用知道元素是在列表里、树上,还是在一页页的接口后面。 解决的痛:翻页、游标、限流写在每个调用方的循环里;平台改一次分页规则,每个循环都要重写,写错一个就多拉或少拉,导出的文件照样生成,看不出错。

你在电商公司做财务后台。店铺开在一个第三方平台上,订单存在平台那边,平台只开放了一个查询接口 list_orders(page, size):传页码和每页条数,返回这一页的订单和订单总数。

用到这个接口的脚本有两个,由不同的人维护:

  • 对账脚本 reconcile.py:每天把平台上每笔订单的金额和我们自己的账对一遍,对不上的列出来。
  • 导出脚本 export.py:把平台上的全部订单导成一个文件,每笔一行,交给财务做报表。

接口一次只给一页,两个脚本都要自己写循环,一页页往下拉。

本章用内存里的列表模拟平台:一共 7 笔订单,每页 3 笔,分 3 页。LEDGER 是我们自己的账,其中 O2 和 O6 两笔金额和平台对不上。全局变量 requests 记录一共请求了平台几次。

每个脚本写一个 while 循环,页码从 1 开始往上加:

from dataclasses import dataclass
@dataclass
class 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 次

两个循环判断“最后一页”的写法不一样:对账看页码乘每页条数够不够总数,导出数自己拿了几条。两种都对。接口规则不变的时候,循环写在用的地方,一眼能看懂,大多数人第一版都这么写。

平台发来通知,同时财务也提了要求:平台下线页码接口,改成游标分页: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)

我们这边要改三处:

  1. 对账脚本的循环整个重写:页码换成游标,“最后一页”换成 next_cursor 为空;找到第一笔对不上的就 return。
  2. 导出脚本的循环也整个重写,改的是同样的东西,写的是另一个人。
  3. 两个脚本各自加限流:数这一秒发了几次,满 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 跳过

图中 ①–③ 就是上面三处,黄色是为新需求加的,浅红是被改写的循环。红色加粗的框是那次事故:导出循环里,终止判断和挪游标写在 continue 后面,被跳过了。

换个思路:别让调用方翻页。把“全部订单”做成能直接 for 的对象 OrderPages,翻页交给它生出来的迭代器 OrderIterator。迭代器记着游标、当前页还没交出去的订单(缓冲区)和“是不是最后一页”:每要一笔就从缓冲区交一笔,缓冲区空了才拉下一页。限流计数放在 OrderPages 上,每次请求前过一遍。

Python 里,对象能被 for 的条件是有 __iter__,返回一个迭代器;迭代器的 __next__ 每调一次给出下一个元素,没有了就抛 StopIteration。for o in orders 就是先调 iter(orders),再反复调 next(),直到 StopIteration。

from collections.abc import Iterator
from dataclasses import dataclass
@dataclass
class 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 行

迭代器版:OrderPages 管限流,OrderIterator 管游标和缓冲,调用方只写 for

看黄色的两个类:游标、缓冲、限流都在它们里面,调用方那一格只剩一句 for o in orders。

一次 for 循环:缓冲用完才请求下一页,break 之后不再请求

看 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():什么时候发请求、失败时手里有几笔,要回到迭代器里才看得清。

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 支持。

Python 和 TS 都有生成器(generator):函数里写 yield,调用它不执行函数体,而是得到一个迭代器;每调一次 next(),函数体就跑到下一个 yield,交出一个值后停在那里,局部变量原样保留,下一次 next() 接着走。

import asyncio
from collections.abc import AsyncIterator, Iterator
from dataclasses import dataclass
@dataclass
class 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 章统一归纳。

例一:文件对象和 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 csv
import 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 次。

  • 数据就在内存里的一个列表。订单已经是 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)),或者用推导式生成新列表。

模式 意图 区别
迭代器(Iterator) 不暴露内部结构地逐个访问元素 只管“下一个是谁”,对每个元素做什么由调用方在循环里决定
组合(Composite) 把对象组织成树,单个和整体一样对待 组合是结构:定义树的形状,以及每个节点怎样回答问题(price()、weight());迭代器是遍历这个结构的一种方式。见第 13 章
访问者(Visitor) 不改节点类,给一组节点类加新操作 也是“对树上每个节点做事”,但要对不同类型的节点做不同的事(单品算税、套餐算折扣),由节点回调访问者里对应的方法。见第 26 章

两组都能用一个问题当场判断:

  • 迭代器还是组合:问“要的是一个个节点,还是一个答案”。第 13 章的 Bundle.weight() 让每个节点自己往下调,汇总成一个数,这是组合。22.10 把套餐树的节点按顺序一个个交出来,拿它们做什么写在外面的循环里,这是迭代器。两者常一起用:组合搭好树,迭代器走树。
  • 迭代器还是访问者:问“对每个节点做的事,要不要按节点类型分开写”。逐个拿出来统一处理,或者循环里 isinstance 一两下就够,用迭代器。节点类型多、操作也多,循环里的类型判断越来越长,就该看第 26 章的访问者。

给第 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*)。想要广度优先,把栈换成队列。

  • 迭代器把“怎样一个个拿到元素”装进一个对象:调用方只要下一个,不知道底下是列表、树,还是分页接口。
  • 游标、缓冲、什么时候停、限流只在迭代器里写一份,平台改规则只改它;要一个给一个,提前 break 就不再请求。
  • 代价是请求和失败都藏进了 next():异常从 for 那一行抛出,手里可能已有半截结果;for 看着便宜,每一圈都可能在发请求、等限流。
  • 生成器是编译器替你写好的迭代器类,局部变量就是状态;异步分页用异步生成器和 async for、for await。生成器只能走一遍,要反复遍历就让 __iter__ 每次返回新的。
  • 和组合的区别:组合定义树的结构,迭代器是走它的方式;对不同类型的节点要做不同的事,看第 26 章的访问者。