Chapter 5: Control Flow Semantics
控制流语义回答一个问题:Python 代码从一个 statement 走向另一个 statement 时,解释器依据什么状态做决定。表达式求值得到对象,控制流语句再把对象的 truth value、iterator 状态、pattern 匹配结果、异常状态和 context manager 退出协议连接起来,形成可观察的执行顺序。
读完本章后,读者应能追踪一段 Python 控制流代码的运行路径:先判断当前 frame 执行到哪个 suite,再判断跳转、迭代、异常传播或资源退出由哪个 runtime 对象驱动,最后解释为什么某个分支执行、某个 else 被跳过、某个 cleanup 一定发生。
本章以 Python 3.10+ 为结构匹配边界。语言语义以 Python Language Reference: compound statements 为依据;涉及 bytecode 时只把它作为 CPython 观察入口,dis 文档明确说明 CPython bytecode 属于实现细节,跨 Python 版本和其它 VM 没有稳定兼容承诺,相关说明见 Python dis documentation。
贯穿本章的材料是一段短代码。它同时包含 with、for、match、guard、continue、break、loop else 和异常路径。后续每节都会回到这段代码,说明表面语法背后的执行状态。
class Recorder:
def __enter__(self):
print("open")
return self
def __exit__(self, exc_type, exc, tb):
print("close", exc_type.__name__ if exc_type else None)
return False
def scan(records, limit):
result = []
with Recorder():
for item in records:
match item:
case {"kind": "hit", "value": value} if value <= limit:
result.append(value)
continue
case {"kind": "bad", "reason": reason}:
raise ValueError(reason)
case {"kind": "stop"}:
break
case _:
pass
else:
result.append("exhausted")
return result
这段代码的主路径可以压缩成一条状态线:进入 with 后获得 context manager;进入 for 后获得 iterator;每次迭代把一个对象绑定到 item;match 按 case 顺序选择 suite;continue 回到下一轮迭代;break 跳出循环并跳过 loop else;iterator 耗尽时执行 loop else;异常抛出时先走异常传播,再触发 with 的退出协议。
这张图只表达当前函数内部的控制流,不展开 pattern 的全部分类,也不展开 CPython 解释器循环。它用于建立一个判断顺序:先看当前语句产生的局部控制转移,再看该转移是否触发 loop else、异常传播或 context cleanup。
5.1 if / while / for
if、while 和 for 都会控制一个 suite 的执行,但三者依赖的 runtime 输入不同。if 依赖条件表达式的 truth value,while 在每轮开始重新计算 truth value,for 依赖 iterable 生成的 iterator 状态。把这三个语句混成“判断和循环”会丢失关键差异:if 只选择一次,while 反复求条件,for 把循环推进权交给 iterator。
Python 的 truth value testing 是对象协议的一部分。对象可以通过 __bool__() 决定真假;缺少 __bool__() 时,解释器会查询 __len__(),长度为零时为假,长度非零时为真;两者都缺少时,对象实例按真值处理。这个规则使 if records:、while buffer: 这类代码看起来像语法判断,运行时仍然是在查询对象能力。
if 的控制路径最短。解释器按顺序求 if、elif 的条件表达式,遇到第一个 truth value 为真的条件就执行对应 suite,后续条件和 suite 都不再求值。这个顺序会影响副作用。例如条件中调用函数、读取属性或执行 walrus 绑定时,只有已经走到的条件表达式才会发生状态变化。
def choose(record):
if record.get("kind") == "hit":
return "recorded"
elif record.get("kind") == "stop":
return "stopped"
return "ignored"
这段代码中,record.get("kind") 至多执行两次。第一个条件命中时,第二个条件不会被求值。把它放回 runtime 模型中看,if 的模型是按源码顺序逐个求 truth value,并在命中后切换到对应 suite。
while 把同一个 truth value 测试放到循环入口。每轮 body 执行结束后,控制流回到条件表达式;条件变假时循环自然结束,并进入 loop else 的候选路径。while 的风险集中在状态推进:如果 body 没有改变条件依赖的对象状态,frame 会不断回到同一个入口。
def drain(queue):
count = 0
while queue:
queue.pop()
count += 1
return count
while queue: 每轮都会重新查询 queue 的 truth value。queue.pop() 改变容器长度,下一轮条件才可能变假。这里的退出条件由容器对象状态变化提供,计数器只记录处理数量。
for 的入口模型和 while 不同。for item in records: 会先对 records 求值,再创建 iterator,然后反复从 iterator 取下一个对象。循环变量 item 是每轮赋值目标,它会覆盖上一轮绑定。贯穿示例中的 for item in records: 因此有两个状态:iterator 内部进度和当前 frame 中 item 名字的绑定。
在 CPython 中,这些语句会被编译成条件跳转、向后跳转、iterator 获取和异常结束路径等 bytecode 结构。bytecode 名称和布局随版本变化,本章只使用稳定语义判断:条件控制 suite,循环控制重复入口,iterator 控制 for 的推进。
5.2 iterator-driven loop
for 循环由 iterator 驱动。工作定义很直接:iterable 是能交出 iterator 的对象,iterator 是能逐次交出下一个值并在耗尽时发出 StopIteration 的对象。for 语句把这组协议包起来,让用户看到的是循环变量和 suite。
贯穿示例中的 records 只在进入 for 时求值一次。之后循环沿着已经创建的 iterator 向前推进。这个细节解释了一个常见现象:在循环体内重新绑定 records,不会把当前 for 改到另一个 iterable 上。
def rebind_inside_loop(records):
seen = []
for item in records:
seen.append(item)
records = []
return seen
如果调用方传入 [1, 2, 3],循环仍然会继续沿原 iterator 推进。records = [] 只改变当前 frame 中名字 records 的绑定关系,它没有替换已经交给 for 的 iterator。这个判断需要先区分 name binding 和 iterator state,再解释循环输出。
next(iterator) 正常返回时,返回值会按 assignment 规则绑定到 target list。target 可以是单个名字,也可以是解构目标。例如 for key, value in pairs: 每轮都会先从 iterator 取一个对象,再把这个对象按赋值规则拆给 key 和 value。解构失败时抛出异常,循环转入异常路径,loop else 没有执行机会。
StopIteration 在普通 for 中代表正常结束信号。解释器捕获这个信号后退出循环,并检查 loop else。因此 for 的自然结束来自 iterator 明确报告耗尽后形成的控制流分支。
这个规则也是阅读 scan() 的第一层判断。只要 records 的 iterator 持续交出对象,match item: 就会反复运行。iterator 耗尽时,result.append("exhausted") 才进入候选路径;break 和异常会绕开这条自然耗尽路径。
5.3 break / continue
break 和 continue 是循环内部的局部控制转移。break 的目标是当前最近一层循环的出口;continue 的目标是当前最近一层循环的下一轮入口。它们不返回值,也不改变 iterator 本身的定义,只改变当前 frame 接下来要执行的位置。
在贯穿示例中,命中 {"kind": "hit", "value": value} 且 guard 通过后,continue 会让控制流回到 for 的下一轮取值点。它会跳过当前 case 后面的语句,也会跳过同一轮中其它 case 的候选执行。下一轮能否继续,仍然由 iterator 是否提供下一个对象决定。
break 的效果更强。命中 {"kind": "stop"} 后,控制流直接离开 for,当前循环的 loop else 被跳过。这个规则只影响最近一层循环;如果循环嵌套在函数、with、try 中,外层结构还会继续处理自己的退出规则。
def first_even(numbers):
for number in numbers:
if number % 2 == 0:
found = number
break
else:
found = None
return found
这段代码用 break 区分“提前命中”和“自然耗尽”。当找到偶数时,found = number 后离开循环,loop else 不执行;当 iterator 耗尽仍无命中时,else 把 found 设为 None。这比额外布尔标志更贴近控制流事实。
break 和 continue 与 finally 组合时,需要先看 pending control transfer,再看 cleanup。finally 会在离开 try suite 的路径上运行,包括 break、continue、return 和异常传播。finally 中再执行新的 return、break 或 continue 会覆盖原来的离开路径;Python 3.14 起,编译器会对此类写法发出 SyntaxWarning。工程上应把 finally 写成收束资源状态的代码,让原控制路径保持清晰。
5.4 loop else 的语义
loop else 表达的是循环自然结束后的补充路径。对 for 来说,自然结束来自 iterator 耗尽;对 while 来说,自然结束来自条件表达式变假。只要循环体执行了命中当前循环的 break,该 else suite 就被跳过。
这个语义适合表达搜索失败、扫描完成、重试耗尽等场景。else 与 if 的 else 名字相同,但触发条件不同:if else 依赖条件分支未命中,loop else 依赖循环没有被 break 提前结束。
贯穿示例中,result.append("exhausted") 的含义是所有输入都被扫描完,并且没有出现 {"kind": "stop"}。如果输入中出现 stop,break 让控制流直接离开 for,"exhausted" 不会被追加。这个分支因此记录“扫描完整结束”,与循环体是否执行无直接对应关系。
def contains_name(records, expected):
for record in records:
if record.get("name") == expected:
return True
else:
return False
这段代码中的 else 会在空 iterable 和全部不匹配两种情况下执行。原因相同:循环都走到了自然结束。读这类代码时,先找有没有命中当前循环的 break 或提前 return,再判断 else 是否有机会执行。
return 与 loop else 的关系需要单独看。return True 会离开整个函数,后续 loop else 没有执行机会。return 属于函数级退出路径,它同样使 frame 不再停留在循环结构中。判断 loop else 时,应把 break 看成语义触发条件,把 return、异常看成更外层的控制离开路径。
5.5 pattern matching
match 是 Python 3.10 引入的结构匹配语句。它的工作对象包含两部分:match 后面的 subject value,以及每个 case 后面的 pattern。执行时先求 subject expression,再按 case 顺序尝试 pattern;某个 case 被选中后,执行该 case 的 suite,整个 match 结束。
贯穿示例中的 match item: 只求一次 item 的当前绑定值。后续 case 都拿这个 subject value 匹配。case {"kind": "hit", "value": value} 是 mapping pattern:它要求 subject 支持 mapping 形状,存在指定 key,并把 "value" 对应的值捕获到名字 value。捕获名是当前局部 namespace 中的普通绑定。
def classify(item):
match item:
case {"kind": "hit", "value": value}:
return ("hit", value)
case {"kind": "stop"}:
return ("stop", None)
case _:
return ("other", None)
这里的 _ 是 wildcard pattern,用于匹配剩余 subject。它不建立业务名字绑定。value 则是 capture pattern 的名字,它在命中的 case 中成为局部变量。阅读 pattern 时要先分清 literal、capture、wildcard、sequence、mapping、class 等 pattern 类型,因为它们触发的检查和绑定规则不同。
pattern matching 的一个关键边界是捕获名与值匹配的区别。裸名字在 pattern 中通常表示捕获,它会绑定 subject 的对应部分;带点路径如 Color.RED 才表达 value pattern,解释器会读取该属性值并与 subject 比较。阅读 case status: 时,应先把它理解为捕获,再根据语法形状判断是否存在值比较。
case 顺序会改变结果。一个更宽的 pattern 放在前面,会提前选中并结束 match,后面的精确 pattern 没有机会运行。通配 case、无 guard 的捕获 case、总能成功的 OR pattern 都属于宽 pattern,通常放在最后。语言规则也要求最多一个 irrefutable case block,并且它必须位于最后。
失败匹配中的部分捕获属于实现可变区域。语言说明允许实现为了优化而产生或清理部分绑定。工程判断应把失败 case 的捕获视为不可观察状态;真正要在 match 后继续使用的值,应在已选中 case 的 suite 中明确赋给稳定名字。
5.6 guard condition
guard condition 是写在 case pattern if expression: 中的附加条件。它的执行顺序固定:先让 pattern 匹配 subject,匹配成功后再求 guard 表达式;guard 为真时选择该 case,guard 为假时继续尝试后续 case;guard 求值抛出异常时,异常向外传播。
贯穿示例中的第一条 case 有两个阶段。{"kind": "hit", "value": value} 先完成结构检查和 value 捕获;随后 value <= limit 才执行。guard 能读取 pattern 捕获出来的名字,因为这些名字已经在 guard 求值前建立绑定。
def accept_hit(item, limit):
match item:
case {"kind": "hit", "value": value} if 0 <= value <= limit:
return value
case {"kind": "hit"}:
return None
case _:
return None
这段代码把结构检查和业务约束分开。第一条 case 表示“形状是 hit,并且 value 落在范围内”;第二条 case 接住剩余 hit。这样写的好处是控制流证据清楚:pattern 负责形状,guard 负责额外布尔判断。
guard 的副作用按 case 顺序发生。只有 pattern 成功的 case 才会求 guard;已经被选中的 case 后面,guard 和 pattern 都不再运行。guard 中调用函数、修改状态或读取外部对象时,要把它当作普通表达式副作用处理,声明式筛选的读法会漏掉顺序影响。
guard 失败后的名字状态容易制造阅读负担。稳定做法是把 guard 中捕获名的使用范围收敛到当前 case 内部,或者在 case suite 中写入新的结果变量。这样即使实现细节或后续重构改变了匹配路径,后续代码仍然只依赖明确选择过的分支结果。
5.7 exception-driven flow
异常驱动控制流从 raise 开始。异常对象携带类型和值,传播时还会连接 traceback,traceback 记录异常经过的 frame 和源码位置。异常路径会转向最近能处理该异常的 handler,或一路传播到调用栈外层。
贯穿示例中,命中 {"kind": "bad", "reason": reason} 后执行 raise ValueError(reason)。此时 for 的自然耗尽路径失去执行机会,loop else 被绕开,控制流转向异常处理。由于外层存在 with Recorder():,解释器在异常离开 with suite 前会调用 Recorder.__exit__,并把异常类型、异常对象和 traceback 传给它。
def parse_positive(value):
if value < 0:
raise ValueError("negative")
return value
这段函数的异常路径包含两个可观察事实:调用方得不到正常返回值,traceback 会把错误位置连到当前 frame。阅读更复杂的异常代码时,应先定位异常创建点,再看它是否被当前 try 匹配,最后看离开路径上有哪些 finally 或 context manager cleanup。
except handler 的匹配按顺序进行。带表达式的 except 会把表达式求成异常类型或异常类型元组,再用异常对象的类型层级判断是否匹配。第一个匹配的 handler 执行后,控制流从整个 try 语句后继续;没有匹配项时,异常继续向外层传播。
finally 是异常路径和非异常路径的共同 cleanup 点。无论 try suite 正常结束、执行 return、触发 break / continue,还是抛出异常,finally 都会在离开时运行。finally 里产生的新异常会改变传播对象,原异常会作为上下文挂在新异常上。
CPython 3.11+ 使用 exception table 表达异常处理范围,降低正常路径上的异常处理开销。这个实现细节改变的是解释器如何记录 handler 范围,不改变本章使用的语言判断:异常一旦传播,控制流会离开当前线性顺序,并沿调用栈寻找处理点,同时执行必要 cleanup。
5.8 context-driven flow
with 把资源进入、绑定、退出和异常处理统一成 context manager 协议。context manager 是实现 __enter__() 和 __exit__() 的对象;__enter__() 负责进入资源边界并返回可绑定对象,__exit__(exc_type, exc, tb) 负责接收退出原因并执行清理。
贯穿示例中,with Recorder(): 的执行顺序是:先求 Recorder() 得到 manager,再调用 __enter__(),然后执行 for suite。suite 正常结束、break 离开循环或异常离开 suite,都会进入 __exit__()。如果没有异常,三个异常参数都是 None;如果 ValueError 正在传播,三个参数分别描述异常类型、对象和 traceback。
__exit__() 的返回值会改变异常路径。返回真值时,异常被抑制,控制流继续执行 with 后面的语句;返回假值时,异常继续传播。贯穿示例中 Recorder.__exit__() 返回 False,因此 ValueError 会继续交给外层调用方。
class SuppressValueError:
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return exc_type is ValueError
def run():
with SuppressValueError():
raise ValueError("hidden")
return "after with"
这段代码会返回 "after with"。控制流没有停在 raise,原因是 __exit__() 对 ValueError 返回真值。阅读 with 时,需要同时检查 body 中是否抛异常,以及 manager 的退出协议是否改变异常传播。
多个 context item 会按嵌套 with 处理。with A() as a, B() as b: 的进入顺序是 A 再 B,退出顺序是 B 再 A。这个后进先出的顺序适合资源依赖:后获得的资源先释放,外层资源最后收束。
async with 使用 __aenter__() 和 __aexit__(),退出动作本身可以被 await。它和普通 with 共享同一个语义形状:进入资源边界,执行 suite,把正常或异常退出交给退出协议。差异在于异步 context manager 的进入和退出都可能把控制权交还事件循环,因此读异步代码时还要额外追踪 await 点。
到这里,控制流语义可以形成一套可复用判断顺序:先定位当前 statement 的 suite,再判断它依赖 truth value、iterator、pattern、异常还是 context 协议;接着检查局部控制转移是否触发 loop else、handler、finally 或 __exit__();最后把可观察结果落回名字绑定、对象状态和调用栈传播。
最小自检任务
阅读下面代码,判断三次调用分别返回什么,是否会打印 exit ...,以及 loop else 在哪些输入中执行。
class Marker:
def __enter__(self):
print("enter")
return self
def __exit__(self, exc_type, exc, tb):
print("exit", exc_type.__name__ if exc_type else None)
return False
def collect(records):
values = []
with Marker():
for record in records:
match record:
case {"kind": "value", "n": n} if n > 0:
values.append(n)
continue
case {"kind": "error", "msg": msg}:
raise RuntimeError(msg)
case {"kind": "stop"}:
break
case _:
pass
else:
values.append("done")
return values
测试输入:
collect([{"kind": "value", "n": 2}, {"kind": "other"}])
collect([{"kind": "value", "n": -1}, {"kind": "stop"}])
collect([{"kind": "error", "msg": "boom"}])
答案要点
第一次调用会打印 enter,随后 iterator 交出两个元素。第一个元素匹配 value pattern,guard n > 0 为真,追加 2 后 continue 回到下一轮;第二个元素走 wildcard case。iterator 耗尽后执行 loop else,追加 "done"。离开 with 时没有异常,打印 exit None,函数返回 [2, "done"]。
第二次调用会打印 enter。第一个元素匹配 value pattern,但 guard 为假,于是继续尝试后续 case,最终走 wildcard case;第二个元素匹配 stop pattern,执行 break。break 跳过 loop else。离开 with 时没有异常,打印 exit None,函数返回 []。
第三次调用会打印 enter。第一个元素匹配 error pattern,捕获 msg 后抛出 RuntimeError("boom")。异常使 for 和 loop else 都失去执行机会;离开 with 前调用 __exit__(),打印 exit RuntimeError。由于 __exit__() 返回 False,异常继续传播,函数没有正常返回值。
这道题的判断顺序是:先看 iterator 是否自然耗尽,再看 continue、break、异常分别把控制流送往何处;随后检查 loop else 是否执行;最后检查 with 的退出协议是否抑制异常。
本章知识点总结
- 控制流对象:Python 控制流由 truth value、iterator、pattern、异常对象和 context manager 共同决定。
- 分支顺序:
if按源码顺序求条件,命中第一个真值 suite 后停止后续条件求值。 - 循环入口:
while每轮重新计算条件表达式,条件依赖的对象状态决定循环能否自然结束。 - 迭代驱动:
for先创建 iterator,再反复取值并绑定 target,iterator 耗尽形成自然结束。 - 名字绑定:循环 target 每轮覆盖旧绑定,循环体内重新绑定 iterable 名字不会替换当前 iterator。
- 局部跳转:
continue回到下一轮入口,break离开最近一层循环并跳过该循环的else。 - 自然结束:loop
else只表示循环自然结束,for对应 iterator 耗尽,while对应条件变假。 - 结构匹配:
match先求 subject,再按 case 顺序匹配 pattern,命中 case 后执行对应 suite。 - 捕获边界:pattern 中裸名字通常是 capture pattern,稳定值比较应使用 literal 或 qualified value pattern。
- Guard 顺序:guard 在 pattern 成功后求值,能读取 pattern 捕获名,失败后继续尝试后续 case。
- 异常传播:异常把控制流从线性下一句切换到 handler 搜索,并通过 traceback 连接经过的 frame。
- 清理路径:
finally会在离开trysuite 的路径上运行,包括正常结束、跳转、返回和异常传播。 - 上下文退出:
with在 suite 离开时调用__exit__(),并把异常信息或三个None交给它。 - 异常抑制:
__exit__()返回真值会抑制异常,返回假值会让异常继续传播。 - 判断顺序:阅读控制流代码时,先定位 suite,再判断驱动对象,随后检查跳转、loop
else、异常和 context cleanup。