前言自定义异常是那种「语法一分钟学会、设计想三年」的东西。写一个class MyError(Exception): pass只需要一行但什么时候该定义新异常、新异常该放在继承树的哪一层、异常里该不该带数据——这些问题没有语法能替你回答。有一个错误很常见为了「显得规范」给每一种出错情况都定义一个异常类最后模块里躺着二十个空类调用方一个也分不清该捕获哪个。另一个极端是干脆不定义全用ValueError对付让调用方被迫靠匹配错误消息来区分情况。本文按四步走先定继承的根这里有一条官方明确建议很多人第一条就踩错再讲层次怎么划分然后讲怎么让异常携带结构化数据、怎么自定义它的字符串表现最后给出模块级的组织方式。一、继承谁为什么是 Exception 而不是 BaseException所有异常的根是BaseException那自定义异常为什么不直接继承它先看继承树里直接挂在BaseException之下的四个成员SystemExit、KeyboardInterrupt、GeneratorExit、BaseExceptionGroup。它们有一个共同点——不是「程序出错了」而是「程序被要求停下来」SystemExit是解释器要被关闭KeyboardInterrupt是用户按了 CtrlCGeneratorExit是生成器要被关闭。如果你的异常继承BaseException那么一句except BaseException:以及各种笼统的兜底写法就会把你的业务异常和这些控制信号混在一起处理。后果很实际用户按 CtrlC 想中断程序却被某个兜底逻辑吞掉程序停不下来。官方文档的说法很明确内置异常类可以被继承用来定义新异常鼓励程序员从Exception或其子类派生新异常而不要从BaseException派生。所以第一条规则就这么简单# 适用于 Python 3.8class AppError(Exception):本项目所有自定义异常的基类。还有一条容易被忽略的建议一次只继承一个异常类型不要写class MyError(ValueError, KeyError)这种多重继承。官方给出的理由是多个父类之间可能在args属性的处理方式上冲突更底层的原因是很多内置异常是用 C 实现的带有各自的内存布局多重继承在实现上不可能总是成立。这条限制属于 CPython 实现细节但它足以构成一条工程上的规矩。二、异常层次怎么划一个基类 若干子类划分层次的判断标准只有一条调用方是否需要区别对待。需要区别对待的才值得单独成类。推荐的结构是一个「项目级基类 若干语义子类」# 适用于 Python 3.8class AppError(Exception):本应用所有业务异常的基类。调用方可以只捕获 AppError 来兜住一切业务异常。class ConfigError(AppError):配置缺失或格式不合法。class NotFoundError(AppError):按条件查找资源没有命中。class PermissionDeniedError(AppError):当前身份没有执行该操作的权限。class ConflictError(AppError):与当前资源状态冲突例如重名。这个结构带来三个好处调用方有统一的兜底点。只要except AppError就能拦住本项目所有业务异常同时不会误伤KeyboardInterrupt。需要细分时能细分。比如 HTTP 层的处理逻辑可以按类型映射状态码NotFoundError→ 404PermissionDeniedError→ 403ConflictError→ 409。与标准库的关系清楚。这些类自成一支不会和ValueError、TypeError这些标准库异常混淆。如果某一类异常在语义上确实与标准库的某个异常重合比如「输入值不合法」天然对应ValueError把它同时挂到本项目的基类上是一种常见做法# 适用于 Python 3.8class ValidationError(AppError, ValueError):输入校验失败。同时是 ValueError便于与标准库习惯兼容。但请回想上一条一次只继承一个内置异常类型。上面这种写法只多继承了一个内置异常ValueError另一个是本项目的纯 Python 基类风险很低但不要写成继承两个内置异常。层次的深度也要克制。三层根 → 分类 → 具体通常足够再深下去调用方记不住维护者也说不清两个类之间的差别。三、携带结构化数据与自定义 __str__异常的一个大优势是可以携带数据。仅仅写一句人类可读的描述是浪费——调用方可能想拿到字段名、想拿到原始值、想把它填进日志的结构化字段。# 适用于 Python 3.8class AppError(Exception):本应用所有业务异常的基类。class ValidationError(AppError):输入校验失败。参数:field: 出问题的字段名。reason: 不通过的原因面向人。value: 触发问题的原始值便于排查。def __init__(self, field, reason, valueNone):super().__init__(reason)self.field fieldself.reason reasonself.value valuedef __str__(self):base f字段 {self.field!r} 校验失败{self.reason}if self.value is not None:return f{base}实际值 {self.value!r}return base几处设计要点先调用super().__init__(reason)。虽然不调用也能跑但基类的args属性不会得到正确填充而args是异常被序列化、被打印、被第三方库读取时的通用接口。留一个可读的reason作为第一个参数是稳妥做法。每个属性都给一个明确的语义。field是机器要用的reason是给人看的value是给排查用的。别用一个字符串把这三种信息揉在一起。覆盖__str__而不是覆盖__repr__。默认的__str__会把参数转成字符串覆盖它能让日志里显示的是一句完整的话而不是(port, 不是整数)这种元组。__repr__保持默认即可它更适合调试。调用方拿到这个异常后就能做机器友好的处理# 适用于 Python 3.8def require_port(value):if not isinstance(value, int) or not (1 value 65535):raise ValidationError(port, 端口必须是 1 到 65535 的整数, value)return value拿到异常的一方可以放心地读exc.field不需要去解析字符串——用异常携带结构化数据避免把消息当接口。四、模块级的组织方式项目变大之后异常类放在哪里就成了问题。几个可行的做法按项目规模递进规模做法说明单文件小脚本定义在脚本顶部够用别过度设计单包项目独立errors.py或用exceptions.py全项目统一从一处导入避免循环导入多层项目根异常放公共模块各子系统定义自己的子类子类继承公共根跨层语义一致对外提供库根异常写进包的公开接口使用者需要能捕获到它一个具体建议根异常的模块不要导入任何其它业务模块。它应该处在依赖图的最底层只依赖内置的Exception。否则很容易出现「errors.py导入models.pymodels.py又导入errors.py」的循环导入。异常类本身几乎不需要引用其它模块这一点很容易做到。另外如果项目要对外暴露接口把根异常作为公开 API 的一部分写进文档使用者至少需要知道「兜底应该捕获哪一个类」。常见坑点1. 直接继承BaseException❌class MyError(BaseException): pass导致兜底捕获会连KeyboardInterrupt一起吞。 ✅ 继承Exception官方文档明确建议从Exception或其子类派生。2. 一次继承多个内置异常❌class MyError(ValueError, KeyError): pass。 ✅ 官方建议一次只子类化一个异常类型多个父类在args处理上可能冲突底层内存布局也可能不兼容。3. 为每种情况都造一个类❌ 模块里躺着三十个内容相同的空异常类调用方无从选择。 ✅ 只给「调用方需要区别对待」的情况建类其余用属性或构造参数承载差异。4. 忘了调用父类的初始化❌__init__里只给属性赋值不super().__init__(...)。 ✅ 先super().__init__(面向人的描述)让args有正确内容。5. 把异常消息当接口用❌ 调用方写if 端口 in str(exc):来判断错误种类。 ✅ 把关键信息放进属性exc.field调用方读属性字符串只给人看。6. 异常类引用业务模块❌ 在errors.py里from .models import User制造循环导入。 ✅ 异常模块只依赖内置Exception处在依赖图最底层。7. 覆盖__str__时返回非字符串❌def __str__(self): return self.args返回元组打印时报TypeError。 ✅__str__必须返回字符串要展示结构化信息就自己拼接。8. 在异常类里做重活❌ 构造时去查数据库、读文件、做网络请求。 ✅ 异常构造要廉价且不会失败——它是在「已经出问题」的时刻被执行的任何额外失败都会掩盖原始问题。总结设计问题结论继承谁Exception或其子类不要BaseException继承几个一次一个内置异常类型避免args冲突与内存布局问题层次怎么划一个项目根异常 按「是否需要区别对待」设子类通常三层足够带什么数据机器用的字段放属性人看的描述放args/__str__放哪里独立模块、只依赖内置异常、作为公开接口的一部分写进文档自定义异常的价值不在「类本身」而在它替调用方建立的那套分类语言有了根异常就有了兜底点有了子类就有了区别对待的依据有了属性就有了机器可读的细节。先用一条判断标准调用方是否需要区别对待去筛绝大多数过度设计都能被挡在门外。