📑 本页目录(点开跳转)
02 · 生成器与惰性求值
⏱ 74 分钟 | ⭐ yield 不是「返回」是「暂停」—— 站里那些流式输出的代码,形状全是它决定的
🎯 一句话
生成器不是一份数据,是一个「还没开始跑、被暂停在半路」的函数。 上一章讲的是「你以为在复制、其实在共享」;这一章讲的是它的孪生兄弟——你以为拿到的是数据,其实拿到的是一个一次性的取数动作。
🧩 一、调用一个生成器函数,函数体一行都不执行
只要函数体里出现了 yield,这个函数就不再是普通函数。调用它不执行任何代码,只是造一个生成器对象扔给你。
# 调用生成器函数,函数体一行都不执行
def gen():
print(" [函数体开始跑了]")
yield 1
print(" [从暂停处恢复,继续往下]")
yield 2
print(" [函数体跑到底了]")
print("调用 gen() 之前")
g = gen() # ⭐ 这一行不执行函数体,只造一个生成器对象
print("调用 gen() 之后,拿到:", type(g).__name__)
print("第一次 next:", next(g))
print("第二次 next:", next(g))
try:
next(g)
except StopIteration:
print("第三次 next: 抛 StopIteration")
对照
调用 gen() 之前
调用 gen() 之后,拿到: generator
[函数体开始跑了]
第一次 next: 1
[从暂停处恢复,继续往下]
第二次 next: 2
[函数体跑到底了]
第三次 next: 抛 StopIteration
⭐ 看那三行方括号打印的位置就懂了:[函数体开始跑了] 出现在「调用 gen() 之后」下面,也就是说它是被 next(g) 触发的,不是被 gen() 触发的。
| 你写的 | 实际发生 |
|---|---|
g = gen() |
造一个生成器对象。函数体零行执行 |
next(g) |
从头(或上次暂停处)跑,跑到下一个 yield 就停 |
| 跑到函数结尾 | 抛 StopIteration。for 循环就是靠捕获它来结束的 |
⚠️ return 和 yield 的差别是整个概念的分水岭:return 把栈帧销毁了,函数就此结束;yield 把栈帧冻起来,局部变量、执行到第几行,全都原样保留着等你回来。
🧩 二、yield 是暂停:状态被冻在生成器对象里
「冻起来」不是比喻,那个栈帧真的挂在生成器对象的 gi_frame 上,能直接查。
# yield 是暂停:局部变量和执行位置都被冻在生成器里
import inspect
def counter(start):
i = start
while i < start + 3:
yield i
i += 1 # ⭐ 这一行要等【下一次 next】才执行
g = counter(10)
print("刚造出来 :", inspect.getgeneratorstate(g))
print("第 1 次 next ->", next(g), "| 冻住的 i =", g.gi_frame.f_locals["i"],
"| 状态:", inspect.getgeneratorstate(g))
print("第 2 次 next ->", next(g), "| 冻住的 i =", g.gi_frame.f_locals["i"])
print("第 3 次 next ->", next(g), "| 冻住的 i =", g.gi_frame.f_locals["i"])
try:
next(g)
except StopIteration:
print("第 4 次 next -> StopIteration | 状态:", inspect.getgeneratorstate(g),
"| gi_frame:", g.gi_frame)
关键信息
⭐ 两处最能说明问题的:
i += 1的执行时机。第 1 次next之后i还是10—— 那一行还没跑。它要等你再要一个值的时候才执行。所以「生成器里的代码什么时候跑」的答案永远是:消费方要的时候。- 跑完之后
gi_frame变成None。栈帧被丢掉了,状态从GEN_SUSPENDED变成GEN_CLOSED。⚠️ 这就是下一节那个坑的机制——不是「数据被消费光了」,是执行位置永久地停在了终点。
⚠️ 三、只能走一次,而且第二次「不报错」
这是本章一号杀手,因为错误的写法既不报错也不警告,只是安静地给你一个空结果。
# 生成器只能遍历一次,而且第二次【不报错】
g = (i * i for i in range(5))
print("第一次 sum :", sum(g))
print("第二次 sum :", sum(g)) # 不报错,安静地给 0
print("再 list :", list(g), "| 长度", len(list(g)))
lst = [i * i for i in range(5)]
print("列表没这问题:", sum(lst), sum(lst))
# 生成器也没有 len,没有索引
try:
len(x for x in range(5))
except TypeError as e:
print("len(生成器) ->", type(e).__name__, ":", e)
操作步骤
💀 事故的完整形状:
| 项 | 内容 |
|---|---|
| 症状 | 「我算了两个统计量,第二个总是 0 / 空」。或者:日志里的样本数是对的,写进文件的却是 0 条 |
| 为什么没被发现 | ⚠️ sum 一个空迭代器合法地返回 0,list 合法地返回 [],max 才会报错。所以只有当你恰好用了 max/min 才有个 ValueError 提醒你 |
| 典型触发点 | 函数返回生成器 → 调用方先 len(list(rows)) 打个日志、再 for r in rows 处理。打日志那一步就把它耗光了 |
| 该补什么 | 需要遍历两次就当场固化:rows = list(rows)。要么就在函数签名/文档里写明返回的是一次性迭代器 |
⭐ 顺带记住:生成器没有 len()、不能索引、不能切片。想「取前 100 条」用 itertools.islice(第七节),不要 list(g)[:100] —— 那等于把整个流物化了,本章的意义当场归零。
🧯 四、这就是为什么值得:0.0 MB vs 77.4 MB
前面全在讲代价,这一节讲收益。同一个求和,两种写法的峰值内存:
# 生成器表达式 vs 列表推导:峰值内存差多少
import tracemalloc
N = 2_000_000
tracemalloc.start()
total = sum(i * i for i in range(N)) # ⭐ 生成器表达式:一次只活一个数
peak_gen = tracemalloc.get_traced_memory()[1]
tracemalloc.stop()
tracemalloc.start()
total2 = sum([i * i for i in range(N)]) # ⚠️ 列表推导:先把 200 万个数全造出来
peak_list = tracemalloc.get_traced_memory()[1]
tracemalloc.stop()
print(f"生成器表达式: 和={total} 峰值={peak_gen/1024/1024:.1f} MB")
print(f"列表推导 : 和={total2} 峰值={peak_list/1024/1024:.1f} MB")
算一算
生成器表达式: 和=2666664666667000000 峰值=0.0 MB
列表推导 : 和=2666664666667000000 峰值=77.4 MB
⭐ 两个结果一模一样,峰值差了 77 MB。 差别只有一对方括号 —— sum([...]) 里的那对。
⚠️ 别把这条误读成「生成器更快」。它省的是内存不是时间;论纯粹的 CPU 开销,列表推导往往还略占优(少了一层逐次恢复栈帧的成本)。它值钱的地方是让「数据量超过内存」这件事从「跑不了」变成「跑得完」,以及让第一个结果立刻可用,而不是等全部算完。
📋 判据(什么时候该用哪个):
| 情况 | 选 |
|---|---|
只遍历一次、只要个聚合结果(sum/max/any/写文件) |
⭐ 生成器表达式 |
| 数据量比内存大,或无穷流 | ⭐ 生成器(列表根本没得选) |
要遍历两次以上、要索引、要 len |
列表 |
| 结果很小(几百条),后面要反复用 | 列表 —— 别为了几 KB 去换一个只能走一次的东西 |
💀 五、惰性把异常也推迟了 —— 站里流式接口的那个坑
第一节说「调用不执行函数体」,它有一个很贵的推论:写在生成器函数开头的参数校验,在调用那一刻不会跑。
# 惰性的代价:函数体里的参数校验也被推迟了
def read_rows(path):
if not path.endswith(".csv"):
raise ValueError("只支持 csv") # ⚠️ 调用的那一刻【不会】执行
yield 1
r = read_rows("data.txt")
print("调用完了,什么都没发生:", type(r).__name__)
try:
next(r)
except ValueError as e:
print("直到第一次 next 才炸:", type(e).__name__, ":", e)
# 想让校验立刻发生:外面一层普通函数,里面一层生成器
def read_rows_ok(path):
if not path.endswith(".csv"):
raise ValueError("只支持 csv") # ⭐ 这是普通函数,调用即执行
def _gen():
yield 1
return _gen()
try:
read_rows_ok("data.txt")
except ValueError as e:
print("调用时就炸:", type(e).__name__, ":", e)
要点
调用完了,什么都没发生: generator
直到第一次 next 才炸: ValueError : 只支持 csv
调用时就炸: ValueError : 只支持 csv
⭐ 修法就是「拆成两层」:外层是普通函数负责立刻校验,内层生成器负责产数据。⚠️ 注意 read_rows_ok 里如果不拆、只是把校验写在同一个函数体里,加不加 try 都没用 —— 因为那个 raise 压根还没被执行到。
💀 这个推论在 Web 流式接口上会变成一个真事故:
| 步骤 | 发生了什么 |
|---|---|
| 1 | handler 里 return StreamingResponse(gen(...)) —— gen 的函数体一行都没跑 |
| 2 | 框架把响应交出去,HTTP 状态行 200 OK 和响应头已经发给客户端了 |
| 3 | 框架开始 next() 拉数据,这时才执行到函数体里的校验/上游调用 |
| 4 | ⚠️ 这时候抛异常,你已经没有 500 可以返回了 —— 状态码早发出去了 |
⭐ 站内 AI全栈 05 · 流式输出 的代码注释里写着「状态码早是 200 了,错误只能顺流走」,所以它的 except 分支是往流里 yield 一个 {"type": "error"} 帧。那个「早」指的就是本节这件事:不是框架偷跑,是生成器函数体本来就要等到第一次拉取才开始执行。
📋 由此得到一条规矩:能在开始流式之前做的校验(鉴权、参数、配额),一律放在返回 StreamingResponse 之前的普通代码里做。 一旦进了生成器,你就只剩「往流里写一个错误帧」这一条路。
🛑 读到这里可以停 —— 前半章讲完了(约 27 分钟)。 后半章还有:迭代器协议:
for背后就两个方法 ·itertools:islice / chain / groupby ·yield from:把子生成器接上去 · 大文件:readlines()和for line in f差多少 · 一张排查表 回来的时候不用重读,直接从下一节接着看就行。
🚦 六、迭代器协议:for 背后就两个方法
for x in obj 展开之后只有两步:调 obj.__iter__() 拿到一个迭代器,然后反复调它的 __next__(),直到 StopIteration。
⭐ 可迭代对象(iterable)和迭代器(iterator)是两个东西,混淆它们正是第三节那个坑的根源:
| 有什么方法 | 能走几次 | 例子 | |
|---|---|---|---|
| 可迭代对象 | __iter__ |
⭐ 每次 for 都能重新走 |
list、dict、str、range |
| 迭代器 | __iter__ + __next__ |
⚠️ 一次 | 生成器、enumerate(...)、zip(...)、打开的文件对象 |
# 迭代器协议:for 循环背后就这两个方法
class Countdown:
def __init__(self, n):
self.n = n
def __iter__(self):
return self # ⚠️ 返回 self = 这个对象【自己就是】迭代器,状态只有一份
def __next__(self):
if self.n <= 0:
raise StopIteration
self.n -= 1
return self.n + 1
c = Countdown(3)
print("第一次 for:", [x for x in c])
print("第二次 for:", [x for x in c]) # ⚠️ 空的,状态已经耗尽
class CountdownOK:
def __init__(self, n):
self.n = n
def __iter__(self):
n = self.n
while n > 0: # ⭐ 每次 __iter__ 都造一个【新的】生成器
yield n
n -= 1
c2 = CountdownOK(3)
print("可重复遍历:", [x for x in c2], [x for x in c2])
要点
第一次 for: [3, 2, 1]
第二次 for: []
可重复遍历: [3, 2, 1] [3, 2, 1]
⭐ __iter__ 里写 yield,方法本身就变成生成器函数 —— 每次被调用都返回一个全新的生成器,于是这个对象就能反复遍历。这是自定义容器的默认应该选的那个写法;return self 只在你真的想造一个一次性流的时候才对。
⚠️ 文件对象是迭代器,所以 for line in f 走完之后再 for line in f 什么也读不到(除非 f.seek(0))。这跟第三节是同一件事。
📋 七、itertools:islice / chain / groupby
生成器的切片、拼接、分组都不能用列表那套语法,标准库里有对应的工具。
# itertools 三件套:islice / chain / groupby
import itertools
def naturals(): # 一个无穷流
i = 0
while True:
yield i
i += 1
print("islice 前 5 个:", list(itertools.islice(naturals(), 5)))
print("islice 第 5–8 :", list(itertools.islice(naturals(), 5, 9)))
print("chain 接起来 :", list(itertools.chain([1, 2], (3, 4), "ab")))
# ⚠️ groupby 只合并【相邻】的相同键 —— 不排序就得到一堆碎片
rows = [("a", 1), ("b", 2), ("a", 3)]
print("没排序:", [(k, [r[1] for r in grp])
for k, grp in itertools.groupby(rows, key=lambda r: r[0])])
rows.sort(key=lambda r: r[0])
print("排序后:", [(k, [r[1] for r in grp])
for k, grp in itertools.groupby(rows, key=lambda r: r[0])])
# 💀 groupby 的第二个坑:分组本身也是惰性的,往前走一步前一组就没了
print("先 list 再看:", [(k, list(grp)) for k, grp in list(itertools.groupby("aabbc"))])
要点
islice 前 5 个: [0, 1, 2, 3, 4]
islice 第 5–8 : [5, 6, 7, 8]
chain 接起来 : [1, 2, 3, 4, 'a', 'b']
没排序: [('a', [1]), ('b', [2]), ('a', [3])]
排序后: [('a', [1, 3]), ('b', [2])]
先 list 再看: [('a', []), ('b', []), ('c', [])]
⚠️ groupby 有两个坑,第一个还算有名,第二个特别阴:
- 它只合并相邻的相同键。跟 SQL 的
GROUP BY不是一回事 —— 不先sort就会把同一个键切成好几段(上面'a'被切成[1]和[3])。⭐ 排序的key必须和groupby的key是同一个。 - 分组迭代器和外层共享同一个底层游标。
list(itertools.groupby("aabbc"))先把外层走到底,于是三个组全变成空的 —— 连最后一个'c'也是空的。⚠️ 它不报错,你会拿到[('a', []), ('b', []), ('c', [])]这种「键都对、内容全空」的结果。📋 规矩:在循环体里当场把grp消费掉(list(grp)/ 直接聚合),不要攒着。
⭐ islice 值得单记:它是唯一能对无穷流做切片的东西。naturals() 永不结束,list(...)[:5] 会挂死,islice(naturals(), 5) 拿完就走。⚠️ 但它不接受负数索引(不能 [-5:]),因为它根本不知道流有多长。
🧩 八、yield from:把子生成器接上去
递归或者委托给另一个生成器时,yield from 省掉一层 for。
# yield from:把子生成器的产出直接转出去
def leaves(node):
if isinstance(node, list):
for child in node:
yield from leaves(child) # ⭐ 一层 for 就够了
else:
yield node
tree = [1, [2, [3, 4], 5], [[6]]]
print("yield from :", list(leaves(tree)))
def leaves_manual(node):
if isinstance(node, list):
for child in node:
for x in leaves_manual(child): # 手写要两层 for
yield x
else:
yield node
print("手写等价物 :", list(leaves_manual(tree)))
# ⚠️ yield from 和 for-yield 不等价的地方:它还转发【返回值】
def inner():
yield 1
yield 2
return "inner 的返回值"
def outer():
got = yield from inner() # ⭐ StopIteration.value 落到 got 上
print(" outer 收到:", got)
print("跑一遍 :", list(outer()))
对照
yield from : [1, 2, 3, 4, 5, 6]
手写等价物 : [1, 2, 3, 4, 5, 6]
outer 收到: inner 的返回值
跑一遍 : [1, 2]
⭐ 最后三行是重点:生成器里的 return "..." 不会出现在产出的序列里(list(outer()) 是 [1, 2],没有那个字符串),它被塞进 StopIteration.value,而 yield from 会把它取出来交给左边的变量。手写的两层 for 拿不到这个值。
⚠️ 所以「生成器里能不能 return」的答案是:能,但它是结束信号 + 一个带回的值,不是产出。想让它出现在结果里必须 yield。
🧯 九、大文件:readlines() 和 for line in f 差多少
第四节那个例子是合成的,这一节是真实场景里最常见的那一个。
# 读大文件:readlines() 把整个文件塞进内存,for line in f 不会
import os
import tracemalloc
PATH = "big_demo.txt"
with open(PATH, "w", encoding="utf-8") as f:
for i in range(500_000):
f.write(f"line {i} " + "x" * 40 + "\n")
print("文件大小:", round(os.path.getsize(PATH) / 1024 / 1024, 1), "MB")
tracemalloc.start()
n = 0
with open(PATH, encoding="utf-8") as f:
for line in f: # ⭐ 文件对象本身就是迭代器,一次一行
n += len(line)
peak_stream = tracemalloc.get_traced_memory()[1]
tracemalloc.stop()
tracemalloc.start()
with open(PATH, encoding="utf-8") as f:
m = sum(len(line) for line in f.readlines()) # ⚠️ 先造一个 50 万元素的列表
peak_all = tracemalloc.get_traced_memory()[1]
tracemalloc.stop()
print(f"for line in f: 字符数={n} 峰值={peak_stream/1024/1024:.1f} MB")
print(f"readlines() : 字符数={m} 峰值={peak_all/1024/1024:.1f} MB")
os.remove(PATH)
算一算
文件大小: 25.6 MB
for line in f: 字符数=26388890 峰值=0.0 MB
readlines() : 字符数=26388890 峰值=48.7 MB
⚠️ 注意第二种写法的伪装性:sum(len(line) for line in f.readlines()) 外面明明是个生成器表达式,看起来很惰性 —— 但 f.readlines() 在生成器开跑之前就已经把 50 万行全读进列表了。惰性只能从最里面那层开始,最内层一旦物化,外面套多少层生成器都白搭。
💀 这是「生成器用法看着对、其实没生效」的头号形态。同族的还有 for x in sorted(gen)、for x in list(gen)、json.load(f) —— 它们都必须先拿到全部数据才能出第一个结果。
📋 一条判据:看最里层。 如果最内侧那个调用会返回一个 list/dict/完整字符串,那么外面所有的生成器语法都只是装饰。
📋 十、一张排查表
| 现象 | 大概率是哪一节 | 一行验证 |
|---|---|---|
| 第二个统计量总是 0 / 空列表,不报错 | ⭐ 三、只能走一次 | 在第一次遍历前 rows = list(rows) |
TypeError: object of type 'generator' has no len() |
三、生成器没有 len |
想计数就 sum(1 for _ in g),但那也会耗光它 |
| 参数明明不合法,调用却没抛异常 | ⭐ 五、校验被推迟 | 拆两层:普通函数校验 + 内层生成器产数据 |
| 流式接口出错,返回的是 200 不是 500 | 五、状态码早发出去了 | 把校验挪到返回响应对象之前 |
| 分组结果「键都对、内容全空」 | ⭐ 七、groupby 的惰性游标 |
别 list(groupby(...)),在循环体里当场消费 |
| 分组把同一个键切成好几段 | 七、groupby 只合并相邻 |
先按同一个 key 排序 |
自定义类第二次 for 就空了 |
六、__iter__ 写了 return self |
改成在 __iter__ 里 yield |
| 用了生成器但内存没降 | ⭐ 九、最里层已经物化 | 检查有没有 readlines()/list()/sorted() |
🔗 这一章连到哪里
| 相关的地方 | 为什么 |
|---|---|
| 01 · 名字、对象和绑定 | 上一章是「以为在复制、其实在共享」,这一章是「以为拿到数据、其实拿到一次性动作」。两章的共同点是:错误写法都不报错 |
| 03 · 装饰器与上下文管理器 | ⭐ contextlib.contextmanager 就是拿生成器实现的 —— 它靠 yield 把函数劈成「进入前」和「退出后」两半。不懂暂停/恢复就只能把它当咒语抄 |
| 05 · 怎么量:计时、剖析、内存 | 本章两次用 tracemalloc 量峰值内存,那一章讲它和 time.perf_counter、cProfile 各自该用在哪 |
| AI全栈 05 · 流式输出 | ⭐ 第五节那个「状态码早是 200 了」的出处。读那一章之前先读本章第一、五节,否则你只会照抄它的 except 分支而不知道为什么必须那么写 |
| AI全栈 11 · 长任务与队列 | 生成器的暂停/恢复只在一个进程内成立。任务要跨进程、跨重启地续上,就得换成队列那套机制 |
| 智能体工程教程 16c · 接进真实产品 | 它的流式那一节直接在用 yield,把它当读者已知的概念。⭐ 本章补的就是「已知」那部分 |
| AI基础设施 22 · 数据管线与存储 | DataLoader 之所以能「边训边读」,底层就是本章的惰性思路。那一章讲的是参数怎么调,本章讲的是它为什么能成立 |
| 数据这一关 04 · 脏数据的十种形态 | ⚠️ 脏数据检查最容易踩第三节的坑:先遍历一遍统计缺失率、再遍历一遍清洗 —— 第二遍是空的,而缺失率报告看起来完全正常 |
✅ 检查点
g = gen()这一行执行了函数体里的多少代码?怎么用一个print证明?yield和return在「栈帧」这件事上的区别是什么?生成器跑完之后gi_frame变成什么?- 第二节那个
counter里,第 1 次next返回 10 之后,局部变量i是多少?为什么? - 为什么「生成器只能走一次」比一个会报错的 bug 更贵?
sum、list、max三个函数里,哪个会在空迭代器上报错? - 第四节实测的两个峰值内存分别是多少?这个差别省的是内存还是时间?
- 一个生成器函数开头写了
raise ValueError(...),调用它的那一行会抛异常吗?正确的修法是什么? - 为什么流式接口里出错时「已经没有 500 可以返回了」?由此该把鉴权和参数校验放在哪?
- 「可迭代对象」和「迭代器」差在哪个方法上?文件对象属于哪一类?
itertools.groupby的两个坑分别是什么?list(itertools.groupby("aabbc"))的结果为什么连最后一组也是空的?sum(len(line) for line in f.readlines())是惰性的吗?判断「生成器有没有真的生效」的那条判据是什么?
👀 答案
- 零行。
gen()只造一个生成器对象。证据是把print(" [函数体开始跑了]")放在函数体第一行 —— 它出现在「调用 gen() 之后」下面,是被第一次next(g)触发的。 return销毁栈帧,函数就此结束;yield冻住栈帧,局部变量和执行位置原样保留。跑完之后gi_frame变成None,状态从GEN_SUSPENDED变成GEN_CLOSED。i还是 10。因为i += 1写在yield i后面,那一行要等下一次next才执行。实跑用g.gi_frame.f_locals["i"]查出来就是 10。- 因为它不报错:
sum在空迭代器上合法地返回0,list合法地返回[],只有max(和min) 会抛ValueError。所以典型场景是「先len(list(rows))打个日志、再for r in rows处理」—— 打日志那步就耗光了,而日志里的数字还是对的。 - 生成器表达式 0.0 MB,列表推导 77.4 MB,两个求和结果一模一样。省的是内存,不是时间 —— 论纯 CPU 列表推导往往还略快。
- 不会抛。 函数体一行都没跑,异常要等第一次
next才出现。修法是拆成两层:外层普通函数负责立刻校验,内层生成器负责产数据(第五节的read_rows_ok)。 - 因为框架拿到
StreamingResponse就把200 OK和响应头发出去了,之后才开始next()拉数据;这时抛异常,状态码已经在路上了。所以鉴权、参数、配额一律放在返回响应对象之前的普通代码里;进了生成器就只剩「往流里写一个错误帧」。 - 可迭代对象只有
__iter__,迭代器还有__next__(且它的__iter__返回自己)。文件对象是迭代器,所以for line in f走完再走一遍什么也读不到,除非f.seek(0)。 - ① 它只合并相邻的相同键,不先按同一个
key排序就会把'a'切成[1]和[3]两段;② 分组迭代器和外层共享同一个底层游标。list(...)先把外层走到底,于是三个组全空 —— 包括最后的'c',输出是[('a', []), ('b', []), ('c', [])]。 - 不是。
f.readlines()在生成器开跑之前就把 50 万行读进了列表,实测峰值 48.7 MB,而for line in f是 0.0 MB。判据是「看最里层」:最内侧调用只要返回完整的list/dict/字符串,外面套多少层生成器语法都只是装饰。
🛑 可以停在这里
⚡ 走神救援
⭐ 生成器不是数据,是一个被暂停在半路的函数。 只要函数体里有
yield,调用它就一行代码都不执行。yield和return的分水岭在栈帧:return销毁它,yield冻住它,局部变量和执行位置原样等你回来。⚠️ 一号杀手是「只能走一次,而且第二次不报错」:同一个生成器第一次求和有值,第二次是 0,再
list是空的。⭐sum和list在空迭代器上都是合法返回——所以「先len(list(rows))打日志、再for r in rows处理」这种写法会静默丢数据,而日志里的数字还是对的。需要走两次就当场rows = list(rows)。收益那一面:把方括号去掉,峰值内存从几十 MB 降到几乎为零,结果完全一样——⚠️ 它省的是内存不是时间。
💀 惰性还把异常推迟了:生成器函数开头的
raise在调用那一刻不会执行,要等第一次next。⭐ 这条推论在流式接口上变成真事故:返回流式响应时函数体还没跑,框架已经把200 OK发出去了,之后拉数据才炸——你没有 500 可以返回了。所以鉴权和参数校验必须放在返回响应对象之前。修法是拆两层:外层普通函数立刻校验,内层生成器产数据。
⚠️ 协议层面:文件对象是迭代器,所以
for line in f走完再走一遍是空的;自定义类的__iter__写return self只能走一次,写yield就每次都造新的。itertools.groupby只合并相邻的相同键——不排序会把同一个键切成好几段。
下一节 👉 03-装饰器与上下文管理器.md