迭代协议与生成器
for 循环你从第 02 章用到现在,早就成了肌肉记忆。但你有没有追问过一句:for 凭什么知道"下一个值"是谁?凭什么列表、字符串、字典、文件都能往 for 里塞,一个数字却会被当场拒绝?这一章我们就把 for 的机箱…
for 循环你从第 02 章用到现在,早就成了肌肉记忆。但你有没有追问过一句:for 凭什么知道"下一个值"是谁?凭什么列表、字符串、字典、文件都能往 for 里塞,一个数字却会被当场拒绝?这一章我们就把 for 的机箱拆开——里面是一套叫迭代协议的约定。吃透它,你不仅能造出自己"可被 for 的对象",还能解锁 Python 里最省内存的取值姿势:生成器。
1. 可迭代对象:for 眼里的合格供应商
先立规矩:
一个对象只要实现了
__iter__()方法,并且这个方法返回一个迭代器,它就是可迭代对象(Iterable)。
你叫得出名字的容器——list、tuple、str、dict、set——全都签了这份合约。
动手验证一下,iter() 这个内置函数能从可迭代对象身上"领"到一个迭代器:
cart = ["键盘", "鼠标", "显示器"]
# iter() 等价于 cart.__iter__(),拿到一个迭代器
it = iter(cart)
print(type(it)) # <class 'list_iterator'>
# next() 等价于 it.__next__(),逐个把值"吐"出来
print(next(it)) # 键盘
print(next(it)) # 鼠标
print(next(it)) # 显示器
# print(next(it)) # 再要就没了:抛 StopIteration
顺手记住一条推理:迭代器自己也实现了 __iter__(约定俗成返回自身),所以迭代器必然是可迭代对象;但反过来,可迭代对象不一定是迭代器。
2. 迭代器:真正吐值的角色
把上面的推理摊开,就是迭代器的完整定义——同时实现了 __iter__ 和 __next__ 两个方法的对象。骨架长这样:
class ValueEmitter:
"""迭代器骨架:两个方法缺一不可"""
def __iter__(self):
"""
要求:返回一个迭代器。
绝大多数情况下,把自己 return 出去就是标准答案
"""
return self
def __next__(self):
"""交出下一个值;实在没了就抛 StopIteration"""
pass
obj = ValueEmitter() # obj 就是一个迭代器
iter() 和 next() 这两个内置函数,不过是这两个魔术方法(第 08 章的老朋友)的马甲而已。
应用:造一条停不下来的序列
容器得先把所有值装进内存才敢开工,迭代器没有这个包袱——它算一步、给一步。比如培养皿里的细胞,每观察一次数量翻一倍:
class CellCulture:
"""细胞分裂迭代器:数量无限翻倍,没有尽头"""
def __init__(self):
self.count = 1
def __iter__(self):
return self
def __next__(self):
observed = self.count
self.count *= 2
return observed
dish = CellCulture()
print(next(dish)) # 1
print(next(dish)) # 2
print(next(dish)) # 4
print(next(dish)) # 8
print(next(dish)) # 16
这条序列没有终点,但谁需要谁取——"无限"在惰性的世界里根本不值钱。
3. 各司其职:可迭代对象配一个专职迭代器
第 1 节那条协议说 __iter__ 要返回迭代器,最讲究的写法是拆成两个类:可迭代对象管"能被遍历"这件事,迭代器管"遍历到哪儿了"这件事。看一部从 6 层往 1 层降的电梯:
class ElevatorDescent:
"""可迭代对象:电梯从 top 层一路降到 1 层"""
def __init__(self, top):
self.top = top
def __iter__(self):
# 每次调用都发一个崭新的迭代器
return FloorStepper(self.top)
class FloorStepper:
"""专职迭代器:记住电梯当前停在哪层"""
def __init__(self, top):
self.floor = top
def __iter__(self):
return self
def __next__(self):
if self.floor < 1:
raise StopIteration # 1 层以下没楼层可给了
current = self.floor
self.floor -= 1
return current
lift = ElevatorDescent(6)
step = iter(lift)
print(next(step)) # 6
print(next(step)) # 5
print(next(step)) # 4
print(next(step)) # 3
print(next(step)) # 2
print(next(step)) # 1
# print(next(step)) # StopIteration!
为什么非要拆开?玄机在 ElevatorDescent.__iter__ 每次都 new 一个新的 FloorStepper:进度状态存在迭代器身上,不污染可迭代对象本身,所以同一个对象能一遍又一遍地从头遍历。列表能 for 两遍,靠的就是这个机制。
4. 隐形的消费者:谁在替你调 iter 和 next
除了 for,还有一大票语法和内置函数在背后偷偷走"领迭代器 → 反复取值"的流程。
4.1 for 循环
# for 的内部剧本:先 iter() 领迭代器,再不停 next(),直到收到 StopIteration
for f in ElevatorDescent(3):
print(f) # 3 2 1
4.2 list()、tuple()、set() 构造函数
print(list(ElevatorDescent(3))) # [3, 2, 1]
print(tuple(ElevatorDescent(3))) # (3, 2, 1)
print(set(ElevatorDescent(3))) # {1, 2, 3}
4.3 * 解包
top, *below = ElevatorDescent(3)
print(top) # 3
print(below) # [2, 1]
floors = [*ElevatorDescent(3)]
print(floors) # [3, 2, 1]
4.4 in 成员判断
print(6 in ElevatorDescent(3)) # False
print(2 in ElevatorDescent(3)) # True
4.5 sum()、max()、min()
print(sum(ElevatorDescent(3))) # 6
print(max(ElevatorDescent(3))) # 3
print(min(ElevatorDescent(3))) # 1
4.6 zip()、map()、filter()
# zip:把多个可迭代对象按位置配成对
goods = ["口罩", "酒精", "创可贴"]
prices = [12, 35, 8]
for good, price in zip(goods, prices):
print(f"{good}: {price} 元")
# 口罩: 12 元
# 酒精: 35 元
# 创可贴: 8 元
# map:对每个值做一次转换
doubled = map(lambda n: n * 2, ElevatorDescent(3))
print(list(doubled)) # [6, 4, 2]
# filter:按条件筛掉不要的
odds = filter(lambda n: n % 2 == 1, ElevatorDescent(3))
print(list(odds)) # [3, 1]
4.7 any()、all()
floors = ElevatorDescent(3)
print(any(floors)) # True(只要有一个值为真,any 就是 True)
print(all(floors)) # True(3、2、1 全是真值)
print(all([3, 2, 1, 0])) # False(混进一个 0,all 立刻翻脸)
5. range:天天用,未必真懂
range() 大概是你最常打交道的可迭代对象,专门负责生产整数序列。三种参数形态:
# range(stop):从 0 数到 stop-1
for i in range(4):
print(i, end=" ") # 0 1 2 3
print()
# range(start, stop):从 start 数到 stop-1
for i in range(3, 7):
print(i, end=" ") # 3 4 5 6
print()
# range(start, stop, step):按步长跳着数
for i in range(1, 10, 3):
print(i, end=" ") # 1 4 7
print()
# 步长为负:倒着数
for i in range(4, 0, -1):
print(i, end=" ") # 4 3 2 1
三条重要特性,逐条说清:
特性一:惰性求值。 range 从不预先生成一长串数字,用到哪个才算哪个。而且和列表不同,它支持索引、切片和长度查询:
r = range(0, 100, 2)
print(len(r)) # 50
print(r[4]) # 8
print(r[2:6]) # range(4, 12, 2)
print(8 in r) # True
print(9 in r) # False
特性二:不是迭代器。 range 是可迭代对象,但不亲自当迭代器——每次 iter() 都发新的,于是可以反复遍历:
r = range(3)
for i in r:
print(i, end=" ") # 0 1 2
print()
for i in r:
print(i, end=" ") # 0 1 2(再来一遍毫无压力)
特性三:内存恒定。 range 内部只保存 start、stop、step 三个数,区间再大也只占这点地方——range(10**10) 随手就建,换成列表试试?
6. 推导式:一行代码顶一小段循环
推导式(Comprehension)是一种紧凑语法:从已有的可迭代对象出发,一步到位造出新容器。
6.1 列表推导式
# 基本款:[表达式 for 变量 in 可迭代对象]
triples = [n * 3 for n in range(5)]
print(triples) # [0, 3, 6, 9, 12]
# 带筛选:[表达式 for 变量 in 可迭代对象 if 条件]
multiples_of_5 = [n for n in range(30) if n % 5 == 0]
print(multiples_of_5) # [0, 5, 10, 15, 20, 25]
# if-else 放在表达式前部(注意:这种写法没有"尾巴上的 if")
scores = [92, 58, 77, 61]
verdicts = ["通过" if s >= 60 else "不通过" for s in scores]
print(verdicts) # ['通过', '不通过', '通过', '通过']
6.2 字典推导式
# 基本款:{键表达式: 值表达式 for 变量 in 可迭代对象}
cube_map = {n: n ** 3 for n in range(5)}
print(cube_map) # {0: 0, 1: 1, 2: 8, 3: 27, 4: 64}
# 带筛选:只保留 3 的倍数
by_three = {n: n ** 3 for n in range(12) if n % 3 == 0}
print(by_three) # {0: 0, 3: 27, 6: 216, 9: 729}
6.3 集合推导式
# 基本款:{表达式 for 变量 in 可迭代对象}(花括号里没有冒号)
multiples_of_4 = {n for n in range(20) if n % 4 == 0}
print(multiples_of_4) # {0, 4, 8, 12, 16}
# 集合自动去重:四个单词只留下三个首字母
words = ["apple", "avocado", "banana", "cherry"]
initials = {w[0] for w in words}
print(initials) # {'a', 'b', 'c'}
6.4 嵌套推导式
# 把二维名单拍扁成一维
roster = [["张三", "李四"], ["欧阳锋"], ["王五", "赵六"]]
names = [name for group in roster for name in group]
print(names) # ['张三', '李四', '欧阳锋', '王五', '赵六']
# 等价于:
# names = []
# for group in roster:
# for name in group:
# names.append(name)
# 嵌套 + 筛选:只留两个字的名字
short = [name for group in roster for name in group if len(name) == 2]
print(short) # ['张三', '李四', '王五', '赵六']
读嵌套推导式有个窍门:从左往右读,for 出现的顺序就等于普通循环从外到内的顺序。
6.5 没有元组推导式这回事
Python 里不存在元组推导式。你用圆括号写出来的那个东西,是下一节的主角——生成器表达式:
result = (n * 2 for n in range(5))
print(type(result)) # <class 'generator'>,不是 tuple
6.6 推导式与循环怎么选
# 推导式写法(更 Pythonic,通常也更快)
cubes = [n ** 3 for n in range(8)]
# 等价的循环写法
cubes = []
for n in range(8):
cubes.append(n ** 3)
推导式短小精悍,解释器对它还有专门优化。但别走火入魔:一旦逻辑复杂起来(多层嵌套加多重条件),硬压成一行只会变成没人敢碰的天书——那种时候,老老实实写循环才是对读者负责。
7. 生成器函数:会踩刹车的函数
普通函数一路狂奔,撞到 return 交出结果就散场。但只要函数体内出现一个 yield 关键字,这个函数的性质就彻底变了——调用它不执行任何代码,只生产一个生成器对象;而生成器本身就是一个迭代器。
# 函数体里有 yield,Python 就把它登记为生成器函数
def game_levels():
print("载入草原关卡")
yield "草原"
print("载入沙漠关卡")
yield "沙漠"
print("载入火山关卡")
yield "火山"
quest = game_levels() # 注意:此刻函数体一行都没跑,只拿到了生成器
print(type(quest)) # <class 'generator'>
print(next(quest)) # 载入草原关卡(开始执行,到第一个 yield 踩下刹车)
# 草原
print(next(quest)) # 载入沙漠关卡(从上次的刹车点无缝续跑)
# 沙漠
print(next(quest)) # 载入火山关卡
# 火山
# print(next(quest)) # 函数体跑完,抛 StopIteration
生成器是编写迭代器的捷径。第 3 节那个电梯,两个类几十行,换成生成器只要几行:
def descend(top):
"""生成器版本:从 top 层降到 1 层"""
while top >= 1:
yield top
top -= 1
lift = descend(5)
print(type(lift)) # <class 'generator'>
for f in lift:
print(f, end=" ") # 5 4 3 2 1
生成器有三个鲜明特点:
- 惰性求值:值按需生产,用一个算一个,内存开销可以忽略
- 现场封存:每次 yield 处暂停,局部变量和执行位置原样冻结,下次调用继续接着演
- 一遍生命:跟迭代器一样,取完即止,没法倒带重放
8. yield from:把产量外包出去
生成器内部可以调用别的生成器,把人家的产出逐个转发:
def opening_ceremony():
yield "运动员入场"
yield "点燃火炬"
def sports_day():
yield "运动会开始"
for moment in opening_ceremony():
yield moment
yield "仪式结束,比赛开始"
for moment in sports_day():
print(moment)
# 运动会开始
# 运动员入场
# 点燃火炬
# 仪式结束,比赛开始
中间那个 for 纯属搬运工体力活,Python 给了个语法糖 yield from,一步完成"委托":
def sports_day():
yield "运动会开始"
yield from opening_ceremony() # 中间环节整体转交子生成器
yield "仪式结束,比赛开始"
9. send:不仅会吐,还会收
yield 不只是出料口,同时也是进料口。调用生成器的 send(值) 方法,这个值会成为 yield 表达式的运算结果,被函数体接住:
def checkout():
"""收银台:不停接收商品价格,随时回报累计账单"""
bill = 0
while True:
price = yield bill # 吐出当前账单,同时等待下一件商品的价格
if price is None:
break
bill += price
counter = checkout()
print(next(counter)) # 0(先点火一次,让生成器走到 yield 处待命)
print(counter.send(15)) # 15(收到 15 元,累计后吐出新账单)
print(counter.send(42)) # 57
print(counter.send(8)) # 65
有个细节必须记住:第一次不能直接 send 具体值——生成器还没跑到 yield 那一行,送进去的值没人接,会直接报错。先 next()(或 send(None))把它点火。
10. 生成器表达式:换一对括号,立刻变懒
把列表推导式的方括号换成圆括号,得到的不是元组,而是生成器表达式——一次性把"算"和"存"分开,只算不存:
# 惰性版本:内存里只有一个生成器,两百万个值一个都没提前算
lazy_squares = (n * n for n in range(2_000_000))
print(type(lazy_squares)) # <class 'generator'>
# 要一个,算一个:
print(next(lazy_squares)) # 0
print(next(lazy_squares)) # 1
print(next(lazy_squares)) # 4
11. itertools:标准库自带的迭代工具箱
itertools 模块收录了一大批现成的高效迭代器,认识几个最常用的:
import itertools
# count(起点, 步长):从起点出发,无限往下数
seat_no = itertools.count(7, 3)
print(next(seat_no)) # 7
print(next(seat_no)) # 10
print(next(seat_no)) # 13
# cycle(可迭代对象):循环播放,永不停止
light = itertools.cycle(["红灯", "绿灯", "黄灯"])
print(next(light)) # 红灯
print(next(light)) # 绿灯
print(next(light)) # 黄灯
print(next(light)) # 红灯(又转回来了)
# repeat(值, 次数):把同一个值连给 N 遍
padding = list(itertools.repeat("待定", 3))
print(padding) # ['待定', '待定', '待定']
# chain(*可迭代对象):几段序列首尾相接
merged = list(itertools.chain([7, 8], [9, 10], [11]))
print(merged) # [7, 8, 9, 10, 11]
# islice(可迭代对象, 起点, 结束, 步长):给无限序列"切片"
head = list(itertools.islice(itertools.count(), 6))
print(head) # [0, 1, 2, 3, 4, 5]
# permutations(可迭代对象, r):排列(讲究先后顺序)
podium = list(itertools.permutations(["甲", "乙", "丙"], 2))
print(podium)
# [('甲', '乙'), ('甲', '丙'), ('乙', '甲'), ('乙', '丙'), ('丙', '甲'), ('丙', '乙')]
# combinations(可迭代对象, r):组合(不分先后)
matchups = list(itertools.combinations(["A", "B", "C", "D"], 2))
print(matchups)
# [('A', 'B'), ('A', 'C'), ('A', 'D'), ('B', 'C'), ('B', 'D'), ('C', 'D')]
12. 作业
动笔之前,先用费曼学习法把三样东西讲给自己听:迭代器、可迭代对象、生成器各是什么,彼此什么关系。讲卡壳的地方,就是需要回头补的地方。
一、多层储物清单摊平
编写生成器函数 flatten_boxes,把任意深度嵌套的列表摊平成一层:
def flatten_boxes(boxes):
# 你的代码
pass
storage = ["拖把", ["抹布", ["扫帚", "垃圾袋"], "水桶"], "梯子", ["灯泡", "灯管"]]
print(list(flatten_boxes(storage)))
# ['拖把', '抹布', '扫帚', '垃圾袋', '水桶', '梯子', '灯泡', '灯管']
二、模拟分批拉取记录
编写一个生成器,模拟从接口分批拉取数据:
def fetch_batches(total, batch_size):
"""
模拟分批拉取
total: 记录总数
batch_size: 每批条数
每轮 yield 一批记录(列表),记录编号从 1 开始
"""
# 你的代码
pass
for batch in fetch_batches(25, 10):
print(batch)
# [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
# [11, 12, 13, 14, 15, 16, 17, 18, 19, 20]
# [21, 22, 23, 24, 25]
三、思考题
下面代码的输出是什么?为什么?
def tea_pot():
print("第一泡,出汤")
yield 1
print("第二泡,出汤")
yield 2
print("第三泡,出汤")
yield 3
print("茶叶已泡乏")
pot = tea_pot()
print("茶具已备好")
print(next(pot))
print("---")
print(next(pot))
print("---")
pot.close()
print("茶具已收拾")
print(next(pot))
下一章:12 · with 与上下文协议