🏠 总目录📚 本教程 02 · 生成器与惰性求值 ← →
📑 本页目录(点开跳转)

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)

关键信息

刚造出来 : GEN_CREATED
第 1 次 next -> 10 | 冻住的 i = 10 | 状态: GEN_SUSPENDED
第 2 次 next -> 11 | 冻住的 i = 11
第 3 次 next -> 12 | 冻住的 i = 12
第 4 次 next -> StopIteration | 状态: GEN_CLOSED | gi_frame: None

⭐ 两处最能说明问题的:

  1. i += 1 的执行时机。第 1 次 next 之后 i 还是 10 —— 那一行还没跑。它要等你再要一个值的时候才执行。所以「生成器里的代码什么时候跑」的答案永远是:消费方要的时候。
  2. 跑完之后 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)

操作步骤

第一次 sum : 30
第二次 sum : 0
再 list : [] | 长度 0
列表没这问题: 30 30
len(生成器) -> TypeError : object of type 'generator' has no len()

💀 事故的完整形状:

项 内容
症状 「我算了两个统计量,第二个总是 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 有两个坑,第一个还算有名,第二个特别阴:

  1. 它只合并相邻的相同键。跟 SQL 的 GROUP BY 不是一回事 —— 不先 sort 就会把同一个键切成好几段(上面 'a' 被切成 [1] 和 [3])。⭐ 排序的 key 必须和 groupby 的 key 是同一个。
  2. 分组迭代器和外层共享同一个底层游标。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 · 脏数据的十种形态 ⚠️ 脏数据检查最容易踩第三节的坑:先遍历一遍统计缺失率、再遍历一遍清洗 —— 第二遍是空的,而缺失率报告看起来完全正常

✅ 检查点

  1. g = gen() 这一行执行了函数体里的多少代码?怎么用一个 print 证明?
  2. yield 和 return 在「栈帧」这件事上的区别是什么?生成器跑完之后 gi_frame 变成什么?
  3. 第二节那个 counter 里,第 1 次 next 返回 10 之后,局部变量 i 是多少?为什么?
  4. 为什么「生成器只能走一次」比一个会报错的 bug 更贵?sum、list、max 三个函数里,哪个会在空迭代器上报错?
  5. 第四节实测的两个峰值内存分别是多少?这个差别省的是内存还是时间?
  6. 一个生成器函数开头写了 raise ValueError(...),调用它的那一行会抛异常吗?正确的修法是什么?
  7. 为什么流式接口里出错时「已经没有 500 可以返回了」?由此该把鉴权和参数校验放在哪?
  8. 「可迭代对象」和「迭代器」差在哪个方法上?文件对象属于哪一类?
  9. itertools.groupby 的两个坑分别是什么?list(itertools.groupby("aabbc")) 的结果为什么连最后一组也是空的?
  10. sum(len(line) for line in f.readlines()) 是惰性的吗?判断「生成器有没有真的生效」的那条判据是什么?
👀 答案
  1. 零行。 gen() 只造一个生成器对象。证据是把 print(" [函数体开始跑了]") 放在函数体第一行 —— 它出现在「调用 gen() 之后」下面,是被第一次 next(g) 触发的。
  2. return 销毁栈帧,函数就此结束;yield 冻住栈帧,局部变量和执行位置原样保留。跑完之后 gi_frame 变成 None,状态从 GEN_SUSPENDED 变成 GEN_CLOSED。
  3. i 还是 10。因为 i += 1 写在 yield i 后面,那一行要等下一次 next 才执行。实跑用 g.gi_frame.f_locals["i"] 查出来就是 10。
  4. 因为它不报错:sum 在空迭代器上合法地返回 0,list 合法地返回 [],只有 max(和 min) 会抛 ValueError。所以典型场景是「先 len(list(rows)) 打个日志、再 for r in rows 处理」—— 打日志那步就耗光了,而日志里的数字还是对的。
  5. 生成器表达式 0.0 MB,列表推导 77.4 MB,两个求和结果一模一样。省的是内存,不是时间 —— 论纯 CPU 列表推导往往还略快。
  6. 不会抛。 函数体一行都没跑,异常要等第一次 next 才出现。修法是拆成两层:外层普通函数负责立刻校验,内层生成器负责产数据(第五节的 read_rows_ok)。
  7. 因为框架拿到 StreamingResponse 就把 200 OK 和响应头发出去了,之后才开始 next() 拉数据;这时抛异常,状态码已经在路上了。所以鉴权、参数、配额一律放在返回响应对象之前的普通代码里;进了生成器就只剩「往流里写一个错误帧」。
  8. 可迭代对象只有 __iter__,迭代器还有 __next__(且它的 __iter__ 返回自己)。文件对象是迭代器,所以 for line in f 走完再走一遍什么也读不到,除非 f.seek(0)。
  9. ① 它只合并相邻的相同键,不先按同一个 key 排序就会把 'a' 切成 [1] 和 [3] 两段;② 分组迭代器和外层共享同一个底层游标。list(...) 先把外层走到底,于是三个组全空 —— 包括最后的 'c',输出是 [('a', []), ('b', []), ('c', [])]。
  10. 不是。 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

打卡记录保存在你的浏览器里,首页能看到总进度