异常处理
前言
- 这篇覆盖 Python 错误处理的一整套:
try / except / else / finally的执行规则、raise与异常链、3.11 的except*、自定义异常怎么设计、assert的边界,以及warnings和异常的关系。 - 原笔记里那张「Python 标准异常」表是 Python 2 时代的抄本(
StandardError、OverflowWarning已经不存在,IOError/EnvironmentError/WindowsError现在只是OSError的别名),这里换成按 3.14 实测重画的速查表。 - 所有示例在本机 Python 3.14.10 上实跑过,输出原样贴在代码块下面;编译期的
SyntaxWarning也保留在输出里,那本身就是知识点。
异常从哪来:3.14 的继承树
-
先看根上分了什么
BaseException之下不只有Exception:SystemExit、KeyboardInterrupt、GeneratorExit是它的兄弟。这条决定了「except Exception到底接不住什么」。1
2
3
4
5
6
7
8import builtins
print('BaseException 的直接子类:', sorted(c.__name__ for c in BaseException.__subclasses__()))
print('Exception 的直接子类数:', len(Exception.__subclasses__()))
print('OSError 的直接子类:', sorted(c.__name__ for c in OSError.__subclasses__()))
print('ArithmeticError 的直接子类:', sorted(c.__name__ for c in ArithmeticError.__subclasses__()))
print('LookupError 的直接子类:', sorted(c.__name__ for c in LookupError.__subclasses__()))
print('Warning 的直接子类:', sorted(c.__name__ for c in Warning.__subclasses__()))输出:
1
2
3
4
5
6BaseException 的直接子类: ['BaseExceptionGroup', 'Exception', 'GeneratorExit', 'KeyboardInterrupt', 'SystemExit']
Exception 的直接子类数: 21
OSError 的直接子类: ['BlockingIOError', 'ChildProcessError', 'ConnectionError', 'FileExistsError', 'FileNotFoundError', 'InterruptedError', 'IsADirectoryError', 'NotADirectoryError', 'PermissionError', 'ProcessLookupError', 'TimeoutError', 'UnsupportedOperation']
ArithmeticError 的直接子类: ['FloatingPointError', 'OverflowError', 'ZeroDivisionError']
LookupError 的直接子类: ['CodecRegistryError', 'IndexError', 'KeyError']
Warning 的直接子类: ['BytesWarning', 'DeprecationWarning', 'EncodingWarning', 'FutureWarning', 'ImportWarning', 'PendingDeprecationWarning', 'ResourceWarning', 'RuntimeWarning', 'SyntaxWarning', 'UnicodeWarning', 'UserWarning'] -
老表里的名字现在还在吗
1
2
3
4
5
6
7
8
9
10
11
12import builtins
print('IOError is OSError:', IOError is OSError)
print('builtins.EnvironmentError is OSError:', builtins.EnvironmentError is OSError)
print('builtins.WindowsError is OSError:', builtins.WindowsError is OSError)
print('StandardError 还在吗:', hasattr(builtins, 'StandardError'))
print('OverflowWarning 还在吗:', hasattr(builtins, 'OverflowWarning'))
try:
open('C:/no/such/dir/file.txt')
except EnvironmentError as e: # 别名照样能接住 OSError 家族
print('接住了:', type(e).__name__, '| errno:', e.errno)输出:
1
2
3
4
5
6IOError is OSError: True
builtins.EnvironmentError is OSError: True
builtins.WindowsError is OSError: True
StandardError 还在吗: False
OverflowWarning 还在吗: False
接住了: FileNotFoundError | errno: 2三条结论:
StandardError、OverflowWarning在 Python 3 里根本不存在。IOError、EnvironmentError、WindowsError三个名字都还在,但它们就是OSError本身(is OSError为真),是留给老代码的兼容别名,新代码一律写OSError。os.WindowsError已经没有了。
-
except Exception接不住什么1
2
3
4
5
6
7
8
9print('KeyboardInterrupt 是 Exception 子类吗:', issubclass(KeyboardInterrupt, Exception))
print('SystemExit 是 Exception 子类吗:', issubclass(SystemExit, Exception))
try:
raise KeyboardInterrupt('用户按了 Ctrl+C')
except Exception:
print('被 except Exception 接住了')
except BaseException as e:
print('只有 except BaseException 接得住:', type(e).__name__, e)输出:
1
2
3KeyboardInterrupt 是 Exception 子类吗: False
SystemExit 是 Exception 子类吗: False
只有 except BaseException 接得住: KeyboardInterrupt 用户按了 Ctrl+C所以「捕获所有异常」要分两种说法:接住除 Ctrl+C / 退出之外的全部,写
except Exception;真要做到一个不落,只能except BaseException——但那会顺手把sys.exit()和用户中断也吞掉,除非是在写顶层 CLI,否则别这么干。 -
常用异常速查(第三列由解释器核对)
异常 什么时候抛 直接父类 BaseException所有异常的根;只有想连 Ctrl+C 一起接住时才用它 (根)Exception常规错误的基类,业务代码的兜底捕获点 BaseExceptionArithmeticError数值计算错误的基类 ExceptionZeroDivisionError除法 / 取模的第二个参数为 0 ArithmeticErrorOverflowError运算结果超出可表示范围 ArithmeticErrorAssertionErrorassert的条件为假ExceptionAttributeError访问对象上不存在的属性或方法 ExceptionEOFError读输入时提前遇到结束标记 ExceptionImportError导入模块失败,或从模块里取不到名字 ExceptionModuleNotFoundErrorimport的模块找不到ImportErrorIndexError序列下标越界 LookupErrorKeyError字典里没有这个键 LookupErrorMemoryError内存不够分配 ExceptionNameError用到没定义的名字 ExceptionUnboundLocalError局部变量在赋值前被读取 NameErrorOSError文件 / 系统 / 网络错误的基类, errno里有原因ExceptionFileNotFoundErroropen一个不存在的路径OSErrorPermissionError权限不足 OSErrorTimeoutError操作超时 OSErrorConnectionError连接建立或传输失败 OSErrorRecursionError递归层数超过 sys.getrecursionlimit()RuntimeErrorRuntimeError不属于其它类别的运行时错误 ExceptionNotImplementedError抽象方法留空时抛它,提示子类必须实现 RuntimeErrorSyntaxError源码语法不合法(编译期就报, try接不住)ExceptionIndentationError缩进不符合要求 SyntaxErrorTabErrorTab 与空格混用 IndentationErrorTypeError对不合适类型做运算,或参数个数 / 关键字不对 ExceptionValueError类型对但值不合法,如 int("abc")ExceptionUnicodeError编解码出错 ValueErrorStopIteration迭代器耗尽; next(it, 默认值)可以免抛ExceptionStopAsyncIteration异步迭代器耗尽 ExceptionGeneratorExit关闭生成器时注入的异常 BaseExceptionKeyboardInterrupt用户按 Ctrl+C,不是 Exception的子类BaseExceptionSystemExitsys.exit()抛出,同样不是Exception的子类BaseExceptionExceptionGroup一次抛出多个异常(3.11+,配 except*)BaseExceptionGroupWarning所有警告的基类 Exception
try / except / else / finally
-
四段各自什么时候跑
1
2
3
4
5
6
7
8
9try:
n = int('42') # 这行不会抛异常
print('转换结果:', n)
except ValueError as e: # as 后面拿到异常对象本身
print('捕获到异常:', type(e).__name__, e)
else:
print('try 里没抛异常才会走这里')
finally:
print('无论有没有异常都会走这里')输出:
1
2
3转换结果: 42
try 里没抛异常才会走这里
无论有没有异常都会走这里规则:
else只在try没抛异常时执行,位置必须在except之后。finally无论有没有异常、有没有return都会执行。try里抛出却没被任何except接住的异常,会在finally之后继续往外传。
-
多个 except 与「分组」
1
2
3
4
5
6
7
8
9
10def parse(text):
try:
return int(text)
except ValueError: # 第一个匹配的 except 生效
return '不是整数'
except (TypeError, IndexError): # 多个类型可以并到一个块里
return '类型或下标有问题'
for s in ['42', 'abc', None]:
print(repr(s), '->', parse(s))输出:
1
2
3'42' -> 42
'abc' -> 不是整数
None -> 类型或下标有问题一个
except接多个类型要写成元组except (TypeError, IndexError):。写成except TypeError, IndexError:是 Python 2 语法,Python 3 直接SyntaxError。 -
顺序很重要:子类写在父类前面
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19def catch(value):
try:
raise value
except LookupError: # 父类写在前面,子类就永远轮不到
return 'LookupError'
except KeyError:
return 'KeyError'
print('父类在前时:', catch(KeyError('k')))
def catch_fixed(value):
try:
raise value
except KeyError: # 正确顺序:子类在前
return 'KeyError'
except LookupError:
return 'LookupError'
print('子类在前时:', catch_fixed(KeyError('k')))输出:
1
2父类在前时: LookupError
子类在前时: KeyErrorexcept是从上到下第一个匹配就执行,父类写在前面的话,后面的子类分支就成了死代码。 -
异常对象上能拿到什么
1
2
3
4
5
6
7try:
int('abc')
except ValueError as e:
print('type:', type(e).__name__)
print('str(e):', e)
print('repr(e):', repr(e))
print('args:', e.args)输出:
1
2
3
4type: ValueError
str(e): invalid literal for int() with base 10: 'abc'
repr(e): ValueError("invalid literal for int() with base 10: 'abc'")
args: ("invalid literal for int() with base 10: 'abc'",)str(e)给人看,repr(e)带类型名、适合写日志,e.args是构造参数元组。自定义异常带多个字段时,args里就不止一项。 -
要完整回溯就借
traceback1
2
3
4
5
6
7
8import traceback
try:
1 / 0
except ZeroDivisionError:
tb = traceback.format_exc() # 拿到完整回溯字符串,适合写日志
print('回溯行数:', len(tb.strip().splitlines()))
print('最后一行:', tb.strip().splitlines()[-1])输出:
1
2回溯行数: 5
最后一行: ZeroDivisionError: division by zerotraceback.format_exc()返回字符串,方便塞进日志。只是想在原样抛出的同时记一笔,用裸raise更省事(见下一节)。 -
坑:
finally里的return会吞掉异常1
2
3
4
5
6
7def bad():
try:
raise ValueError('很重要的错误')
finally:
return 'finally 里的 return' # 3.14 会在这里给一条 SyntaxWarning
print('调用结果:', bad())输出:
1
2<string>:5: SyntaxWarning: 'return' in a 'finally' block
调用结果: finally 里的 returnValueError就这么消失了。3.14 已经会对这种写法发SyntaxWarning(输出的第一行),别写这种代码:finally里只放清理动作。 -
处理完还想往上抛:裸
raise1
2
3
4
5
6
7
8
9
10
11def read(path):
try:
return int(path)
except ValueError:
print(' 先记一笔日志')
raise # 裸 raise:原样把当前异常抛出去
try:
read('abc')
except ValueError as e:
print(' 外层收到:', repr(e))输出:
1
2先记一笔日志
外层收到: ValueError("invalid literal for int() with base 10: 'abc'")在
except块里写不带参数的raise,抛出的是原来那个异常对象,回溯链完整保留。
raise 与异常链
-
显式链:
raise X from Y在
except里换成另一种异常往外抛时,用from把起因挂上去,读日志的人才看得到链条。1
2
3
4
5
6
7
8
9try:
try:
int('abc')
except ValueError as e:
raise RuntimeError('配置项解析失败') from e # 显式链
except RuntimeError as out:
print('__cause__:', type(out.__cause__).__name__)
print('__context__:', type(out.__context__).__name__)
print('__suppress_context__:', out.__suppress_context__)输出:
1
2
3__cause__: ValueError
__context__: ValueError
__suppress_context__: True -
隐式链与
from None不写
from时,解释器仍会把当时正在处理的异常记到__context__上,只是__cause__为空;from None是主动掐断展示。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16try:
try:
int('abc')
except ValueError:
raise RuntimeError('配置项解析失败') # 隐式链:不写 from
except RuntimeError as out:
print('__cause__:', out.__cause__)
print('__context__:', type(out.__context__).__name__)
try:
try:
int('abc')
except ValueError:
raise RuntimeError('配置项解析失败') from None # 掐断上下文展示
except RuntimeError as out:
print('from None 之后 __suppress_context__:', out.__suppress_context__)输出:
1
2
3__cause__: None
__context__: ValueError
from None 之后 __suppress_context__: True注意最后一行:
from None之后__context__其实还挂在对象上,只是打印回溯时不再显示。它是「别让无关异常干扰排障」的手段,不是删除。 -
两种链在报错文本上的区别
同一份逻辑,一个不写
from、一个写from e,未捕获时解释器给的提示语不同。不写from:1
2
3
4try:
int('abc')
except ValueError:
raise RuntimeError('配置项解析失败')输出:
1
2
3
4
5
6
7
8
9
10
11
12Traceback (most recent call last):
File "<string>", line 2, in <module>
int('abc')
~~~^^^^^^^
ValueError: invalid literal for int() with base 10: 'abc'
During handling of the above exception, another exception occurred:
Traceback (most recent call last):
File "<string>", line 4, in <module>
raise RuntimeError('配置项解析失败')
RuntimeError: 配置项解析失败写
from e:1
2
3
4try:
int('abc')
except ValueError as e:
raise RuntimeError('配置项解析失败') from e输出:
1
2
3
4
5
6
7
8
9
10
11
12Traceback (most recent call last):
File "<string>", line 2, in <module>
int('abc')
~~~^^^^^^^
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 "<string>", line 4, in <module>
raise RuntimeError('配置项解析失败') from e
RuntimeError: 配置项解析失败During handling of the above exception, another exception occurred表示「处理 A 的过程中又冒了 B」,通常是意外;The above exception was the direct cause of the following exception表示这是有意转换。看到前者就该去查是不是漏写了from。
except*:一次处理多个异常
3.11 引入(PEP 654)。并发场景里可能同时冒出多个异常,ExceptionGroup 把它们打包,except* 按类型分别摘走——注意每个匹配的分支都会执行,不是第一个匹配就结束。
1 | try: |
输出:
1 | ValueError 分支: (ValueError('第 2 行'),) |
except*后面跟类型,拿到的eg是一个异常组,.exceptions是被摘走的那批。- 上面没被任何
except*摘走的成员会打包继续往外抛,外层还能再用except*接(示例里的KeyError就是这条路径)。 except匹配的是整个组,except*拆组匹配成员,两者语义不同,别混用着写。asyncio.TaskGroup、concurrent相关接口抛的多异常就是这个结构,所以 3.11 之后写并发代码基本绕不开它。
自定义异常
最小写法就是继承一下 Exception:
1 | class MyError(Exception): |
值得自定义的场景是「调用方需要按业务类型分别处理」。推荐一个模块一个基类,业务异常都挂在它下面,再带个错误码:
1 | class AppError(Exception): |
输出:
1 | 捕获: [E1001] port 必须是整数 |
几条约定:
- 类名以
Error结尾,一眼看出是异常。 - 直接继承
Exception,别继承BaseException——那等于躲过了except Exception的兜底。 - 一个模块导出一个基类(示例的
AppError),内部异常继承它:调用方except AppError一网打尽,需要细分时再按子类分别接。 - 额外信息存成属性,同时把消息交给
super().__init__(message)。原因是pickle/copy会用*args重建实例,args里只放消息最稳。 - 别为了「分类」把同一层级的异常拆成十几个类;分类靠错误码或属性,比靠类继承层次更好维护。
断言 assert
-
基本用法
「断言」就是把自己的假设写成代码:
assert x > 0读作「我断言 x 一定大于 0」,不成立就当场抛错,好处是让 bug 在假设刚被破坏的地方暴露,而不是跑到远处才炸。语法是assert condition, message:条件为假时抛AssertionError,message成为异常文本。1
2
3
4
5
6
7
8
9
10
11print('__debug__ =', __debug__) # 普通运行是 True
def divide(a, b):
assert b != 0, '除数不能为零' # 失败时抛 AssertionError
return a / b
print('正常调用:', divide(10, 2))
try:
divide(10, 0)
except AssertionError as e:
print('断言失败:', e)输出:
1
2
3__debug__ = True
正常调用: 5.0
断言失败: 除数不能为零 -
-O会把断言整句删掉断言由
__debug__控制:python -O(或PYTHONOPTIMIZE=1/2)时__debug__为False,编译器直接把assert语句优化掉,不是「运行时跳过判断」。同一段代码用-O跑:1
2
3
4
5
6
7print('__debug__ =', __debug__)
def divide(a, b):
assert b != 0, '除数不能为零' # -O 下整句被跳过
return a / b
print('divide(10, 0) =', divide(10, 0))输出:
1
2
3
4
5
6
7
8
9__debug__ = False
Traceback (most recent call last):
File "<string>", line 7, in <module>
print('divide(10, 0) =', divide(10, 0))
~~~~~~^^^^^^^
File "<string>", line 5, in divide
return a / b
~~^~~
ZeroDivisionError: division by zero断言消失了,错误变成
ZeroDivisionError从更深的地方冒出来。所以不要用assert做参数校验或任何带副作用的检查——那是一段「生产环境会蒸发」的代码;校验请写if ...: raise ValueError(...)。 -
元组陷阱
1
2assert (False, '这条消息其实没用上') # 非空元组恒为真
print('这行照样执行了:assert 后面跟了个元组,等于永远不触发')输出:
1
2<string>:1: SyntaxWarning: assertion is always true, perhaps remove parentheses?
这行照样执行了:assert 后面跟了个元组,等于永远不触发assert (cond, msg)的条件是一个非空元组,恒为真,断言形同虚设。3.14 会直接发SyntaxWarning: assertion is always true, perhaps remove parentheses?——逗号写错了就该被看见。 -
什么时候适合用
内部不变量(「这个分支不可能走到」「这个值由调用方保证非空」)、调试期契约、单元测试。判断标准一句话:线上真的需要这个检查生效,就别用
assert。
警告 warnings
警告不是异常:默认只打印一次、不中断执行,但可以提升成异常。类上 Warning 是 Exception 的子类,所以被提升之后 except Warning 接得住。
1 | import warnings |
输出:
1 | 捕获到 1 条: DeprecationWarning - old_api 已废弃,请改用 new_api |
warnings.warn(msg, Category, stacklevel=2):stacklevel决定警告指向调用方那一行;写库时必须 +1,否则用户只看到库内部某一行。catch_warnings(record=True)用来在测试里断言「确实发了警告」。simplefilter('error', UserWarning)把指定类别升级为异常;等价的命令行用法是-W error,环境变量是PYTHONWARNINGS=error。- 常见类别:
DeprecationWarning(已弃用,默认对普通用户隐藏)、PendingDeprecationWarning(即将弃用)、FutureWarning(行为会变,建议现在就改)、SyntaxWarning(可疑语法)、RuntimeWarning(可疑运行行为)、ResourceWarning(资源没关干净,-W error::ResourceWarning查泄漏很好用)、EncodingWarning(3.10+,未显式指定编码的文本 IO)。
整体示例:一次调用的完整错误路径
把前面的点串起来:类型转换异常 → 打包成业务异常并保留起因(raise ... from)→ else 里做开发期自检 → finally 里发弃用警告 → 调用方按业务基类捕获、按 __cause__ 打起因。
1 | import warnings |
输出:
1 | <string>:31: DeprecationWarning: charge 即将下线 |
四条读法:
'abc'那条展示了异常链:外层看到AmountError,__cause__里留着真正的ValueError。finally里的警告只打印一次(默认过滤器按「位置 + 消息」去重),但清理逻辑每次调用都会执行。0那条是主动raise的业务异常,没有起因,所以__cause__为空、不打第二行。- 循环里用
except BaseException兜底只是为了演示;真实代码要收窄到具体类型,否则连 Ctrl+C 都停不下来。
参考
- 官方教程的错误与异常一章
- 内建异常清单与继承树(
library/exceptions.html已 301 到这里) - 警告机制
assert语句的语法定义