本节目标:学会定义带结构化信息的自定义异常,掌握隐式链与显式链的区别并正确使用
raise ... from,理解库作者应该怎样设计异常体系、怎样宽泛捕获后重新抛出。
适用版本:Python 3.12+(实测 3.14.6)
7.2 自定义异常、异常链与错误设计
上一节把异常机制本身讲清楚了。但在真实项目里,光会 except ValueError 是不够的:一个订单系统需要区分「库存不足」和「支付被拒」,一个 API 网关需要把内部错误映射成合适的状态码。能精确表达「发生了什么」的异常类型,是接口契约的一部分。 这一节就讲怎么设计它。
7.2.1 为什么要自定义异常
用内建异常凑合,会遇到两个问题:语义模糊和无法精确捕获。
def withdraw(account, amount):
if amount > account.balance:
raise ValueError("余额不足") # 语义模糊
if amount <= 0:
raise ValueError("金额必须为正") # 同一个类型,调用方无法区分
account.balance -= amount
调用方拿到 ValueError 时,没法判断该提示「充值」还是「输入有误」。自定义异常解决的就是这个问题:每一种可恢复的业务错误,都应该有一个专门的类型。
7.2.2 自定义异常的写法
规则很简单:继承 Exception(或你自己的异常基类),在 __init__ 里带上结构化字段,重写 __str__ 决定打印出来的样子。
class AppError(Exception):
"""本应用所有异常的基类。"""
class ValidationError(AppError):
def __init__(self, field, message):
super().__init__(f"{field}: {message}") # 传给 Exception,进入 args
self.field = field
self.message = message
def __str__(self):
return f"[{self.field}] {self.message}"
e = ValidationError("email", "格式不正确")
print(str(e)) # [email] 格式不正确
print(e.args) # ('email: 格式不正确',)
print(e.field, "/", e.message) # email / 格式不正确
print(isinstance(e, AppError)) # True
[email] 格式不正确
('email: 格式不正确',)
email / 格式不正确
True
几个关键点:
super().__init__(...)要调用,它把消息放进e.args。日志系统、str(e)的默认实现都依赖它。- 结构化字段(
field、message)比字符串更有价值:调用方可以e.field拿到出错字段,去做表单高亮,而不必去解析消息字符串。 - 重写
__str__只影响显示,不影响args,也不影响捕获。str(e)适合给人看,e.field适合给程序用。 - 不要直接继承
BaseException:那会让except Exception抓不到它,违背了「能被常规兜底捕获」的预期。
7.2.3 隐式异常链:__context__
当一个异常在处理另一个异常的过程中被抛出,Python 会自动把原始异常挂到新异常的 __context__ 上——这叫隐式链。
class ConfigError(Exception):
pass
def load(config):
try:
return int(config["port"])
except (KeyError, ValueError):
raise ConfigError("invalid port config") # 没有 from,隐式链接
try:
load({"port": "abc"})
except ConfigError as e:
print("type:", type(e).__name__)
print("__context__:", type(e.__context__).__name__)
print("__cause__:", e.__cause__)
type: ConfigError
__context__: ValueError
__cause__: None
__cause__ 是 None,但 __context__ 记着那个 ValueError。看 traceback 就明白了:
Traceback (most recent call last):
File "demo.py", line 8, in load
return int(config["port"])
ValueError: invalid literal for int() with base 10: 'abc'
During handling of the above exception, another exception occurred:
Traceback (most recent call last):
File "demo.py", line 13, in <module>
load({"port": "abc"})
File "demo.py", line 10, in load
raise ConfigError("invalid port config")
ConfigError: invalid port config
During handling of the above exception, another exception occurred: 这行就是隐式链的标记——它告诉你「上面那个错误是引发下面这个错误的背景」。
7.2.4 显式异常链:raise ... from e
隐式链的问题是:它只是「碰巧发生」。如果新异常其实是直接由原异常引起的(而不是在清理它时顺便出错),应该用 raise ... from e 显式声明因果,让语义清晰:
class ConfigError(Exception):
pass
def load(config):
try:
return int(config["port"])
except (KeyError, ValueError) as e:
raise ConfigError("invalid port config") from e # 显式声明因果
try:
load({"port": "abc"})
except ConfigError as e:
print("__cause__:", type(e.__cause__).__name__) # ValueError
print("__suppress_context__:", e.__suppress_context__) # True
此时 traceback 的措辞也变了:
Traceback (most recent call last):
File "demo.py", line 7, in load
return int(config["port"])
ValueError: invalid literal for int() with base 10: 'abc'
The above exception was the direct cause of the following exception:
Traceback (most recent call last):
File "demo.py", line 12, in <module>
load({"port": "abc"})
File "demo.py", line 9, in load
raise ConfigError("invalid port config") from e
ConfigError: invalid port config
对照两行关键提示,就能看出两种链的语义差异:
| 链类型 | 属性 | traceback 提示语 | 语义 |
|---|---|---|---|
| 隐式 | __context__ | During handling of the above exception, another exception occurred | 处理旧错误时又出了新错误 |
| 显式 | __cause__ | The above exception was the direct cause of the following exception | 旧错误直接导致了新错误 |
实践建议:只要你在 except 块里抛新异常,就总是写上 from e。 它让「这是同一个错误的两种表述」和「这是处理过程中又冒出来的另一个错误」区分开来,调试时省下的时间远超敲那 7 个字符。
7.2.5 from None:主动切断噪音
有时底层异常的细节对调用方毫无意义,反而是干扰。比如一个公开的解析函数,内部用了 int(),暴露 ValueError: invalid literal for int() with base 10 只会让用户困惑。这时用 from None 主动切断链条:
class NotAnInteger(Exception):
pass
def parse(s):
try:
return int(s)
except ValueError:
raise NotAnInteger(f"不是合法整数: {s!r}") from None
try:
parse("abc")
except NotAnInteger as e:
print("__cause__:", e.__cause__)
print("__context__:", type(e.__context__).__name__)
print("__suppress_context__:", e.__suppress_context__)
__cause__: None
__context__: ValueError
__suppress_context__: True
traceback 变干净了,不再出现 During handling of...:
Traceback (most recent call last):
File "demo.py", line 11, in <module>
parse("abc")
File "demo.py", line 9, in parse
raise NotAnInteger(f"不是合法整数: {s!r}") from None
NotAnInteger: 不是合法整数: 'abc'
注意:from None 并没有删掉 __context__,只是把 __suppress_context__ 设为 True,让 traceback 不再打印它。调试时如果你还想看原始错误,e.__context__ 依然在那里。
什么时候用 from None?判断标准是:底层异常的细节属于实现细节,暴露它只会误导使用者。 库的公开 API 边界上很常见;内部模块之间则应保留链条。
7.2.6 宽泛捕获 + 重新抛出
有些代码需要「不管什么异常,先做点事,再让它继续传下去」——记录日志、回滚事务、补充上下文。这时绝不能吞掉异常,必须重新抛出。
class DataError(Exception):
pass
class NotFound(DataError):
pass
def fetch(key):
raise KeyError(key)
def load(key):
try:
return fetch(key)
except KeyError as e: # 捕获够具体
raise NotFound(f"no record: {key!r}") from e # 带上因果,继续抛
try:
load("u-1")
except NotFound as e:
print("caught:", e, "| cause:", type(e.__cause__).__name__)
caught: no record: 'u-1' | cause: KeyError
三种「重新抛出」的写法,效果完全不同:
except SomeError:
log()
raise # 原样重抛,保留原 traceback(推荐)
except SomeError as e:
log()
raise e # 重建 traceback,丢失原始位置(不推荐)
永远用裸 raise 重新抛出,别用 raise e:后者会重置异常的 __traceback__,让原始出错行号从日志里消失。
7.2.7 库的异常设计原则
如果你是库作者,异常就是你对外契约的一部分。几条经过验证的原则:
定义自己的异常基类,所有库内异常都从它派生。好处是调用方可以
except MyLibError一次性兜住「所有来自这个库的错误」,同时又不误伤ValueError这类内建异常。让调用方能精确捕获。 为每类可恢复的错误定义子类型,而不是一律
MyLibError("...")。调用方要能写except RateLimitError而不是去str(e)里找关键词。别把内建异常当业务异常抛。 抛
ValueError会让调用方分不清是你抛的,还是标准库抛的。不要用异常做正常控制流。 上一节已经论证过:正常结果用返回值,异常只留给计划外的情况。
文档化每个异常类,说清楚什么条件下会抛出——异常类型是 API 文档的一部分。
一个完整的库异常层次长这样:
class MyLibError(Exception):
"""库异常基类。"""
class NetworkError(MyLibError):
"""网络相关错误的基类。"""
class TimeoutError(NetworkError): # noqa: A001 - 本库自己的超时异常
"""请求超时。"""
调用方可以按需要的粒度捕获:except TimeoutError 只处理超时,except NetworkError 处理所有网络问题,except MyLibError 兜住整个库。
7.2.8 警告与 warnings.deprecated
不是所有「不太对」的情况都该抛异常——有些只是「还能用,但建议改掉」,比如调用了即将废弃的 API。这时用 warnings 模块,它不会中断程序,只发一条提示。
Python 3.13 起提供了 warnings.deprecated 装饰器,专门标记废弃的函数或类:
import warnings
from warnings import deprecated
@deprecated("use new_func instead")
def old_func():
return 42
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
print(old_func()) # 42
print(w[0].category.__name__) # DeprecationWarning
print(w[0].message) # use new_func instead
注意 deprecated 需要 3.13 及以上;在更老的版本里要手动 warnings.warn("...", DeprecationWarning, stacklevel=2)。stacklevel=2 的作用是让警告指向调用方的那一行,而不是库内部发出警告的那一行——写库时几乎总该带上它。
异常与警告的分工很清晰:
| 场景 | 用什么 |
|---|---|
| 程序无法继续,调用方必须处理 | 抛异常 |
| 还能继续,但用法已过时/可疑 | warnings.warn |
| 只是给开发者看的自检 | assert(可被 -O 移除) |
小结
- 自定义异常要继承
Exception(或自有基类),调用super().__init__填args,用结构化字段承载信息,用__str__控制显示。 - 隐式链(
__context__)由解释器自动建立,traceback 显示「During handling of…」;显式链(raise ... from e设置__cause__)显示「The above exception was the direct cause of…」。 - 在
except里抛新异常时总是写from e;只有底层细节属于实现噪音时,才用from None(它只设__suppress_context__,不删__context__)。 - 宽泛捕获后必须重新抛出,且用裸
raise,不要raise e(后者会重置 traceback)。 - 库应定义自己的异常基类,让调用方能按粒度精确捕获;不要用内建异常冒充业务异常,也不要用异常做正常控制流。
- 3.13 起的
warnings.deprecated用于标记废弃 API;更早版本用warnings.warn(..., stacklevel=2)。
下一节我们处理异常之外的另一半资源管理问题:文件、锁、数据库连接打开后必须确保关闭,with 语句和上下文管理器就是为这件事设计的。
阅读导航:上一节:7.1 异常层次与 try/except/else/finally · 下一节:7.3 上下文管理器与 with 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。