Chapter 17: Built-in Exceptions
内置异常是 Python runtime 对失败状态的分类接口。读完本章后,读者应能追踪一个异常对象从触发点、类型匹配、traceback 连接到上层处理的路径,并能判断某个异常属于协议失败、值域错误、查找失败、迭代结束信号还是进程级控制流。
本章以 CPython 为主要实现语境,以 Python 3.14 built-in exceptions 文档、raise statement 文档、PEP 3134 和 PEP 479 为版本相关依据。语言层面的稳定规则是:被抛出的对象必须派生自 BaseException;except SomeClass 会匹配该类及其子类;异常对象会携带参数、traceback 和链式上下文。CPython 的实现细节,例如内置异常多数由 C 类型实现、部分异常具有自定义内存布局,只作为解释工程边界的依据。
贯穿材料是一段配置读取代码。它短,但能覆盖本章需要的几个路径:mapping 查找失败会产生 KeyError,数值转换失败会产生 ValueError,直接从空 iterator 取值会暴露 StopIteration,业务层把底层异常翻译成更贴近调用方的异常时需要保留 cause。
class ConfigStore:
def __init__(self, data):
self.data = data
def read_port(self):
raw_port = self.data["port"]
return int(raw_port)
def first_plugin(plugins):
return next(iter(plugins))
def build_service(config, plugins):
try:
port = config.read_port()
plugin = first_plugin(plugins)
except LookupError as exc:
raise AttributeError("service configuration is incomplete") from exc
except ValueError as exc:
raise RuntimeError("service port must be an integer") from exc
return port, plugin
这段代码中的异常类型承担的是 runtime contract。self.data["port"] 要求对象支持 mapping lookup 且存在对应 key;int(raw_port) 要求输入能转换为整数;next(iter(plugins)) 要求 iterator 还能产出元素;raise ... from exc 要求上层异常保留底层异常作为直接 cause。后续每一节都会回到这段材料,把异常层级、常见异常族、迭代控制异常、traceback 和工程选择连接起来。
17.1 Exception hierarchy roots
异常层级的根负责定义“哪些对象可以进入异常传播路径”。Python 要求被抛出的异常对象派生自 BaseException,所以异常匹配首先是类型层级匹配,再由 except 子句决定进入哪个 handler。这个规则让异常处理成为对象系统的一部分:异常对象有类型、有属性、有继承关系,也能被用户代码创建和重新抛出。
BaseException 是所有内置异常的根。Exception 位于 BaseException 之下,承担普通应用错误的公共父类。绝大多数业务错误、库错误和可恢复 runtime 错误都应该派生自 Exception。这条边界的工程含义很直接:except Exception 适合表达“处理普通失败”,同时让退出、用户中断、generator 关闭这类控制流继续向外传播。
下面的最小层级图只覆盖本章关注的根部路径,完整层级以官方文档中的 exception hierarchy 为准。
这张图的关键点在根部。SystemExit、KeyboardInterrupt 和 GeneratorExit 直接继承 BaseException,它们表达进程退出、用户中断和 generator/coroutine 关闭路径。业务代码在中层捕获 Exception 时,通常会放过这些控制信号。只有顶层入口、任务调度器、测试框架或资源清理边界才适合捕获 BaseException,捕获后也应根据语义重新抛出或转换为明确的状态。
回到 build_service。它只捕获 LookupError 和 ValueError,因为这段代码只准备处理“配置项缺失”和“端口值格式错误”这两类可解释失败。它没有捕获 BaseException,所以用户按下中断键触发的 KeyboardInterrupt、线程外部退出路径中的 SystemExit、generator 关闭路径中的 GeneratorExit 都会继续向上层传播。
BaseExceptionGroup 和 ExceptionGroup 是 Python 3.11 引入的分组异常。它们用于表达一组并发或聚合操作中同时出现的多个异常。ExceptionGroup 同时位于 Exception 分支下,适合普通异常组合;BaseExceptionGroup 可以包含更底层的 BaseException 实例。并发运行时和任务组需要这种结构,因为单个异常对象无法完整表达多个子任务同时失败的事实。
CPython 对内置异常的实现还有一个继承边界。官方文档提示,多数内置异常由 Objects/exceptions.c 中的 C 类型实现,部分类型带有自定义内存布局;用户自定义异常推荐单继承一个异常基类。这个边界服务于可维护性:异常类型的继承关系应该表达捕获语义,复杂的多继承层级会让 args、属性布局和跨版本兼容性变得不稳定。
本节建立的判断顺序是:先确认异常是否派生自 BaseException,再确认它是否属于普通 Exception 分支,最后看它是否处在更具体的异常族中。根部层级先决定“能否被当前 handler 接住”,具体类型再决定“handler 应该如何恢复或翻译”。
17.2 Common runtime exception families
常见 runtime 异常族的分类依据是失败发生在哪种操作契约上。判断一个异常类型时,先看失败操作:属性访问、mapping/sequence 查找、数值转换、函数调用、协议分发、资源操作分别有不同的异常族。异常名中的英文含义只提供提示,真正的判断依据是 runtime 正在执行的操作边界。
TypeError 表达操作和对象类型或对象能力之间的契约失败。len(3) 会失败,因为整数对象没有提供长度协议;callable_object(unknown=1) 可能失败,因为 callable 的参数绑定契约不接受该关键字;object() + 1 会失败,因为参与对象没有提供可用的 numeric protocol 组合。TypeError 的重点是“这类对象或这组参数形状无法参与该操作”。
ValueError 表达对象类型已经合适,具体值落在操作接受范围之外。int("8080") 可以成功,因为字符串能进入整数解析路径;int("not-a-port") 会触发 ValueError,因为输入对象的类型可接受,字符串内容无法被解析为整数。在贯穿材料中,read_port 的 int(raw_port) 正是这个边界:如果 raw_port 是无法解析的字符串,上层捕获 ValueError 并翻译为端口配置错误。
AttributeError 表达属性访问或属性写入失败。普通属性读取会沿实例、类、MRO、descriptor 等路径查找结果;查找链没有产出可用属性时,runtime 会抛出 AttributeError。这个异常也经常作为协议的一部分出现,例如 getattr(obj, name, default) 依赖 AttributeError 判断属性缺失,某些动态代理会在 __getattr__ 中抛出它来表达兜底失败。
LookupError 是查找类失败的公共父类,主要覆盖 IndexError 和 KeyError。sequence 下标越界对应 IndexError,mapping key 缺失对应 KeyError。贯穿材料中的 self.data["port"] 如果面对缺少 "port" 的 dict,会产生 KeyError;build_service 捕获 LookupError 后把它翻译成 AttributeError("service configuration is incomplete"),表示对上层来说“服务配置对象缺少可用属性”。
下面的代码把四个常见异常族放在同一组观察中。它用于固定判断维度:先定位操作,再判断对象能力和值域。
def classify_runtime_failure(data):
samples = [
("type contract", lambda: len(3)),
("value contract", lambda: int(data["port"])),
("attribute contract", lambda: data.missing_name),
("lookup contract", lambda: data["missing_key"]),
]
for label, action in samples:
try:
action()
except Exception as exc:
print(label, type(exc).__name__)
如果 data 是 {"port": "bad"},第二个 action 会触发 ValueError,第四个 action 会触发 KeyError。如果 data 是普通 dict,第三个 action 会触发 AttributeError,因为 dict 的 key lookup 和 attribute lookup 是两条路径。这个差异很适合定位 bug:data["x"] 失败时看 mapping 内容,data.x 失败时看 attribute lookup 链。
常见异常族还包括 OSError、NameError、RuntimeError 和 SyntaxError。OSError 连接操作系统资源失败,例如文件、socket、权限和路径;NameError 连接未限定名字查找失败;RuntimeError 表达没有更具体类别的运行时失败;SyntaxError 来自解析和编译阶段。它们在本章不作为主线展开,但判断方法一致:定位 runtime 正在执行的操作,确认输入对象、状态和外部资源分别满足哪个契约。
异常族的工程用法可以压缩成一个检查顺序:先判断失败点属于 attribute、lookup、conversion、call、protocol、resource 还是 control-flow;再看该操作已经拿到了什么对象;接着判断对象类型、对象值、对象状态或外部资源哪一项破坏了契约。异常处理代码应该围绕这个判断顺序写窄,而不依赖错误消息字符串做分支。
17.3 Iteration and generator control exceptions
迭代相关异常的特殊之处在于它们既能表达失败,也能表达正常控制信号。StopIteration 是同步 iterator 的结束信号,StopAsyncIteration 是异步 iterator 的结束信号,GeneratorExit 是 generator 或 coroutine 被关闭时收到的退出信号。它们的语义需要放在驱动者和被驱动对象之间理解。
for 循环驱动 iterator 时,会反复调用 next()。当 next() 抛出 StopIteration,循环把它解释为“没有更多元素”,然后正常结束循环体。这个异常在 for 语句内部属于协议信号;它穿出 next() 调用点并被业务函数看到时,通常说明业务代码把迭代结束当作一个普通返回值来依赖,需要明确处理空输入。
贯穿材料中的 first_plugin 就暴露了这个边界。
def first_plugin(plugins):
return next(iter(plugins))
当 plugins 为空时,next(iter(plugins)) 会抛出 StopIteration。如果调用方期待“至少有一个插件”,这个异常已经从 iterator 协议层穿到了业务层。更稳定的写法是把空输入变成领域内的普通失败,例如抛出 LookupError、ValueError 或自定义 ConfigurationError,并用 raise ... from exc 保留原始迭代结束信号。
def first_required_plugin(plugins):
try:
return next(iter(plugins))
except StopIteration as exc:
raise LookupError("at least one plugin is required") from exc
generator 的 return value 也通过 StopIteration.value 传递给驱动方。这个机制主要服务 yield from 和底层迭代协议,普通业务代码很少直接读取它。下面的例子展示 generator 返回值如何进入异常对象属性。
def produce_once():
yield "ready"
return "done"
iterator = produce_once()
print(next(iterator))
try:
next(iterator)
except StopIteration as exc:
print(exc.value)
第一次 next(iterator) 产出 "ready"。第二次 next(iterator) 让 generator 走到 return "done",runtime 创建 StopIteration,并把 "done" 放入 exc.value。for 循环会消费这个结束信号,所以循环体里看不到 StopIteration.value;使用 yield from 时,外层 generator 可以接收子 generator 的返回值。
PEP 479 改变了 generator 内部意外抛出 StopIteration 的处理方式。Python 3.7 起,如果 StopIteration 准备从 generator frame 泄出,runtime 会把它转换成 RuntimeError,并把原来的 StopIteration 保留为 cause。这个规则保护 generator 抽象:内部某个 next() 调用意外耗尽时,外层迭代不再静默结束,而是让调用者看到错误路径。
def broken_generator():
next(iter(()))
yield "unreachable"
iterator = broken_generator()
try:
next(iterator)
except RuntimeError as exc:
print(type(exc.__cause__).__name__)
这段代码在 Python 3.7+ 中会捕获 RuntimeError,并能从 exc.__cause__ 看到原始的 StopIteration。这说明 generator 内部的 StopIteration 和 iterator 对外报告结束的 StopIteration 处在不同层级:前者是 generator frame 内部的未处理异常,后者是 iterator protocol 的合法结束信号。
GeneratorExit 的语义也属于控制信号。调用 generator 的 close() 时,runtime 会在 generator 内部抛入 GeneratorExit,让它执行清理逻辑。它直接继承 BaseException,表示这个信号用于关闭路径。generator 在清理时可以释放资源,然后让 GeneratorExit 继续完成关闭;在这个路径里继续产出普通值会破坏 close 契约并触发运行时错误。
异步迭代使用 StopAsyncIteration。async for 驱动 __anext__(),当 __anext__() 抛出 StopAsyncIteration 时,异步循环正常结束。它和 StopIteration 的判断模型相同:在驱动语句内部是结束信号,泄到业务边界时需要翻译成调用方能理解的失败状态。
迭代异常的检查顺序是:先确认异常发生在 iterator driver 内部还是穿出到业务函数;再确认它表达正常耗尽、空输入失败、generator 内部 bug 还是关闭信号;最后决定是否翻译异常并保留 cause。这个顺序能把“正常结束”和“业务失败”分开处理。
17.4 Traceback and exception chaining
异常传播携带两类信息:异常对象本身说明失败类别和附加参数,traceback 说明失败穿过哪些 frame。BaseException.__traceback__ 保存 traceback 对象;异常被抛出并沿调用栈传播时,当前 frame 会进入 traceback 链。调试时看到的栈并非独立日志,而是异常对象持有的 runtime 关系。
traceback 会连接 frame,因此它也会延长部分局部对象的生命周期。只要异常对象、traceback 或被保存的 exc 变量仍被引用,相关 frame 中的局部变量就可能继续存活。这个边界在日志系统、测试框架、异步任务聚合和长生命周期缓存中需要关注:保存异常对象等同于保存一段执行现场。
异常链解决的是“上层翻译错误时如何保留底层原因”。贯穿材料中,build_service 捕获 LookupError 后抛出 AttributeError,并使用 from exc 建立直接 cause。
try:
service = build_service(ConfigStore({}), [])
except AttributeError as exc:
print(exc)
print(type(exc.__cause__).__name__)
这段代码中,调用方看到的主异常是 AttributeError("service configuration is incomplete")。exc.__cause__ 指向原始的 KeyError 或被翻译后的 LookupError 子类。上层错误信息面向业务语义,底层 cause 保留 runtime 事实。调试器、日志系统和测试断言可以沿 __cause__ 继续定位缺失的是哪个 key 或哪个 iterator 为空。
__context__ 表示隐式上下文。一个 except、finally 或 with 清理过程中抛出新异常时,runtime 会把正在处理的旧异常放进新异常的 __context__。这个关系表达“新异常发生在处理旧异常的过程中”。它强调时间和处理现场,并不要求新异常由旧异常直接导致。
raise new_exc from original_exc 会设置 __cause__。raise new_exc from None 会抑制默认显示中的旧上下文,同时保留调试时可检查的上下文属性。这个写法适合把内部实现细节换成公开 API 的失败语义。例如库内部用 dict 查找缓存,公开接口返回 AttributeError 或自定义异常时,可以隐藏内部 key 结构,同时在需要诊断时保留对象关系。
异常显示顺序也有固定含义。默认 traceback 显示会先展示被链接的旧异常,再展示最后抛出的异常,最后一行对应当前对外传播的异常类型和值。这个顺序让 handler 匹配当前异常,同时让调试者能沿链回到根因。PEP 3134 的核心贡献就是同时保留隐式上下文和显式 cause,让异常翻译不再丢失原始失败。
Python 3.11 引入的 BaseException.add_note() 和 __notes__ 为异常对象追加诊断说明。note 适合补充运行参数、配置路径、任务名称和外部资源标识。它不改变异常类型、traceback 或 cause,只增加展示层的上下文。工程上应把“失败类别”放在异常类型里,把“失败路径”放在 traceback 和 cause 里,把“辅助诊断”放在 note 里。
本节的检查顺序是:先看最终抛出的异常类型和值,再看 __cause__ 是否说明直接原因,接着看 __context__ 是否说明处理旧异常时产生的新异常,最后沿 __traceback__ 定位 frame 顺序和局部状态。这个顺序能把用户可见语义、底层原因和执行现场分开判断。
17.5 Built-in exceptions as runtime contracts
内置异常类型的工程价值在于把 runtime contract 显式化。一个异常类型应该回答三个问题:哪个操作失败,失败属于对象能力、对象值、对象状态、外部资源还是控制信号,上层调用方能否恢复。按照这三个问题选择异常,比按照错误消息写分支更稳定。
下表把常见内置异常映射到可复用判断维度。表中的“典型上层动作”提供处理代码需要回答的恢复方向。
| 异常或异常族 | runtime contract | 典型触发点 | 典型上层动作 |
|---|---|---|---|
TypeError | 对象类型、调用形状或协议能力不满足操作要求 | len(3)、参数绑定失败、不可调用对象被调用 | 修正调用方式或拒绝该对象 |
ValueError | 对象类型可接受,具体值超出该操作值域 | int("bad")、非法枚举值、范围错误 | 报告输入值非法并给出有效范围 |
AttributeError | attribute lookup 或 assignment 没有得到可用属性 | obj.missing、代理对象兜底失败 | 检查对象模型、拼写、descriptor 或公开 API |
LookupError | 容器查找没有得到目标位置或 key | mapping[key]、sequence[index] | 检查 key/index 来源和空数据分支 |
StopIteration | 同步 iterator 正常耗尽 | next(iterator) | 在 driver 内消费;业务边界翻译成明确状态 |
GeneratorExit | generator/coroutine 关闭路径 | generator.close() | 执行清理并完成关闭 |
OSError | 操作系统或 I/O 资源返回失败 | 文件、socket、权限、路径、磁盘 | 根据 errno、路径和资源类型恢复或上报 |
RuntimeError | 当前运行状态失败且缺少更具体分类 | generator 内部 StopIteration 转换、递归限制等 | 保留上下文并补充状态说明 |
SyntaxError | 源码解析或编译阶段失败 | compile()、eval()、模块加载 | 报告源码位置并停止执行该代码块 |
贯穿材料可以改造成更稳定的 API 边界。内部仍然使用 dict、int() 和 iterator protocol,但公开边界只暴露调用方需要处理的异常类别,同时保留底层 cause。
class ConfigurationError(Exception):
pass
def build_service_boundary(config, plugins):
try:
port = config.read_port()
plugin = first_required_plugin(plugins)
except (LookupError, AttributeError, ValueError) as exc:
raise ConfigurationError("invalid service configuration") from exc
return port, plugin
ConfigurationError 派生自 Exception,表示它属于普通应用失败。它把 config 的内部结构、dict key、端口解析和插件列表耗尽统一成服务配置错误;from exc 保留 runtime 层的具体失败。调用方可以捕获 ConfigurationError 做配置回退,日志系统可以沿 __cause__ 看到 KeyError、ValueError 或 LookupError。
选择内置异常时可以按以下顺序判断。第一步,定位失败的操作表面:attribute、item lookup、conversion、call、iteration、resource、syntax、runtime state。第二步,确认对象类型是否能参与操作;类型或调用形状不合适时使用 TypeError。第三步,确认对象值是否落在合法范围;类型合适但值非法时使用 ValueError。第四步,确认查找路径;属性缺失使用 AttributeError,key/index 缺失使用 KeyError 或 IndexError。第五步,确认它是否属于正常控制信号;iterator 耗尽使用 StopIteration,generator 关闭使用 GeneratorExit。
捕获异常时也按同一顺序收窄范围。靠近失败点的位置捕获具体异常类型,因为那里知道恢复方式;模块边界可以把多个内部异常翻译成一个公开异常;顶层入口负责记录日志、释放资源和决定进程状态。宽泛捕获 Exception 应该出现在任务边界、请求边界或测试断言边界,并且需要保留原始异常链。
内置异常还承担 API 兼容性。一个函数长期承诺“缺失 key 抛出 KeyError”或“非法值抛出 ValueError”后,调用方会围绕这个契约编写恢复逻辑。随意改成另一个异常类型会改变 handler 匹配结果,即使错误消息相同也会破坏调用方判断。因此异常类型属于公开行为的一部分,尤其在库、框架、插件系统和命令行工具中需要稳定维护。
本章最终建立的模型是:异常类型表达失败契约,traceback 表达执行现场,cause/context 表达翻译关系,异常层级表达捕获边界。读 Python 异常代码时,先看操作契约,再看异常类型,接着看传播链,最后看上层恢复动作。这个顺序能把语法表面的 raise 和 runtime 内部的对象、frame、协议、状态变化连接起来。
最小自检任务
阅读下面代码,判断 run({}) 和 run({"port": "bad"}) 分别向调用方暴露什么异常类型,并说明如何沿异常链找到底层原因。
class ConfigurationError(Exception):
pass
class ConfigStore:
def __init__(self, data):
self.data = data
def read_port(self):
return int(self.data["port"])
def run(data):
try:
return ConfigStore(data).read_port()
except (LookupError, ValueError) as exc:
raise ConfigurationError("invalid config") from exc
答案要点
run({}) 对调用方暴露 ConfigurationError。底层失败发生在 self.data["port"],dict 查找缺少 "port" 时触发 KeyError,KeyError 属于 LookupError 子类,所以被 except (LookupError, ValueError) 捕获。raise ConfigurationError(...) from exc 把 KeyError 放进新异常的 __cause__,调用方可以通过 caught.__cause__ 看到底层查找失败。
run({"port": "bad"}) 对调用方同样暴露 ConfigurationError。底层失败发生在 int(self.data["port"]),字符串对象能进入整数解析路径,但值无法解析为整数,因此触发 ValueError。handler 捕获它并把它作为 cause 连接到 ConfigurationError。这两个输入的公开异常类型一致,底层 cause 不同,说明 API 边界把内部 runtime contract 翻译成统一配置错误,同时保留诊断路径。
判断步骤是:先看公开边界最后抛出的异常类型;再看 handler 捕获了哪些内置异常族;接着定位底层操作是 mapping lookup 还是 value conversion;最后沿 __cause__ 找到原始异常。__traceback__ 负责说明异常穿过哪些 frame,__cause__ 负责说明公开异常由哪个底层异常直接引发。
本章知识点总结
- 异常对象:Python 抛出的对象必须派生自
BaseException,异常处理依赖对象类型和继承关系匹配 handler。 - 普通错误:
Exception分支承载大多数可恢复应用失败,用户自定义异常通常派生自它。 - 控制信号:
SystemExit、KeyboardInterrupt和GeneratorExit直接位于BaseException分支,用于进程退出、中断和关闭路径。 - 类型契约:
TypeError表达对象类型、调用形状或协议能力无法参与当前操作。 - 值域契约:
ValueError表达对象类型已经适合当前操作,但具体值超出操作接受范围。 - 属性契约:
AttributeError表达 attribute lookup 或 attribute assignment 没有得到可用属性。 - 查找契约:
LookupError统领KeyError和IndexError,表达 mapping 或 sequence 查找失败。 - 迭代结束:
StopIteration是同步 iterator 的结束信号,for循环会消费它,业务边界需要显式处理空输入。 - Generator 边界:Python 3.7+ 会把 generator 内部泄出的
StopIteration转换成RuntimeError,并保留原异常作为 cause。 - 异常现场:
__traceback__连接异常传播经过的 frame,也可能延长局部对象生命周期。 - 异常链:
__cause__表示显式直接原因,__context__表示处理旧异常时出现的新异常上下文。 - API 契约:公开函数暴露的异常类型属于调用方可依赖行为,翻译异常时应保留底层 cause。
- 判断顺序:阅读异常代码时,先定位操作契约,再判断异常族,接着检查 traceback 和 cause,最后确认上层恢复动作。