KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
07 · 迭代器协议与上下文管理器:for 和 with 的底层 — keel 龙骨
## 这一章解决什么问题
这一章解决什么问题
for 和 with 天天用,但「for 循环底层发生了什么」「with 到底保证什么」多数人答不上来。这一章讲两个协议——迭代器协议和上下文管理器协议。前者是 yield 课的直接前置:不先懂 __iter__/__next__,yield 的所有行为都像魔法;懂了它,yield 只是把协议手工实现换成语法糖。
for 循环的底层翻译
先看现象:任何能被 for 遍历的对象,都不是「天生能 for」,而是实现了协议。Python 把
for item in collection:
print(item)
翻译成:
it = iter(collection) # 1. 拿迭代器:调 collection.__iter__()
while True:
try:
item = next(it) # 2. 取下一个:调 it.__next__()
except StopIteration: # 3. 取到头会抛 StopIteration
break # for 把这个异常当成"正常结束"信号,静默 break
print(item)
三个关键角色:
- 可迭代对象(Iterable):实现了
__iter__,能被 iter() 调用。list、dict、str、文件都是。它可能被遍历多次——每次iter()返回一个新迭代器。 - 迭代器(Iterator):实现了
__iter__(返回自己)和__next__。它是一次性的游标:取完就耗尽,再 next 只有 StopIteration。 - StopIteration:不是错误,是协议信号——「没有下一个了」。for 捕获它当结束指令。
用 list 验证「可迭代 ≠ 迭代器」:
>>> nums = [1, 2, 3]
>>> it = iter(nums)
>>> next(it) # 1
>>> next(it) # 2
>>> for x in nums: pass # nums 完好,还能再遍历——它是可迭代对象
>>> next(it) # 3
>>> next(it) # StopIteration —— it 这个游标已经耗尽
list 可以无限次 for;迭代器只能消费一次。 这是「迭代器 vs 序列」的分界,也是后续所有「为什么我的数据第二次遍历就空了」问题的答案。
手写一个迭代器:看清协议全貌
不依赖任何语法糖,手写一个倒计时迭代器:
class Countdown:
"""可迭代对象:每次 iter() 返回一个新游标"""
def __init__(self, start):
self.start = start
def __iter__(self):
return CountdownIterator(self.start) # 新游标,可重复遍历
class CountdownIterator:
"""迭代器:一次性游标"""
def __init__(self, current):
self.current = current
def __iter__(self):
return self # 迭代器返回自己
def __next__(self):
if self.current <= 0:
raise StopIteration # 信号:没货了
self.current -= 1
return self.current + 1
>>> for n in Countdown(3): print(n)
3
2
1
请对着这段代码记住三件事:__iter__ 管发货(给游标),__next__ 管货品(给下一个值),StopIteration 管收摊。下一章 yield 会把这个类压缩成三行——到时候你会回来对照:yield 替你写了 __next__ 的全部机械部分。
生成器:协议的语法糖(预告)
同样的倒计时,用 yield 写:
def countdown(start):
while start > 0:
yield start # 暂停在这里,交出一个值
start -= 1
>>> for n in countdown(3): print(n)
3
2
1
countdown(3) 没有执行函数体——它返回了一个生成器对象(迭代器的一种)。for 每次要值时函数才向前跑到下一个 yield。你手写的 CountdownIterator 里的状态(self.current)和 __next__ 机械(判断、递减、返回、抛信号),全部由 yield 的执行模型代管。
这一章只需要记住结论:生成器 = 自动实现迭代器协议的函数。 它为什么能「暂停—恢复」、send 怎么往里传值、异常怎么往里扔——正是 yield 课 01/02 章的全部内容。
上下文管理器协议:with 到底保证什么
with 的承诺很具体:无论代码块正常结束还是中途抛异常,__exit__ 都会被调用。
with open("data.txt") as f:
process(f.read())
# 等价于:
f = open("data.txt")
try:
process(f.read())
finally:
f.close() # 文件在异常时也会被关——这就是 with 的全部意义
所以「with 管资源清理」的准确说法是:with 把「用完必须清理」从纪律变成语法。忘写 finally 不会忘写 with。
自己实现一个(协议就两个方法):
class Timer:
def __enter__(self):
self.start = time.perf_counter()
return self # as 后面接到的就是这个返回值
def __exit__(self, exc_type, exc_val, exc_tb):
self.elapsed = time.perf_counter() - self.start
print(f"took {self.elapsed:.3f}s")
return False # False = 不吞异常,继续传播
with Timer() as t:
heavy_work()
__exit__ 的三个参数是「代码块里炸的异常信息」(没炸就是三个 None)。返回值有语义:返回 True 会吞掉异常,返回 False/None 继续传播。默认写 None(别吞),需要「拦截特定异常」时才显式返回 True——这也是 contextlib.suppress 的原理。
contextlib:95% 的场景不用写类
日常最常用的是 @contextmanager——用生成器写上下文管理器:
from contextlib import contextmanager
@contextmanager
def timer(label):
start = time.perf_counter() # __enter__ 部分
try:
yield # ← with 块的执行发生在这一刻
finally:
elapsed = time.perf_counter() - start # __exit__ 部分
print(f"{label}: {elapsed:.3f}s")
yield 之前是进入逻辑,yield 之后是退出逻辑,yield 本身代表 with 块。是不是发现 yield 又出现了?它是 Python 里「函数能在中间暂停」的唯一原语——本课用它简化了协议书写,yield 课把它拆到底层。这个「一个原语、两种用途」正是 yield 值得单独开一门课的原因。
配套的两个小工具也值得记住:
from contextlib import suppress, ExitStack
with suppress(FileNotFoundError): # 明确声明"这个异常我知道,吞掉"
os.remove(tmp_file)
with ExitStack() as stack: # 动态管理任意多个上下文
files = [stack.enter_context(open(p)) for p in paths]
# 退出时按逆序全部关闭
自检三题
for x in [1,2]循环两次后,再for x in [1,2]还能遍历吗?换成iter([1,2])存下来的迭代器呢?- 手写迭代器类时,为什么
__iter__要返回self?Countdown 里为什么却返回新对象? @contextmanager装饰的函数里,try/finally 的 finally 部分对应__exit__的什么行为?如果把 finally 去掉会怎样?
下一站
yield 课(python-yield-deepdive):本章的迭代器协议是它的直接前置。从 yield 的暂停/恢复逐行演算开始,到 send/throw/close 双向通道,再到异步生成器——你将看到本章所有「语法糖」背后的完整机制。