《Python编程入门》7.2 自定义异常、异常链与错误设计

本节讲如何设计一套可维护的异常体系。先讲自定义异常的写法:继承哪个基类、怎样携带结构化字段、如何重写字符串表示;再讲异常链,用真实回溯区分隐式链与显式链,说明何时该用 from 保留原始错误、何时用 from None 切断噪音;随后给出库作者的异常设计原则,包括定义基类、让调用方能精确捕获、不用异常做正常控制流,以及宽泛捕获后重新抛出的正确姿势;最后介绍警告机制与弃用装饰器。

本节目标:学会定义带结构化信息的自定义异常,掌握隐式链与显式链的区别并正确使用 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 库的异常设计原则

如果你是库作者,异常就是你对外契约的一部分。几条经过验证的原则:

  1. 定义自己的异常基类,所有库内异常都从它派生。好处是调用方可以 except MyLibError 一次性兜住「所有来自这个库的错误」,同时又不误伤 ValueError 这类内建异常。

  2. 让调用方能精确捕获。 为每类可恢复的错误定义子类型,而不是一律 MyLibError("...")。调用方要能写 except RateLimitError 而不是去 str(e) 里找关键词。

  3. 别把内建异常当业务异常抛。 抛 ValueError 会让调用方分不清是你抛的,还是标准库抛的。

  4. 不要用异常做正常控制流。 上一节已经论证过:正常结果用返回值,异常只留给计划外的情况。

  5. 文档化每个异常类,说清楚什么条件下会抛出——异常类型是 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 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「python」更多文章

  1. 《Python高级编程》目录
  2. 《Python高级编程》11.3 PEP 流程与版本迁移策略
  3. 《Python高级编程》11.2 嵌入式与自由线程运行时