学习目标:建立"异常 = 对象"的心智模型(类型 + 参数 + 回溯);掌握
try/except/else/finally四件套与异常链(raise ... from);理解 EAFP 哲学与"该吞还是该抛"的决策;掌握with协议的完整语义与contextlib工具箱,能写出确定性地获取/释放资源的代码——最终让程序"出错可预期、资源不泄漏"。
异常处理与上下文管理器,是 Python 回答两个根本问题的机制:"程序出错时,错误信号如何产生、传播、被处理?" 以及 "资源(文件、连接、锁)如何保证被确定性释放?"。这两个问题看似独立,实则共享同一套底层设施——try/finally 是 with 的语法内核,with 又是异常语义的精确化。因此本章把两者放在一起讲。
本章与前面章节的关系:第 2 章 2.9 讲了 assert(raise 的调试形态),第 5 章的迭代器协议靠 StopIteration 异常做控制流,第 6 章函数返回值是"正常通道"——本章补上它的"异常通道";第 7 章 7.7.7 已经用 Timer 预览了 with 协议,7.7.2 生命周期又强调"资源清理交给 with 而非 __del__"——本章把这两个伏笔完整兑现。附录 A 对 ExceptionGroup(PEP 654)与零成本异常的版本条目,本章展开其语义与用法。
程序出错时怎么报告? 不同的语言给出了截然不同的答案:
// C:返回错误码,调用方必须手动检查
int fd = open("config.json", O_RDONLY);
if (fd == -1) { // errno 存了错误细节
fprintf(stderr, "open failed: %s\n", strerror(errno));
return -1;
}// Go:返回 (值, error),错误是普通值
f, err := os.Open("config.json")
if err != nil {
return nil, err // 错误必须显式传递
}# Python:抛出异常对象——不用检查返回值,出错时"直接打断"
>>> with open("config.json") as f: # 文件不存在时,open 抛 FileNotFoundError
... data = f.read()
Traceback (most recent call last):
...
FileNotFoundError: [Errno 2] No such file or directory: 'config.json'三者的本质差异在于错误信号的传递路径:
| 语言 | 错误信号 | 传递方式 | 漏检后果 |
|---|---|---|---|
| C | 错误码(int + errno) |
返回值手动传递 | 忘记检查 → 静默失败 |
| Go | error 值 |
返回值显式传递 | 忘记检查 → 静默失败(_ 丢弃) |
| Python | 异常对象 | 调用栈自动向上传播 | 无人捕获 → 打印 traceback 并终止 |
Python 的关键设计:异常不占用返回值通道。函数可以放心地返回"结果值",出错时异常自动沿调用栈向上找处理器——调用链中间的每一层都不必为"转发错误"写代码(对比 Go 的层层 if err != nil)。代价是:错误是隐式流动的,你无法从函数签名看出它可能抛什么(第 12 章的类型标注可缓解)。
"异常也是对象"。 Exception 是一个普通类,异常实例携带错误信息。两个关键部件:
>>> e = ValueError("金额必须为正")
>>> type(e) # 类型:决定能被哪个 except 捕获
<class 'ValueError'>
>>> e.args # 参数:构造时传入的信息元组
('金额必须为正',)
>>> str(e) # 打印时显示的消息
'金额必须为正'衔接
assert(第 2 章 2.9):assert cond, msg是if not cond: raise AssertionError(msg)的语法糖。差别在运行时:python -O下assert会被整个删除,而raise不会。所以assert只用于调试期的不变量检查,正式的错误检查用显式的raise。
Python 的异常是一个类继承树,根是 BaseException。这棵树决定了"捕获的粒度":
BaseException # 一切异常的根
├── BaseExceptionGroup(3.11+) # 异常组(8.2.5 展开)
├── GeneratorExit # 生成器被 close() 时抛出(第5章)
├── KeyboardInterrupt # Ctrl+C——用户想中断,不是 bug
├── SystemExit # sys.exit()——程序主动退出
└── Exception # ★ 常规错误的父类
├── ArithmeticError
│ └── ZeroDivisionError
├── AssertionError
├── AttributeError
├── LookupError
│ ├── IndexError
│ └── KeyError
├── OSError
│ └── FileNotFoundError
├── RuntimeError
│ └── RecursionError
├── StopIteration # 迭代器"用尽"信号(控制流!)
├── TypeError
└── ValueError
三个直接挂在 BaseException 下的类型,都不属于 Exception——这是刻意的设计:
>>> try:
... 1 / 0
... except Exception: # ✅ 捕获"常规错误"
... pass
>>> try:
... raise KeyboardInterrupt() # ❌ KeyboardInterrupt 不在 Exception 下
... except Exception: # 所以这里捕获不到!
... print("捕获了?")
KeyboardInterrupt为什么? SystemExit/KeyboardInterrupt/GeneratorExit 代表的不是"代码里的 bug",而是"需要立刻退出的信号"——程序被要求终止、用户按了 Ctrl+C、生成器被 close()。如果把它们归入 Exception,那么"捕获一切常规错误的 except Exception"就会把这些退出信号一并吞掉,导致程序该退退不了(比如后台任务想优雅退出,却被一个宽泛的 except 拦住)。这直接引出一个最重要的实践规则:
⚠️ 陷阱——永远不要用裸except::>>> try: ... 1 / 0 ... except: # ❌ 捕获一切,含 KeyboardInterrupt/SystemExit ... pass裸
except:等价于except BaseException:。它会吞掉KeyboardInterrupt(用户按 Ctrl+C 无效)、SystemExit(程序退不出去),制造"杀不死"的程序。正确的默认是except Exception:——只捕获常规错误,放行退出信号。
常见异常速查表(写代码时最常遇到的十来个):
| 异常 | 触发场景 | 典型修复 |
|---|---|---|
ValueError |
参数值不合法(int("abc")) |
先校验再转换 |
TypeError |
类型不匹配/参数个数错(1 + "a") |
检查类型或调用签名 |
KeyError |
字典键不存在(d["x"]) |
用 d.get("x", 默认) |
IndexError |
序列下标越界(lst[10]) |
检查 len() 或用 try |
AttributeError |
属性不存在(obj.xxx) |
hasattr 或补属性 |
ZeroDivisionError |
除零 | 先判断除数 |
FileNotFoundError |
打开不存在的文件 | os.path.exists 或 try |
StopIteration |
迭代器耗尽(next(it)) |
用 for 或 next(it, 默认) |
RecursionError |
递归太深 | 检查终止条件 |
ImportError/ModuleNotFoundError |
导入失败 | 装依赖/查路径 |
StopIteration 是"用异常做控制流"的合法代表。 迭代器协议(第 5 章)要求 __next__ 在耗尽时抛出 StopIteration 来告诉 for 循环"该结束了"——这是"异常用于控制流"最正当的场合,因为它简洁地表达了"正常路径的终止信号"。除此之外,用异常做流程控制都是反模式(8.3.2 详谈)。
raise 的三种形态:
>>> raise ValueError("金额必须为正") # 形态一:raise 一个实例(最常用)
Traceback (most recent call last):
...
ValueError: 金额必须为正
>>> raise ValueError # 形态二:raise 一个类——自动实例化
Traceback (most recent call last):
...
ValueError
>>> def f():
... raise ValueError("内部错误")
>>> try:
... f()
... except ValueError:
... raise # 形态三:裸 raise = 原样重新抛出
Traceback (most recent call last):
...
ValueError: 内部错误裸 raise 只在 except 块内合法,它的语义是"把当前正在处理的异常原样重新抛出"——保留原始回溯,不新建异常对象。这在"记录日志后继续传播"的场景里是标配:
>>> import logging
>>> def process(data):
... try:
... return data["name"]
... except KeyError:
... logging.exception("处理数据时缺字段")
... raise # 记完日志,原样往上抛自定义异常:继承 Exception。 定义自己的异常类型,是为了让调用方精确捕获——捕获 ValueError 的代码可能误伤库内部逻辑,捕获你自己的 BizError 则是精准的契约:
class BizError(Exception):
"""业务异常:携带错误码与消息"""
def __init__(self, code: int, message: str):
self.code = code # 结构化字段
super().__init__(message) # Exception 的 args 存 message
>>> try:
... raise BizError(42, "订单状态非法")
... except BizError as e:
... print(e.code, e) # 42 订单状态非法
42 订单状态非法实战建议:自定义异常命名以
Error/Exception结尾;一定要继承Exception,而不是BaseException(否则会误伤退出信号);基类调用super().__init__(message)把消息存进args,保证str(e)和日志可用。复杂的自定义异常体系设计见 8.3.3。
异常对象长什么样? 一个异常对象由三部分构成:类型(决定了能被谁捕获)、参数(args,构造时传入的信息)、回溯(__traceback__,记录"一路抛上来"的调用位置链):
>>> def inner():
... raise ValueError("boom")
>>> def outer():
... inner()
>>> try:
... outer()
... except ValueError as e:
... tb = e.__traceback__
>>> tb.tb_frame.f_code.co_name # 最近的帧:inner
'inner'
>>> tb.tb_next.tb_frame.f_code.co_name # 上一帧:outer
'outer'__traceback__ 是一个链表:每个节点对应栈上的一层调用帧,从抛出点一路指回 try 所在帧。解释器打印的错误报告正是沿着这条链渲染的:
Traceback (most recent call last):
File "<stdin>", line 2, in outer ← 链上第 2 层
File "<stdin>", line 2, in inner ← 链上第 1 层(抛出点)
ValueError: boom
raise 的字节码。 raise X(msg) 编译为两段——先构造异常对象(LOAD_GLOBAL + CALL),再用 RAISE_VARARGS 抛出:
>>> import dis
>>> def f():
... raise ValueError("boom")
>>> dis.dis(f)
25 LOAD_GLOBAL 1 (ValueError + NULL)
LOAD_CONST 0 ('boom')
CALL 1 # 构造异常实例
RAISE_VARARGS 1 # 抛出(1 = 带参数)零成本异常处理(Python 3.11+)。 在 3.11 之前,try 块会在字节码里插入 SETUP_FINALLY,解释器对每一帧都要维护"当前 try 栈"——即使从不抛异常,每次进入函数都有成本。3.11 的零成本异常处理(zero-cost exception handling)改成了把异常处理器的映射关系抽离到独立的 ExceptionTable,字节码的"正常路径"里完全没有异常检查指令:
>>> def safe_div(x):
... try:
... return 10 // x
... except ZeroDivisionError:
... return -1
>>> dis.dis(safe_div)
6 NOP
7 L1: LOAD_SMALL_INT 10
LOAD_FAST_BORROW 0 (x)
BINARY_OP 2 (//)
L2: RETURN_VALUE # 正常路径:零异常检查指令!
-- L3: PUSH_EXC_INFO # 异常路径:由 ExceptionTable 跳转而来
8 LOAD_GLOBAL 0 (ZeroDivisionError)
CHECK_EXC_MATCH # 检查异常类型是否匹配
...
L4: POP_EXCEPT
RETURN_VALUE
ExceptionTable:
L1 to L2 -> L3 [0] # 字节码区间 [L1, L2) 内的异常 → 跳到 L3 处理关键点:正常执行时,解释器根本不会去看 ExceptionTable——只有真正的异常发生,才按表跳转。这让 try 块的开销趋近于零(详见 8.3.4 的性能实测)。
版本注意:异常处理器的形态随版本演进——3.11 引入零成本异常处理与
ExceptionTable;3.12 起特殊方法的字节码由LOAD_METHOD改为LOAD_SPECIAL(with的__enter__/__exit__调用即如此,见 8.4.2)。"异常发生时沿表跳转"的结论在所有 3.11+ 版本一致,字节码细节随版本变化。
传播的查找过程。 一个异常被 raise 后,解释器做两件事:先看当前帧有没有能匹配的处理器(查 ExceptionTable),没有就销毁当前帧(栈帧弹出)、把异常交给调用帧继续找——直到某帧匹配,或栈底无处理,此时打印 traceback 并退出解释器。理解这一点,"异常成本主要花在构造回溯上"(8.3.4)就好懂了:传播本身是常数级的,贵的是把每一帧的上下文记录进 __traceback__。
try 语句有四个子句,各司其职。用一句话记住各自的时机:
| 子句 | 时机 |
|---|---|
try |
受保护的代码块 |
except |
抛异常时执行(按声明顺序匹配) |
else |
没有异常时执行 |
finally |
无论是否异常都执行(清理) |
基本形态——捕获特定的异常类型:
>>> try:
... n = int("abc") # 抛 ValueError
... except ValueError:
... n = 0 # 处理:给个默认值
>>> n
0多 except 按声明顺序自上而下匹配——写在前面的优先,命中后不再继续:
>>> try:
... int("abc")
... except TypeError: # 先匹配 TypeError
... print("类型错误")
... except ValueError: # 再匹配 ValueError——这次命中
... print("值错误")
值错误
⚠️ 陷阱——捕获顺序:子类必须写在父类前面,否则父类把子类截胡。因为ZeroDivisionError是ArithmeticError的子类(8.1.2 的树),下面的写法永远走不到ZeroDivisionError:>>> try: ... 1 / 0 ... except ArithmeticError: # ❌ 父类在前,捕获一切算术错误 ... print("算术错误") ... except ZeroDivisionError: # 这一行是死代码 ... print("除零") 算术错误
元组捕获——多个不相关的类型一起处理:
>>> try:
... d = {"name": "alice"}; name = d["age"]
... except (KeyError, IndexError) as e: # 一个处理器管多个类型
... print("取不到:", e)
取不到: 'age'as 绑定异常对象——上面用 as e 拿到异常实例,以便读取 e.args 或自定义字段。注意作用域:except ... as e: 块结束后,e 在 Python 3 里会被自动删除(del e),防止循环引用导致的内存泄漏:
>>> try:
... 1 / 0
... except ZeroDivisionError as e:
... pass
>>> e # ❌ NameError——except 块外 e 已被清理
Traceback (most recent call last):
...
NameError: name 'e' is not defined未捕获的异常如何传播——沿调用栈向上找处理器,层层都没有就终止程序:
>>> def inner():
... raise KeyError("缺 key")
>>> def middle():
... inner() # 不处理,继续往上抛
>>> def top():
... try:
... middle()
... except KeyError as e: # 在 top 这一层被捕获
... print("在顶层处理:", e)
>>> top()
在顶层处理: 缺 keyelse 子句在 try 块成功完成、没有抛异常 时执行。它的价值是精确划定"受保护代码"的边界:
>>> try:
... data = load_data() # ① 可能抛 IOError——需要保护
... except IOError as e:
... print("读取失败:", e)
... else:
... print("读取成功,共", len(data), "条") # ② 只有成功才走到这为什么要用 else 而不是把 ② 直接写进 try?因为写进 try 的代码出错也会被 except 捕获——如果 ② 本身抛了个 IOError,你会错误地把"后处理代码的 bug"当成"读取失败"。else 把这层误伤隔开:
>>> try:
... data = load_data()
... process(data) # ❌ process 里的 IOError 也被当成"读取失败"
... except IOError:
... print("读取失败")实战建议:
try块只放"真的需要被except保护的代码"。后续处理放进else——既隔离误伤,又让读者一眼看清"哪些代码可能被捕获、哪些不会"。
finally 是清理钩子:无论 try 正常完成、还是抛异常、还是被 return/break/continue 跳出,finally 里的代码都保证执行:
>>> def f():
... try:
... return "try 的返回值"
... finally:
... print("finally 总是执行")
>>> f()
finally 总是执行
'try 的返回值'finally 用于释放资源——文件、锁、网络连接。它是 with 语句的语法内核(8.4 展开),在 with 出现前的 Python 2.x 里,人们正是这样写:
# 老式写法:try/finally 手动释放(现在用 with)
f = open("data.txt")
try:
data = f.read()
finally:
f.close() # 无论读写是否出错,都关文件
⚠️ 陷阱——finally里的return会覆盖一切。finally中的return会把 try 的返回值、甚至正在传播的异常都覆盖掉(编译器会发SyntaxWarning):>>> def f(): ... try: ... return "try" ... finally: ... return "finally" # ❌ 覆盖了 try 的 return >>> f() 'finally'
finally里不要写return——它本应是"清理"的钩子,混入返回值语义会让控制流难以预测。同理,finally里也不该抛异常(会覆盖正在处理的异常)。
finally 与 return 的执行顺序——return 先求值,再执行 finally,最后真正返回:
>>> def f():
... try:
... return expensive() # ① 先算出返回值
... finally:
... cleanup() # ② 再执行清理
>>> # ③ 最后把 ① 的值返回捕获一个异常、再抛出一个新异常是常见操作(翻译层、包装层)。问题是:新异常与原始异常的因果关系如何保留?Python 用三个字段记录:
| 字段 | 含义 | 显示 |
|---|---|---|
__context__ |
隐式上下文:在 except 里抛新异常时自动记录"正在处理谁" |
显示 "During handling..." |
__cause__ |
显式原因:raise X from Y 里明确指定的 Y |
显示 "The above exception was the direct cause" |
__suppress_context__ |
True 时隐藏 __context__(from None 会设置它) |
不显示上下文 |
默认的隐式链——在 except 块里抛新异常,旧异常自动成为 __context__:
>>> def wrapper():
... try:
... 1 / 0
... except ZeroDivisionError:
... raise ValueError("计算失败") # 隐式链接原异常
>>> wrapper()
Traceback (most recent call last):
File "...", line 3, in wrapper
raise ValueError("计算失败")
ValueError: 计算失败
During handling of the above exception, another exception occurred: ← __context__
Traceback (most recent call last):
File "...", line 2, in wrapper
1 / 0
ZeroDivisionError: division by zeroraise X from Y——显式指定因果。from 把 Y 设为 __cause__,打印效果更明确("这个才是直接原因"):
>>> def wrapper():
... try:
... int("abc")
... except ValueError as e:
... raise TypeError("需要数字") from e # 显式因果raise ... from None——切断上下文。有时你想把底层异常完全隐藏(不暴露内部实现细节,或底层错误对调用方毫无信息量):
>>> def public_api():
... try:
... _internal() # 内部细节
... except InternalError:
... raise PublicError("操作失败") from None # 不暴露内部异常注意一个精确的语义:
from None做的是设置__suppress_context__ = True(隐藏显示)并把__cause__设为None——但__context__字段仍然记录了原异常。所以from None是"不展示因果",而不是"抹掉因果":>>> try: ... 1 / 0 ... except ZeroDivisionError: ... raise ValueError("bad") from None >>> # e.__context__ 仍是 division by zero,只是不显示
实战建议:三层原则——① 包装异常时默认保留链(不写
from也行,隐式链已在);② 要强调直接原因用raise X from Y;③ 只有确认"底层异常对调用方是噪音"时才用from None。日志系统(8.3.5)与调试时,异常链是无价线索,别轻易切断。
问题:并发/批处理场景下,一次操作可能同时产生多个不相关的错误(十个任务,五个失败)。传统的"抛一个异常"只能报告其中一个。PEP 654 引入 ExceptionGroup:一个容器异常,可以打包任意多个子异常:
>>> eg = ExceptionGroup("批处理失败", [ValueError(1), TypeError(2), ValueError(3)])
>>> eg
ExceptionGroup('批处理失败', [ValueError(1), TypeError(2), ValueError(3)])except* 按类型匹配子组——except* 会把组里匹配某类型的子异常成组取出,剩下的留在组里继续匹配:
>>> try:
... raise ExceptionGroup("batch", [ValueError(1), TypeError(2)])
... except* ValueError as eg:
... print("值错误组:", list(eg.exceptions))
... except* TypeError as eg:
... print("类型错误组:", list(eg.exceptions))
值错误组: [ValueError(1)]
类型错误组: [TypeError(2)]except* 与普通 except 的规则差异:
except |
except* |
|
|---|---|---|
| 匹配对象 | 单个异常 | 异常组中的子组 |
| 多个子句 | 第一个匹配即停 | 每个子句匹配自己的子组,可多次命中 |
与 else/finally |
可用 | 可用 |
| 嵌套组 | 支持(类型传播) | 支持(结构保留) |
典型场景:asyncio.gather、concurrent.futures 会把多个任务的结果聚合,失败时抛 ExceptionGroup(第 11 章并发展开)。手动创建组也常用于"收集所有校验错误":
>>> errors = []
>>> for field, value in {"name": "", "age": "abc"}.items():
... if not value:
... errors.append(ValueError(f"{field} 不能为空"))
>>> if errors:
... raise ExceptionGroup("校验失败", errors)
Traceback (most recent call last):
...
ExceptionGroup: 校验失败 (1 sub-exception)版本注意:
ExceptionGroup/except*是 3.11 新增(PEP 654)。3.12 起内置ExceptionGroup可被except*之外的普通except ExceptionGroup整体捕获(作为单个对象处理)。若需兼容 3.11 之前的版本,标准库exceptiongroup(import exceptiongroup as eg)提供等价实现。
处理"可能出错的操作",有两种截然相反的姿势:
LBYL(Look Before You Leap,先看再跳)——操作前先检查所有可能的失败条件:
>>> if os.path.exists(path): # 先检查
... with open(path) as f: # 再执行
... data = f.read()EAFP(Easier to Ask Forgiveness than Permission,请求宽恕比请求许可容易)——直接做,出错再捕获:
>>> try: # 直接干
... with open(path) as f:
... data = f.read()
... except OSError: # 失败了再说
... data = NonePython 社区强烈倾向 EAFP,理由有三:
- 检查与执行之间的竞态(TOCTOU)。LBYL 的检查通过后,条件可能已经改变——
os.path.exists(path)返回True之后、open之前文件被删了,照样抛异常。EAFP 不做"事前的许诺",天生免疫这种窗口。 - 检查成本翻倍。LBYL 要为每个操作多写一套"预检",且预检的失败模式未必与真实操作一致(
exists通过了,open却可能因为权限失败)。EAFP 只需一次try就能覆盖所有失败模式。 - 与鸭子类型(7.6.2)同源。鸭子类型"不检查它是什么,只调用它做什么",EAFP"不检查能不能做,做了再说"——两者共享同一个哲学:行为主义优于本质主义。
什么时候 LBYL 合理?
| 场景 | 选择 | 原因 |
|---|---|---|
| 操作有明显副作用,失败会产生脏状态 | LBYL | 先验再干,避免做一半留残局 |
| 性能极度敏感、异常路径会被高频触发 | LBYL | 抛异常要构造回溯(8.3.4) |
| 检查比操作便宜得多,且操作几乎必失败 | LBYL | 如"用户输入是否为空" |
| 常规 I/O、解析、网络 | EAFP | 竞态免疫 + 覆盖全面 |
记忆:EAFP 是默认,LBYL 是性能/副作用驱动的特例。判断口诀:问自己"这个操作失败是常态还是意外?"——常态失败(输入校验)用 LBYL,意外失败(I/O、格式)用 EAFP。
异常处理最大的坑,不是语法,而是决策。四个经典反模式:
反模式 1:裸 except: 吞一切。(8.1.2 已讲)except: 吞掉 KeyboardInterrupt/SystemExit,程序"杀不死"。正确:except Exception:。
反模式 2:吞异常后无声(silent failure)。 except 里只有一个 pass,错误信息彻底消失——这是最难排查的 bug 来源:
>>> try:
... save_to_db(record)
... except DBError:
... pass # ❌ 失败?谁也不知道,包括用户>>> try:
... save_to_db(record)
... except DBError:
... logger.error("保存失败", exc_info=True) # ✅ 至少记录;需要重试就 raise反模式 3:捕获过宽。 except Exception: 一把抓,把 KeyboardInterrupt 之外的所有错误都当成"预期的失败",包括程序 bug(AttributeError、IndexError)——于是真正的逻辑错误被当作"可忽略的运行时异常"吞掉:
>>> try:
... process(data)
... except Exception: # ❌ 连代码 bug 都吞了
... print("出错了") # process 内部拼写错误也走这>>> try:
... process(data)
... except (OSError, ValueError) as e: # ✅ 只捕获"已知可能发生"的类型
... print("预期失败:", e)反模式 4:用异常做常规流程控制。 把 try/except 当 if/else 用(StopIteration 除外):
>>> # ❌ 用异常判断"键在不在"
>>> try:
... v = d["name"]
... except KeyError:
... v = "default">>> # ✅ 用 dict.get——意图直白、更快(8.3.4)
>>> v = d.get("name", "default")该吞还是该抛的决策树:
这个异常,调用方"预期"吗?
├─ 预期且能恢复 → 捕获并处理(返回默认值、重试、提示)
├─ 预期但不能在此恢复 → 记日志后 raise(或 raise ... from)
└─ 不预期(是 bug)→ 不要捕获,让它暴露、让测试抓住
实战建议:捕获异常的宽度要精确匹配已知可能发生的类型;处理不了的异常别拦住,让调用链上层或调试器看到它。一个有用的自检:如果你的
except块里没有return/raise/logger,它大概率是反模式 2。
当业务足够复杂,需要一个"异常家族"。设计原则:
① 定一个模块级基类,所有业务异常继承它。 这样调用方只需 except YourBaseError: 就能捕获全部业务异常:
class OrderError(Exception): # 模块基类
"""所有订单相关异常的父类"""
class OrderNotFound(OrderError):
def __init__(self, order_id):
self.order_id = order_id
super().__init__(f"订单 {order_id} 不存在")
class InvalidStatus(OrderError):
def __init__(self, order_id, status):
self.order_id = order_id
self.status = status
super().__init__(f"订单 {order_id} 状态 {status} 非法")② 异常携带结构化字段(不只是字符串消息),让调用方程序化处理:
>>> try:
... raise OrderNotFound("A123")
... except OrderError as e:
... if isinstance(e, OrderNotFound):
... print("重定向到:", e.order_id) # 访问结构化字段
... else:
... raise
重定向到: A123③ 与 enum(第 7 章)结合——错误码枚举,替代散落的魔法数字:
class ErrorCode(Enum):
NOT_FOUND = 404
INVALID = 422
CONFLICT = 409
class APIError(Exception):
def __init__(self, code: ErrorCode, message: str):
self.code = code # 类型安全的错误码
super().__init__(message)
>>> raise APIError(ErrorCode.NOT_FOUND, "资源不存在")反模式:为每个方法都造一个异常(UserSaveError、UserLoadError...类爆炸)。粒度应该对着"调用方要区分什么",而不是"每个操作一个"。绝大多数模块两三个异常就够:一个基类 + 少数几个需要单独捕获的。
两个性能事实,决定你该怎么写:
① try 块本身几乎零成本。 3.11 的零成本异常处理(8.1.4)让"进入/退出 try 块"没有指令开销——ExceptionTable 是静态元数据,正常路径根本不查它。用 timeit 实测:被 try 包裹与不包裹的正常代码,耗时几乎无差别(本机 3.14 实测仅约 6% 的开销,且函数体越大、占比越低):
>>> import timeit
>>> def no_try():
... return 1 + 1
>>> def with_try():
... try:
... return 1 + 1
... except Exception: # 从不触发
... return -1
>>> timeit.timeit(no_try, number=1_000_000) # 裸代码
0.0152
>>> timeit.timeit(with_try, number=1_000_000) # 套 try(3.11+)
0.0162版本注意:3.11 之前,
try块有可见开销(SETUP_FINALLY每帧维护),高频函数里滥套try确实会慢。3.11 起这个顾虑基本消除——"用try不用怕慢"成立。
② 抛异常很贵——贵在构造回溯。 异常一旦抛出,解释器要沿调用栈记录每一帧的上下文(8.1.4 的 __traceback__ 链)。实测对比:
>>> def via_value(n):
... if n < 0:
... return None # 哨兵值
... return n * 2
>>> def via_raise(n):
... try: # ✅ 必须捕获,否则 timeit 会崩溃
... if n < 0:
... raise ValueError("负值")
... return n * 2
... except ValueError:
... return None
>>> # 每次都触发失败路径,测 20 万次
>>> timeit.timeit("via_value(-1)", number=200_000, globals=globals())
0.0045
>>> timeit.timeit("via_raise(-1)", number=200_000, globals=globals())
0.0203同样是"失败",走异常路径慢了约 4~5 倍(本机 3.14 实测)。且这个差距会随调用栈深度增大——回溯要记录的帧越多,构造越贵(8.1.4 的 __traceback__ 链)。结论:
"预期会发生"的失败(常态分支)用返回值/哨兵值/
dict.get;"不该发生"的失败(异常路径)才用异常。 这正是 8.3.2 反模式 4 的性能依据——把异常当if用,等于每个"条件分支"都付一次回溯构造的代价。
处理异常时,有时需要拿到"当前正在处理的异常"做程序化检查:
sys.exception()(Python 3.11 推荐)vs sys.exc_info()(旧):
>>> import sys
>>> def f():
... try:
... 1 / 0
... except ZeroDivisionError:
... e = sys.exception() # 3.11+:直接返回当前异常(或 None)
... old = sys.exc_info() # 旧:返回三元组 (type, value, traceback)
... return e, old
>>> e, old = f()
>>> e
ZeroDivisionError('division by zero')
>>> old[0] is ZeroDivisionError, old[1] is e, old[2] is e.__traceback__
(True, True, True)traceback 模块——把回溯变成字符串(日志、上报用):
>>> import traceback
>>> try:
... 1 / 0
... except ZeroDivisionError:
... msg = traceback.format_exc() # 完整的 traceback 文本
>>> print(msg.splitlines()[-1]) # 最后一行是异常本身
ZeroDivisionError: division by zerologging.exception()——异常与日志的最佳搭档。在 except 块里调用,它会自动附带当前异常的完整回溯:
>>> import logging
>>> try:
... process(data)
... except ValueError:
... logging.exception("处理失败") # 日志里自动包含 traceback
ERROR:root:处理失败
Traceback (most recent call last):
...
ValueError: ...实战建议:日志里记录异常用
logging.exception(或logger.error(msg, exc_info=True)),它会带上__traceback__——这是事后排障的唯一线索。别只print(str(e)),那会丢掉调用栈。
with 是 Python 回答"资源如何确定性释放"的答案(PEP 343,Python 2.5)。看它如何把 8.2.3 的 try/finally 压缩成一行:
# 老式:try/finally 手动释放
f = open("data.txt")
try:
data = f.read()
finally:
f.close()
# with:资源的获取与释放成对出现
with open("data.txt") as f:
data = f.read() # 无论内部是否异常,f.close() 都保证执行with 的三步执行流程:
with CM() as r:
body
等价于:
enter_result = CM().__enter__() # ① 调用 __enter__,返回值绑定给 as 变量 r
try:
body # ② 执行代码块
finally:
CM().__exit__(...) # ③ 无论②是否异常,都调用 __exit__
三个要点:
__enter__的返回值绑定给as变量——注意绑定的是"返回的资源",不一定是 CM 实例本身(open的__enter__返回的就是文件对象)。__exit__在三种情况下都被调用:正常完成、抛异常、return/break提前离开。with比手写try/finally更强——__exit__能拿到异常信息(8.4.2),可以决定吞掉还是传播;手写finally做不到"根据异常类型决策"。
设计哲学:
with是 RAII(Resource Acquisition Is Initialization) 思想在 Python 的形态——C++ 靠析构函数保证资源释放,Python 靠with的词法作用域保证。差别在于:C++ 的析构时机由编译器决定,Python 的with把"何时释放"显式写在代码里——更啰嗦,但更可预测(这正是 7.7.2 说的"资源清理用with而非__del__")。
一个对象要支持 with,只需实现两个方法:
| 方法 | 签名 | 返回值语义 |
|---|---|---|
__enter__(self) |
无参数(除了 self) |
返回值绑定给 as 变量 |
__exit__(self, exc_type, exc_val, exc_tb) |
三个异常参数 | 返回真值 = 吞掉异常;None/False = 异常继续传播 |
__exit__ 的三个参数是异常处理的关键:
>>> class Reporter:
... def __enter__(self):
... return self
... def __exit__(self, exc_type, exc_val, exc_tb):
... if exc_type is None:
... print("正常退出")
... else:
... print(f"异常退出: {exc_type.__name__}: {exc_val}")
... return False # 不吞异常,让它继续传播
>>> with Reporter():
... print("执行体")
执行体
正常退出
>>> with Reporter():
... raise ValueError("boom") # 执行体抛异常
异常退出: ValueError: boom
Traceback (most recent call last):
...
ValueError: boom # 因为返回了 False,异常继续传播参数含义:
exc_type:异常类型(如ValueError);正常退出时为Noneexc_val:异常实例(e.args可读)exc_tb:回溯对象
返回真值 = 吞异常——把异常"消化"在 with 里,不让它继续传播:
>>> class Suppressor:
... def __enter__(self): return self
... def __exit__(self, exc_type, exc_val, exc_tb):
... return exc_type is ValueError # 只吞 ValueError,其余放行
>>> with Suppressor():
... raise ValueError("被吞了") # 不打印 traceback
>>> with Suppressor():
... raise TypeError("放行") # 返回 False → 继续传播
Traceback (most recent call last):
...
TypeError: 放行字节码:with 如何编译? 用 3.14 反汇编一个 with(3.12+ 用 LOAD_SPECIAL 取特殊方法,3.11 是 LOAD_METHOD):
>>> import dis
>>> def f2():
... with CM() as r:
... return r
>>> dis.dis(f2)
18 LOAD_GLOBAL 1 (CM + NULL)
CALL 0 # 创建 CM 实例
LOAD_SPECIAL 1 (__exit__) # 3.12+ 取 __exit__
LOAD_SPECIAL 0 (__enter__) # 取 __enter__
CALL 0 # 调用 __enter__()
L1: STORE_FAST 0 (r) # 返回值绑定 as 变量
19 LOAD_FAST_BORROW 0 (r)
18 L2: LOAD_CONST 0 (None) ×3 # 压入 (None,None,None)
CALL 3 # 正常退出:__exit__(None,None,None)
POP_TOP
RETURN_VALUE
L3: PUSH_EXC_INFO
WITH_EXCEPT_START # 异常退出:分发异常给 __exit__
TO_BOOL
POP_JUMP_IF_TRUE 2 (to L4) # __exit__ 返回真值 → 吞异常
RERAISE 2 # 否则重新抛出两个分支一目了然:正常路径压入三个 None 调 __exit__;异常路径用 WITH_EXCEPT_START 把异常交给 __exit__,看返回值决定吞还是抛。这正好对应 __exit__ 三参数在正常/异常时的两种形态。
实现一个上下文管理器有两种方式:类实现(__enter__/__exit__)与 contextmanager 装饰器(8.4.4)。先看类实现——第 7 章 7.7.7 的 Timer 是完整样本,这里看一个"连接管理":
class Connection:
def __init__(self, dsn):
self.dsn = dsn
self._conn = None
def __enter__(self):
print(f"连接 {self.dsn}")
self._conn = connect(self.dsn) # 获取资源
return self._conn # 返回值绑定给 as 变量
def __exit__(self, exc_type, exc_val, exc_tb):
print(f"关闭连接" + ("(有异常)" if exc_type else ""))
self._conn.close() # 释放资源——无论是否异常
return False # 异常照常传播实战建议:类实现适合"资源有状态、退出时要做复杂处理(回滚、上报、多步清理)"的场景。如果只是"进入打印 / 退出打印"这类简单包裹,用
contextmanager(8.4.4)更省事。
标准库 contextlib 提供了"不用写类"的上下文管理器,以及一批现成的工具。
① @contextmanager:把生成器函数变成上下文管理器。 约定:yield 之前的代码 = __enter__,yield 之后的代码 = __exit__:
>>> from contextlib import contextmanager
>>> @contextmanager
... def managed_resource():
... print("进入") # yield 前 = __enter__
... try:
... yield "resource" # yield 的值 = __enter__ 的返回值
... finally:
... print("退出") # yield 后 = __exit__(finally 保证)
>>> with managed_resource() as r:
... print("使用", r)
进入
使用 resource
退出关键机制:contextmanager 内部把生成器包成一个上下文管理器——__enter__ 驱动生成器到第一个 yield;__exit__ 用 GeneratorExit/异常恢复生成器。如果代码块抛异常,异常会在 yield 处被抛出,此时可用 try/except 捕获:
>>> @contextmanager
... def may_fail():
... print("进入")
... try:
... yield 42
... except ValueError:
... print("代码块抛了 ValueError")
... print("退出")
>>> with may_fail() as v:
... raise ValueError("x") # 在 yield 处被 except 捕获
进入
代码块抛了 ValueError
退出
⚠️ 陷阱——yield后没有try/finally的裸写:代码块抛异常时,yield之后的语句不会执行(异常从 yield 处传播出去)。所以需要保证的清理逻辑必须包在finally里:@contextmanager def bad(): yield "r" print("清理") # ❌ 代码块抛异常时这行不执行 @contextmanager def good(): try: yield "r" finally: print("清理") # ✅ 无论是否异常都执行
② suppress:优雅地忽略特定异常——比 try/except/pass 更声明式:
>>> from contextlib import suppress
>>> with suppress(FileNotFoundError): # 文件不存在时静默通过
... os.remove("tmp.json") # 等价于 try/except FileNotFoundError: pass③ ExitStack:动态管理"数量未知"的资源。 前面所有 with 都在编译期写死了资源个数;当资源个数在运行时才知道(一批连接、可配置的文件列表),用 ExitStack:
>>> from contextlib import ExitStack
>>> def open_many(paths):
... files = []
... with ExitStack() as stack: # 栈退出时,所有资源统一释放
... for p in paths:
... f = open(p) # 逐个打开
... stack.enter_context(f) # 注册到栈上(注意:这里传的是已打开的对象)
... files.append(f)
... return files # ExitStack 退出时全部 closeExitStack 的核心方法(语义已实测):
| 方法 | 行为 |
|---|---|
enter_context(cm) |
调用 cm.__enter__() 并返回其结果,退出时调用 __exit__ |
push(cm) |
只注册 __exit__,不调用 __enter__——用于"已进入的 CM 补注册" |
callback(fn, *args) |
退出时调用回调(栈式,LIFO) |
close() / pop_all() |
提前释放 / 把栈转交出去(解耦生命周期) |
⚠️ 陷阱——pushvsenter_context:push(cm)不会调用cm.__enter__(),但退出时仍会调用cm.__exit__()。如果__exit__依赖__enter__建立的状态,用push会踩空。默认用enter_context,push只在你已经手动进入过 CM、想补注册清理时用。
④ 其他常用工具:
| 工具 | 作用 |
|---|---|
closing(obj) |
退出时调用 obj.close()(即使没实现 __exit__) |
nullcontext(enter_result) |
空操作上下文管理器——给"可选加锁/不锁"的分支一个统一的 with 接口 |
redirect_stdout(io) / redirect_stderr |
临时把输出重定向到别的流 |
AbstractContextManager |
抽象基类(配合 7.6.3 的 ABC) |
nullcontext 的实战——"可开关"的锁:
>>> from contextlib import nullcontext
>>> lock = threading.Lock() # 或 None(不加锁)
>>> cm = lock if lock else nullcontext()
>>> with cm: # 两个分支同一个接口
... critical_section()with A as a, B as b:——一行管理多个资源,等价于嵌套 with,退出顺序从内到外(后进入的先退出):
>>> with open("a.txt") as fa, open("b.txt") as fb:
... data_a = fa.read() # 两个文件都打开
>>> # 退出顺序:先 fb,后 fa(LIFO)求值顺序:__enter__ 从左到右依次调用;中途某个 __enter__ 抛异常,之前已进入的资源会立即被退出清理(不会泄漏)。
嵌套 with vs ExitStack 的选型:
| 场景 | 选择 |
|---|---|
| 资源数量编译期确定(2~3 个) | with A as a, B as b |
| 资源数量运行时才知道(循环打开) | ExitStack |
| 需要条件性进入/中途退出 | ExitStack(enter_context 可按条件调用) |
# 条件性进入:只有日志开启时才挂上日志处理器
with ExitStack() as stack:
if logging_enabled:
stack.enter_context(open_log())
# 主体逻辑...陷阱 1:__exit__ 误吞异常。 __exit__ 返回 True 会吞掉代码块的异常——如果函数忘记写 return(隐式返回 None)没事,但错误地返回了 True(比如 return self.closed),异常会被静默吞掉。除非明确要吞,__exit__ 一律返回 False/None。
陷阱 2:contextmanager 里"假 exit"。8.4.4 已讲:yield 后不包 finally,异常时清理不执行。写 contextmanager 时清理逻辑永远放 finally。
陷阱 3:把 with 当装饰品。with open(...) 打开的对象生命周期只到 with 块结束——块外引用它并不会让它保持打开:
>>> f = None
>>> with open("a.txt") as fh:
... f = fh # 块内引用
>>> f.closed # ✅ 块结束,文件已被关闭(即使 f 还引用它)
True与 __del__/finally 的对比(呼应 7.7.2 的闭环):
| 机制 | 释放时机 | 可靠性 |
|---|---|---|
with / finally |
代码块结束,确定 | ✅ 首选 |
__del__ |
引用计数归零,不确定(循环引用、退出时序) | ❌ 只兜底 |
设计哲学收束:
with把"获取/释放"写成一对词法上的对称结构,任何读者都能在一屏内看出资源边界;__del__把释放藏在对象生命周期里,谁也说不准什么时候发生。工程上要确定性,就要把生命周期显式化——这就是本章与第 7 章 7.7.2 共同的核心结论。
async with(版本注意):异步代码用async with配合__aenter__/__aexit__(PEP 492,3.5+),语义与with完全对应,只是进入/退出是协程调用(第 11 章异步编程展开)。
把本章所有机制串起来,实现一个事务语义的资源管理器:进入时开启事务,正常退出提交,异常退出回滚。这正是 8.4.3 类实现的完整形态,也是 with 相对 try/finally 最强的地方——__exit__ 能根据异常参数做决策(提交 or 回滚),手写 finally 做不到。
用一个内存版 FakeDB 让示例可运行:
class FakeDB:
"""模拟数据库:记录每次操作,便于验证事务边界"""
def __init__(self):
self.log = []
def begin(self): self.log.append("BEGIN")
def insert(self, row): self.log.append(f"INSERT {row}")
def commit(self): self.log.append("COMMIT")
def rollback(self): self.log.append("ROLLBACK")
class Transaction:
"""事务上下文管理器:成功提交、异常回滚"""
def __init__(self, db):
self.db = db
def __enter__(self):
self.db.begin() # ① 进入:开启事务
return self.db # 绑定给 as 变量
def __exit__(self, exc_type, exc_val, exc_tb):
if exc_type is None:
self.db.commit() # ② 无异常 → 提交
else:
self.db.rollback() # ③ 有异常 → 回滚
logging.exception("事务已回滚") # 8.3.5:日志带回溯
return False # ④ 异常照常传播,调用方感知失败成功路径——BEGIN → INSERT → COMMIT:
>>> db = FakeDB()
>>> with Transaction(db) as tx:
... tx.insert("A")
... tx.insert("B")
>>> db.log
['BEGIN', 'INSERT A', 'INSERT B', 'COMMIT']异常路径——BEGIN → INSERT → ROLLBACK,且异常继续传播:
>>> db = FakeDB()
>>> try:
... with Transaction(db) as tx:
... tx.insert("A")
... raise RuntimeError("中途出错")
... except RuntimeError:
... print("调用方感知到失败")
调用方感知到失败
>>> db.log
['BEGIN', 'INSERT A', 'ROLLBACK']对比两条路径的差异:同样一行 with,__exit__ 的三个参数决定了截然不同的结局——这就是"异常感知式资源管理"的精髓。
增强 1:组合 ExitStack 管理多个事务资源。 当一次操作跨多个事务(主库 + 从库)时,用 8.4.4 的 ExitStack 统一生命周期:
def transfer(db_from, db_to, amount):
with ExitStack() as stack:
tx1 = stack.enter_context(Transaction(db_from))
tx2 = stack.enter_context(Transaction(db_to))
tx1.insert(f"扣款 {amount}")
tx2.insert(f"入账 {amount}")
# 任一步异常 → 两个事务都回滚(栈式退出,LIFO)增强 2:用异常链保留失败因果。 回滚后若想抛一个"业务语义"的异常,用 raise ... from(8.2.4)保留底层原因:
class TransferError(Exception): ...
def transfer(db_from, db_to, amount):
try:
with Transaction(db_from) as tx1, Transaction(db_to) as tx2:
tx1.insert(f"扣款 {amount}")
tx2.insert(f"入账 {amount}")
except Exception as e:
raise TransferError("转账失败,已回滚") from e # 保留原异常链这个例子回答了一句话:为什么 with 值得学?因为资源边界 + 异常决策这两件事,with 让你写在一对对称的代码块里——获取与释放相邻、成功与失败的分支就在 __exit__ 的 if exc_type is None 里,任何读者都能在一屏内看懂整个生命周期。
| 主题 | 核心要点 |
|---|---|
| 异常对象 | 类型 + args + __traceback__;异常是可实例化的对象;assert 是 raise 的调试形态 |
| 异常层次 | 根是 BaseException;KeyboardInterrupt/SystemExit/GeneratorExit 独立于 Exception;裸 except 是反模式,默认 except Exception |
raise |
三形态:实例 / 类(自动实例化)/ 裸 raise(re-raise);自定义异常继承 Exception 并携带结构化字段 |
| 底层机制 | 3.11+ 零成本异常(ExceptionTable 独立于正常路径);传播沿栈找处理器;回溯是帧链 |
| 四件套 | try/except(按序匹配、子类在前、元组捕获)/else(无异常才执行)/finally(保证清理,别 return) |
| 异常链 | 隐式 __context__ / 显式 __cause__(raise ... from)/ from None 抑制显示;日志 logging.exception 保留链 |
ExceptionGroup |
PEP 654 聚合多错误;except* 按类型取子组;并发/批处理场景(3.11+) |
| EAFP vs LBYL | EAFP 默认(免疫 TOCTOU、覆盖全面);LBYL 用于副作用敏感 / 性能敏感 |
| 性能 | try 块近零成本(3.11+);抛异常贵(构造回溯)——常态失败用返回值/哨兵值 |
with 协议 |
__enter__ 返回绑定 as;__exit__(exc_type, exc_val, exc_tb) 返回真值吞异常;字节码 WITH_EXCEPT_START |
contextlib |
contextmanager(yield 前=进入、yield 后=退出,清理放 finally);suppress;ExitStack 动态多资源(push ≠ enter_context);closing/nullcontext |
| 设计哲学 | with = RAII 的 Python 形态;资源生命周期要显式;清理用 with/finally,__del__ 只兜底 |
1.(预测输出) 下面代码打印什么?解释 finally 与 return 的执行顺序。
def f():
try:
return "try"
finally:
print("清理")
print(f())2.(预测输出) 下面代码会打印 捕获 吗?为什么?
try:
raise KeyboardInterrupt()
except Exception:
print("捕获")3.(解释行为) 分别说明 raise X、raise X from Y、raise X from None 对 __cause__/__context__/__suppress_context__ 的影响,以及回溯打印的差异。
4.(修复 bug) 下面代码静默吞掉了所有异常。指出问题并修正,要求:只捕获 ValueError,其余异常照常传播,且失败时有日志。
def parse(value):
try:
return int(value)
except:
return None5.(手写实现) 用 contextlib.contextmanager 实现一个 timed 上下文管理器:进入时记开始时间,退出时打印耗时(含异常时的耗时)。
6.(手写实现) 用类实现一个 AtomicFileWriter:__enter__ 打开临时文件,正常退出写回目标文件,异常退出删除临时文件并抛异常。说明它比"直接 with open()"强在哪。
7.(解释行为) 在 ExceptionGroup 里抛 [ValueError(1), TypeError(2), ValueError(3)],分别写出 except* ValueError 与 except* TypeError 各自拿到的子组。
8.(设计) 下列场景选 EAFP 还是 LBYL,说明理由:① 从字典取值,键可能不存在;② 删除一个"大概率不存在"的临时文件;③ 解析用户输入的数字;④ 高并发下给文件加锁。
9.(性能实验) 用 timeit 对比"用异常判断除零"与"先检查再除"两种写法,在 number=100_000 下的耗时差距,并解释原因(提示:8.3.4)。
10.(综合实战) 为一个小型银行转账系统设计自定义异常体系:基类 TransferError + 至少三个子类(余额不足、账户不存在、金额非法),每个携带结构化字段;再写一个 transfer(from_acct, to_acct, amount) 函数,用 Transaction 模式保证"全部成功或全部回滚",失败时抛带异常链的业务异常。
进入下一章的准备:
- ✅ 能画出
BaseException家族树,说出三个非Exception子类存在的原因 - ✅ 能说清
try/except/else/finally四个子句的时机,并解释finally里return的陷阱 - ✅ 能区分
__context__/__cause__/__suppress_context__,会正确使用raise ... from - ✅ 能解释 3.11 零成本异常的原理,以及"为什么抛异常很贵"
- ✅ 能判断 EAFP 与 LBYL 的适用场景
- ✅ 能写出
with协议(__enter__/__exit__)与contextmanager版本 - ✅ 会用
ExitStack管理运行时才知道数量的资源,能说出push与enter_context的区别
衔接预告:第 9 章"文件 I/O 与序列化"将是本章知识的最大应用场——
with open(...)的每个细节(缓冲、编码、TextIOBase协议)都建立在本章之上;第 11 章"并发与异步编程"会用到async with与ExceptionGroup聚合任务错误;第 12 章"元编程"则会展示如何用装饰器与元类自动生成上下文管理器。