Skip to main content

Chapter 6: Exception Semantics

异常语义回答一个具体问题:当 Python 代码在深层调用里失败时,解释器保存了哪些对象状态,控制流怎样离开当前 frame,清理代码怎样执行,最终调用方能根据哪些信息判断失败来源。本章读完后,应能追踪异常对象、traceback、cause、context、active exception 和 exception group 之间的关系,并能判断一段 try 代码的正常路径、异常路径和资源收束顺序。

本章以一个短代码现象贯穿:配置读取失败后,把底层 KeyError 包装成对上层更有意义的 ValueError,同时执行清理逻辑,再在批处理场景中把多个失败合并为 ExceptionGroup。这个例子覆盖普通异常传播、显式异常链、finally 收束、CPython 3.11+ 的异常表路径,以及 except* 对异常组的拆分。

Python 语言层面的依据来自 Python 3 文档中的 raise statementtry statementbuilt-in exceptionsExceptionGroupexcept* 来自 PEP 654,CPython 3.11+ 的异常路径变化可从 What’s New In Python 3.11 里的 zero-cost exception 说明和 bytecode changes 观察到。正文只把这些材料整理成可迁移的 runtime 判断,不把某个 CPython 文件行号当成本章结论。

6.1 Exception object propagation and traceback chain

异常传播的主角是异常对象。raise ValueError("missing count") 执行后,解释器得到一个继承自 BaseException 的实例,并把当前执行位置形成的 traceback 挂到这个实例的 __traceback__ 属性上。traceback 是一条从抛出点向调用方延伸的 frame 记录链,它让调用方在异常到达外层时仍能看到失败发生在哪个函数、哪一行、经过了哪些调用。

先看贯穿代码的第一部分。这个函数把底层数据读取错误转换成上层可识别的配置错误:

def read_count(row):
try:
return int(row["count"])
except KeyError as exc:
raise ValueError("missing count") from exc
finally:
print("cleanup")

输入 read_count({}) 时,row["count"] 在当前 frame 中触发 KeyError。解释器先构造或接收这个异常对象,记录它的 traceback,然后沿调用栈寻找能匹配它的 handler。当前函数内有 except KeyError as exc,所以异常暂时被这个 handler 接住,exc 绑定到原始 KeyError 实例。接着 handler 中的 raise ValueError(...) from exc 又创建新的异常对象,新的 ValueError 成为继续向外传播的异常。

异常传播可以按下面这条路径理解。图中只描述普通函数调用和异常传播,不展开生成器、协程和 C 扩展边界。

这条路径的核心判断是:异常对象保存失败类型和错误参数,traceback 保存失败到达外层前经过的执行位置,handler 决定控制流是否在当前 frame 内收束。frame 退出时,局部变量、value stack、当前指令位置和异常状态都会参与清理或迁移;traceback 会持有相关 frame 的引用,所以异常对象一旦被长期保存,就可能延长局部对象生命周期。

读取 traceback 时,要先确认最内层失败点,再看异常向外传播时经过的调用链。比如 read_count({}) 的最内层失败点是 row["count"],外层看到的主异常是 ValueError("missing count")。如果只看最后一行错误类型,会得到上层抽象;如果看异常链和 traceback,可以复盘底层数据访问失败如何被转换成业务层错误。

raise 还有一个容易影响判断的边界:不带表达式的 raise 使用当前正在处理的 active exception 重新抛出。它适合在 handler 内局部记录日志后继续向外传播同一个异常对象;在没有 active exception 的位置执行裸 raise,解释器会抛出 RuntimeError。这个规则说明 raise 的含义依赖当前 runtime error state,而非单纯依赖语法形状。

6.2 Exception chaining and hierarchy

异常链解决的是“上层错误和底层原因怎样同时保留”的问题。raise NewError(...) from old_error 会把 old_error 放到新异常的 __cause__ 上,并设置显示策略,让 traceback 输出明确展示直接原因。没有 from 时,如果 handler、finallywith 退出过程中又产生新异常,解释器会把正在处理的旧异常放入新异常的 __context__

贯穿代码中,ValueError("missing count") 是调用方需要处理的错误类型,KeyError("count") 是数据访问失败的底层原因。显式链让两个层级同时可见:调用方可以捕获 ValueError,日志系统仍能从 err.__cause__ 找到原始 KeyError。这比直接把 KeyError 泄露给上层更稳定,因为上层接口暴露“缺少配置项”这一稳定语义,字典访问细节留在异常链里作为证据。

下面的观察代码只检查对象关系,不依赖具体 traceback 文本:

try:
read_count({})
except ValueError as err:
print(type(err).__name__)
print(type(err.__cause__).__name__)
print(type(err.__context__).__name__)
print(err.__suppress_context__)

在 Python 3.11+ 中,输出会表明主异常是 ValueError,直接原因是 KeyError,上下文也存在,__suppress_context__ 为真值。显示 traceback 时,显式 __cause__ 优先表达“direct cause”,隐式 __context__ 的显示被压住。对象上仍保留足够信息,工具可以读取这些属性进行日志、测试断言或错误归类。

异常层级决定 except 的匹配范围。BaseException 是根,Exception 是普通应用错误的共同父类;SystemExitKeyboardInterruptGeneratorExit 直接位于 BaseException 下,代表解释器退出、用户中断或生成器收束这类控制流信号。工程代码里常见的 except Exception 会捕获 ValueErrorTypeErrorOSError 等普通错误,同时让中断和退出信号继续向外传播。

选择异常类型时,应从调用方可恢复的动作出发。参数形状错误通常映射到 TypeErrorValueError;容器查找失败映射到 LookupError 族里的 KeyErrorIndexError;属性读取失败映射到 AttributeError;外部系统失败常落到 OSError 及其子类。这个分类的价值在于让 handler 用类型表达处理策略,而非用字符串解析错误消息。

异常链和异常层级要一起读。类型告诉你“当前层级希望调用方怎样处理”,链条告诉你“这个结论从哪个底层失败演化而来”。当你设计库接口时,底层异常可以通过 from 保留证据,上层异常类型负责稳定 API 边界。读取别人代码时,先看最外层异常类型,再沿 __cause____context__ 回到原始失败点,这样能同时得到接口语义和实现证据。

6.3 finally cleanup and stack unwinding

finally 的作用是把“离开当前控制区域时必须执行的动作”绑定到控制流出口上。这个出口可以是正常返回、异常传播、breakcontinue 或 handler 内再次抛出。解释器执行 try 体后,一旦控制流要离开这个区域,就进入 finally 代码;如果进入 finally 前存在待传播异常,该异常会被临时保存,finally 执行结束后再继续传播。

贯穿代码里的 finally 打印 cleanup。无论 row["count"] 成功、int(...) 失败,还是 handler 抛出新的 ValueError,这行清理都会在函数离开前执行。真实工程中它通常对应文件关闭、锁释放、临时状态复位、事务回滚或指标记录。关键点是清理动作跟出口绑定,异常类型只影响清理后控制流到达哪里。

stack unwinding 描述调用栈在异常传播时逐层退出的过程。一个 frame 没有合适 handler 时,解释器要撤离这个 frame;撤离前先执行该 frame 中覆盖当前指令范围的 finally 或上下文管理退出逻辑。清理完成后,同一个异常继续交给调用方 frame 匹配。调用栈越深,异常可能触发多层清理,每一层都可以记录日志、释放资源、转换异常或吞掉异常。

这段代码展示 finally 对返回值和异常的影响边界:

def override_return():
try:
return "try result"
finally:
return "finally result"

print(override_return())

输出是 finally result。原因是 try 中的 return 先准备好返回值并触发离开过程,finally 随后执行;finally 里的 return 成为最后一次控制流决策。这个行为同样适用于异常:如果 finally 自己抛出新异常,原先保存的异常会进入新异常的上下文。工程上应让 finally 尽量执行清理并自然结束,把错误处理放在 except 或调用方;这样原始失败信息更容易保持清晰。

with 的退出路径也属于这一类控制流收束。with manager: 会把退出动作交给 manager.__exit__(exc_type, exc, traceback),异常对象和 traceback 会作为参数传入。__exit__ 返回真值时,异常被视为已经处理;返回假值时,异常继续传播。下一章会系统展开 context manager,本章只需要保留一个判断:finallywith 都参与 stack unwinding,二者都可能改变异常是否继续向外层传播。

检查清理顺序时,按三个问题推进:当前出口是什么,当前 frame 覆盖了哪些 finally 或退出回调,清理代码本身是否产生新的控制流。第一个问题确定为何离开,第二个问题确定清理范围,第三个问题确定原始异常是否仍是最终异常。这个顺序比直接看最后抛出的异常更可靠,因为最后异常可能来自清理阶段。

6.4 Exception table and zero-cost exception path

CPython 3.11+ 改变了异常处理在 bytecode 正常路径中的形状。以前的异常处理需要在指令流里维护更多 block stack 相关操作;3.11+ 把异常处理范围和目标 handler 信息放入 code object 的 exception table,正常执行 try 体时主要按普通指令推进。异常真的发生后,解释器才根据异常表查找当前 instruction offset 对应的 handler。

可以用 dis 观察这个实现形状。下面的代码和贯穿材料结构相同,只保留一个 try / except

import dis

def parse_value(raw):
try:
return int(raw)
except ValueError:
return None

dis.dis(parse_value)

在 CPython 3.11+,dis 输出中通常能看到 ExceptionTable: 区域,并看到异常路径使用 PUSH_EXC_INFOCHECK_EXC_MATCHPOP_EXCEPTRERAISE 等指令。这个观察说明 handler 范围已经从普通线性指令流中抽到表结构里。try 覆盖范围、handler 入口、异常栈深度和是否恢复 last instruction 信息,都由异常表项表达。

zero-cost exception path 的含义要限定在正常路径:当没有异常抛出时,try 语句本身对正常路径的额外解释器开销被压低。这个结论来自 CPython 3.11+ 实现,属于 CPython 版本相关事实。异常发生时仍然需要创建或传播异常对象、挂接 traceback、查表、进入 handler、执行匹配和清理,所以异常路径仍有明确成本。

这个变化影响性能判断,也影响源码阅读顺序。读 Python 源码时,try 包裹一小段正常逻辑不再天然意味着正常路径有明显解释器负担;判断成本时要看异常是否常态发生、异常对象是否被频繁创建、traceback 是否被长期保留、handler 是否执行昂贵逻辑。读 CPython bytecode 时,要把 code object 的 co_exceptiontable 纳入观察对象,同时结合可见指令顺序推断 handler 范围。

异常表还影响调试工具和字节码分析。工具若只扫描跳转指令,很容易漏掉异常进入 handler 的边。正确模型是:普通跳转表达显式控制流,exception table 表达异常控制流。两者共同构成当前 code object 的控制流图。后续讲 CFG、bytecode 和 adaptive runtime 时,还会把这个判断放回编译器和执行器的整体路径中。

6.5 ExceptionGroup and runtime error state

ExceptionGroup 解决的是“多个无关异常同时向上报告”的问题。并发任务、批处理、资源收束阶段可能同时得到多个失败;把它们压成第一条异常会丢失信息,把它们逐个抛出又无法表达“同一个操作产生了一组失败”。Python 3.11 引入 BaseExceptionGroupExceptionGroupexcept*,用树形异常对象表达一组异常,并允许 handler 按类型拆分处理其中一部分。

下面把贯穿材料扩展成批处理。两个输入都会失败:第一个缺少键,第二个字符串无法转换为整数。外层收集普通异常,再统一抛出异常组:

def collect_errors(rows):
errors = []
for row in rows:
try:
read_count(row)
except Exception as exc:
errors.append(exc)
if errors:
raise ExceptionGroup("batch failed", errors)

try:
collect_errors([{}, {"count": "NaN"}])
except* ValueError as group:
print(len(group.exceptions))

这段代码会执行两次 cleanup,然后 except* ValueError 得到一个只含匹配子异常的 ExceptionGroup。第一个子异常是由 KeyError 显式链接出来的 ValueError("missing count"),第二个子异常是 int("NaN") 产生的 ValueError。因为两个叶子异常都匹配 ValueError,这个 handler 会接收包含两个异常的 group,并打印 2

except* 的匹配单位是异常组中的叶子异常,但 handler 接收到的对象仍是一个异常组。一个异常组可以被多个 except* 子句按类型拆分;已匹配的部分进入当前 handler,未匹配的部分继续交给后续 except*,最后仍未处理的部分会重新组合并传播。普通 exceptexcept* 在同一个 try 语句中不能混用,这保证了一个 try 区域采用单异常处理模型或异常组处理模型。

runtime error state 指解释器在当前执行点保存的“正在处理的异常”。在 Python 3.11+,sys.exception() 可以在 handler 内取得当前 active exception;离开 handler 后,解释器会恢复进入 handler 前的状态。except E as name 绑定的异常名在 handler 结束时会被清理,这和 traceback 持有 frame 引用有关;如果异常对象继续留在局部变量里,frame、locals 和对象图可能形成引用环,延长内存释放时间。

异常组不会改变普通异常对象的基本字段。每个叶子异常仍有自己的类型、参数、__traceback____cause____context__;外层异常组也有自己的 traceback,它记录这些异常被组合后共同传播的路径。读异常组时,应先看 group 的 message 和传播位置,再看叶子异常类型分布,最后沿叶子的异常链回到具体失败点。这个顺序可以把“批处理整体失败”和“单个输入为何失败”分开判断。

本章建立的总模型是:异常是携带 traceback 的对象,传播是沿调用栈寻找 handler 的控制流,链条保留错误转换证据,清理代码参与 stack unwinding,CPython 3.11+ 用异常表降低正常路径负担,异常组用树形结构表达多个失败。迁移到任意一段 Python 异常代码时,先定位主异常对象,再读 traceback 和链条,然后检查清理路径、handler 匹配范围和版本相关的执行器行为。

最小自检任务

阅读下面代码,不运行程序,判断输出顺序、最终被 except* 接住的异常数量,以及第一个异常的底层原因类型。

def read_count(row):
try:
return int(row["count"])
except KeyError as exc:
raise ValueError("missing count") from exc
finally:
print("cleanup")

errors = []
for row in [{}, {"count": "NaN"}]:
try:
read_count(row)
except Exception as exc:
errors.append(exc)

try:
raise ExceptionGroup("batch failed", errors)
except* ValueError as group:
print(len(group.exceptions))
print(type(group.exceptions[0].__cause__).__name__)

答案要点

这段代码会先输出两次 cleanup,因为两次 read_count 调用都要离开 try 区域,finally 会在异常交给外层前执行。第一次输入 {} 触发 KeyError,handler 把它转换成 ValueError("missing count"),并通过 from exc 写入 __cause__;第二次输入 {"count": "NaN"}int("NaN") 直接产生 ValueError,没有显式 cause。外层 errors 收集到两个普通 ValueError,随后构造 ExceptionGroupexcept* ValueError 会匹配两个叶子异常,所以打印数量 2;第一个叶子异常的 __cause__KeyError,所以最后打印 KeyError

本章知识点总结

  • 异常对象raise 传播的是继承自 BaseException 的异常实例,类型、参数和 traceback 共同描述一次失败。
  • traceback 链:traceback 记录异常经过的 frame 路径,让外层 handler 能回到最内层失败点复盘。
  • handler 匹配:解释器沿当前 frame 到调用方 frame 查找匹配 handler,命中后控制流进入对应处理代码。
  • 显式 causeraise new from old 把底层异常写入 __cause__,适合保留错误转换证据。
  • 隐式 context:处理一个异常时产生新异常,旧异常会进入新异常的 __context__
  • 异常层级Exception 覆盖普通应用错误,SystemExitKeyboardInterruptGeneratorExit 直接位于 BaseException 下。
  • finally 收束finally 绑定到控制流出口,正常返回、异常传播和循环跳转都会先执行清理代码。
  • 栈展开:stack unwinding 会逐层退出 frame,并在退出前执行覆盖当前区域的清理逻辑。
  • 异常表:CPython 3.11+ 把异常处理范围放入 exception table,异常发生后再按表进入 handler。
  • 正常路径:zero-cost exception path 只描述未抛出异常时的 try 正常执行成本,异常路径仍要承担对象、traceback 和 handler 成本。
  • 异常组ExceptionGroup 用树形对象表达多个失败,except* 按叶子异常类型拆分处理。
  • 错误状态:active exception 属于 runtime error state,handler 内可读取,离开 handler 后会恢复到外层状态。