8. 错误与异常
对应官方文档:8. Errors and Exceptions 一句话导读:这一章解决「程序出错了怎么办」——先学会看懂那一片红字,再用 try、finally、with 把该兜的兜住、该收的收好,学完能写出不轻易崩的程序。
到目前为止,报错信息我们基本是「看一眼就跳过」。但真写项目时,能不能读 traceback、能不能在出错时优雅收尾,是新手和能干活的分界线。官方把错误分成两大类:语法错误(还没跑就被拦下)和异常(跑着跑着出的事)。村长严格按 8.1 到 8.10 的编号走,所有演示异常的代码都用 try 接住,复制粘贴能直接跑完,不会真的把终端刷成一片红。
8.1 语法错误
语法错误(也叫解析错误)是你写代码时最常撞见的:漏了冒号、括号没配对、关键字拼错,解析器在读源码阶段就拒绝执行。它会把出错的那一行原样重复一遍,并用 ^ 指到大致位置,最前面还有文件名和行号。
>>> while True print('Hello world')
File "stdin", line 1
while True print('Hello world')
^^^^^
SyntaxError: invalid syntax
上面是官方的经典例子:while True 后面少了个冒号,解析器把箭头指到了 print。注意 caret 只是「解析器发现的第一个不对劲的地方」,真正要改的地方可能在它左边一点。语法错误不属于运行时异常,你没法用 try 把它接住——因为代码压根没开始跑。
8.2 异常
代码语法没问题,执行到某一步却做不下去了,这时抛出的叫异常。官方给了三个最常见的结果(中间两个例子的 Traceback 头与第一个相同,这里省略):
>>> 10 * (1/0)
Traceback (most recent call last):
File "stdin", line 1, in module
ZeroDivisionError: division by zero
>>> 4 + spam*3
NameError: name 'spam' is not defined
>>> '2' + 2
TypeError: can only concatenate str (not "int") to str
读 traceback 有个固定套路:从最后一行往上读。最后一行给出异常类型(内置异常名,如 ZeroDivisionError)和一句人话说明;上面那串 File ... line ... 是调用栈,告诉你错误是从哪一层一路冒上来的。异常类型其实是一堆继承关系里的类,绝大多数日常异常都继承自 Exception。
for expr in [lambda: 10 * (1 / 0), lambda: 4 + spam * 3, lambda: '2' + 2]:
try:
expr()
except Exception as exc:
print(type(exc).__name__, '->', exc)
8.3 处理异常
try 块里放「可能出事」的代码,except 接住特定异常,else 在没出异常时执行,finally 无论如何都执行。一个 except 可以用元组接多种异常,还可以用 as 把异常对象绑到变量上读取信息。
def divide(x, y):
try:
result = x / y
except ZeroDivisionError as exc:
print('① 除零了:', exc)
except (TypeError, ValueError):
print('② 类型或值不对')
else:
print('③ 没异常才执行,结果是', result)
return result
finally:
print('④ finally 永远执行')
for args in [(10, 2), (10, 0), ('10', 2)]:
divide(*args)
执行顺序可以这样理解:try 跑完没异常 → 跳过所有 except → 进 else → 进 finally;中途抛了异常 → 从上往下找第一个匹配的 except,命中就执行它(其余分支跳过)→ 进 finally;谁都没接住 → 异常继续往外冒。官方还特意推荐 else:把「只有成功时才该做的事」放进去,就不会被 try 里的 except 误捕获。
def log_and_reraise():
try:
1 / 0
except ZeroDivisionError as exc:
print('记录日志后原样抛出:', exc)
raise # 不带参数的 raise:把当前异常原样再抛出去
try:
log_and_reraise()
except ZeroDivisionError as exc:
print('外层接到:', type(exc).__name__)
村长划重点:别写光秃秃的
except:。它会连KeyboardInterrupt(你按 Ctrl+C)和SystemExit一起吞掉,程序变得关不掉、还查不出问题。至少要写except Exception as exc:并且把exc打印或记进日志。另外except ... as exc里的exc在块结束时会被自动删掉(为了断开引用环),别指望出了块还能用。
8.4 抛出异常
raise 用来主动抛异常:可以抛一个异常类(自动实例化),更常见是抛一个带消息的实例。异常对象的值放在 .args 里,str(exc) 就是那句人话。
try:
raise NameError('HiThere')
except NameError as exc:
print('显式抛出:', type(exc).__name__, '| args =', exc.args)
真正有用的是「转译」:底层抛了 ValueError,你想在上层换成语义更清楚的 RuntimeError,用 raise 新异常 from 原异常 把因果挂上去,异常对象会多一个 __cause__。
def convert(value):
try:
return int(value)
except ValueError as exc:
raise RuntimeError('转换失败,已包装为更高层错误') from exc
try:
convert('abc')
except RuntimeError as exc:
print('外层:', exc)
print('直接原因 __cause__:', type(exc.__cause__).__name__, '|', exc.__cause__)
8.5 异常链
其实不用 from 也有链:在 except 块里抛新异常时,Python 会把刚处理的那个自动存进 __context__,traceback 里打印「During handling of the above exception, another exception occurred」,这叫隐式链。写了 raise ... from exc 则是显式链,打印「The above exception was the direct cause of...」。嫌上下文太吵,可以用 from None 把它藏起来。
def make(use_none):
try:
1 / 0
except ZeroDivisionError:
if use_none:
raise ValueError('隐藏上下文') from None
raise ValueError('隐式链:处理过程中又抛了新异常')
for flag in (False, True):
try:
make(flag)
except ValueError as exc:
print('from None =', flag,
'| 上下文:', type(exc.__context__).__name__ if exc.__context__ else None,
'| 隐藏:', exc.__suppress_context__)
村长划重点:三个写法别混——
raise原样再抛;raise B from A把 A 记为直接原因(__cause__);在except里直接raise B只记上下文(__context__);raise B from None则是「别显示上下文」。排查多层包装的错误时,__cause__和__context__是顺着藤摸瓜的关键。
8.6 用户自定义异常
项目里光用内置异常不够表达业务含义,就自己定义类:继承 Exception(不要直接继承 BaseException),名字按约定以 Error 结尾。类里可以加属性,但官方建议保持简单——多个相关异常最好共用一个基类,方便调用方一次 except 全接住。
class MyError(Exception):
"""自定义异常:带一个业务状态码。"""
def __init__(self, message, code):
super().__init__(message)
self.code = code
try:
raise MyError('接口返回异常', 503)
except MyError as exc:
print('捕获:', exc, '| code =', exc.code)
print('是 Exception 的子类:', isinstance(exc, Exception))
异常类和其他类没两样,可以重写 __str__ 定制消息,但别忘了先 super().__init__(...) 把消息交给基类,args 才能正常工作。
8.7 定义清理动作
有些收尾动作「不管成功失败都得做」:关文件、释放锁、断开连接。finally 子句就是干这个的——return、break、continue、抛异常,都拦不住它执行;如果异常一直没人接,finally 跑完后异常会继续往外抛。
def work(flag):
try:
if flag:
raise ValueError('出错了')
return '正常返回'
finally:
print(' finally:不管怎样都收尾')
print(work(False))
try:
work(True)
except ValueError as exc:
print('外层捕获:', exc)
村长划重点:千万别在
finally里写return或break,它会把正在往外抛的异常悄悄吞掉,问题现场直接消失。下面with的__exit__里返回True也有同样的吞异常效果,非必要不要这么干。
8.8 预定义清理动作
with 语句把「申请资源 → 使用 → 释放资源」固化成语法。一个对象只要实现了 __enter__ 和 __exit__,就是上下文管理器:进入 with 时调 __enter__(返回值给 as 后的变量),退出时无论是否出错都调 __exit__,并把异常的类型、值、traceback 三个参数传进去。
class Managed:
def __enter__(self):
print(' __enter__:申请资源')
return self
def __exit__(self, exc_type, exc, tb):
print(' __exit__:异常类型 =', exc_type.__name__ if exc_type else None)
return False # 返回 True 才会「吞掉」异常
def do(self):
print(' 正在干活')
with Managed() as m:
m.do()
文件是最常见的现成上下文管理器,锁、socket、数据库连接也普遍支持这套协议:
import tempfile, os
with tempfile.TemporaryDirectory() as d:
path = os.path.join(d, 'note.txt')
with open(path, 'w', encoding='utf-8') as f:
f.write('with 保证文件被关闭\n')
print('出了 with 已自动关闭:', f.closed)
8.9 抛出与处理多个不相关异常
一批任务里同时冒出好几种互不相关的异常(并发、批量校验、多个回调),一个个 except 写不下,Python 3.11 起提供了 ExceptionGroup:把多个异常打包成一个抛出去;接收端用带星号的 except* 按类型把「子组」挑出来分别处理。
def check(values):
errors = []
for v in values:
if not isinstance(v, int):
errors.append(TypeError(f'{v!r} 不是整数'))
elif v < 0:
errors.append(ValueError(f'{v} 是负数'))
if errors:
raise ExceptionGroup('一批校验错误', errors)
try:
check([1, -2, 'x', -3, 4])
except* ValueError as eg:
print('只处理 ValueError:', [str(e) for e in eg.exceptions])
except* TypeError as eg:
print('只处理 TypeError:', [str(e) for e in eg.exceptions])
和普通 except 不同,每个匹配的 except* 分支都会执行一次,拿到的是一个只含该类异常的 ExceptionGroup(eg.exceptions 可遍历);没被任何分支接住的子异常会继续往外抛。ExceptionGroup 只能装 Exception 子类,想装 KeyboardInterrupt 这类得用 BaseExceptionGroup;事后还能用 group.subgroup(ValueError) 筛出子组。
村长划重点:
except*的星号不能省,写成普通except只会接到整个ExceptionGroup本身,里面的子异常一个都匹配不上。另外ExceptionGroup里的异常必须是实例(不能是异常类),这是它最容易报错的地方。
8.10 用 add_note 补充异常信息
有时候你不想改动异常消息(怕破坏已有的日志匹配规则),只想往上追加一点上下文,比如「这个值来自哪个用户」「当前处理到第几条记录」。Python 3.11 的 add_note() 就是干这个的:它把字符串挂到异常的 __notes__ 列表里,打印 traceback 时会自动附在后面。
def process(raw):
try:
return int(raw)
except ValueError as exc:
exc.add_note(f'补充:收到的原始值是 {raw!r}')
raise
try:
process('abc')
except ValueError as exc:
print('异常:', exc, '| 备注:', exc.__notes__)
add_note() 只接受字符串,可以调用多次,对普通异常和 ExceptionGroup 都生效。它的价值在于「不改语义、只补现场」:底层抛出来的还是原来的 ValueError,但排查问题的人多拿到了一条线索。
本章小结
- 语法错误在解析阶段报(
SyntaxError+ caret),异常在运行时抛;读 traceback 从最后一行往上读,最后一行是异常类型和说明。 try/except/else/finally各司其职:except可用元组接多种异常、as绑对象,else放「只在成功时做」的代码,finally必执行;别写裸except:。raise原样再抛,raise B from A记直接原因,隐式链记__context__,raise B from None隐藏上下文;自定义异常继承Exception、以Error结尾。- 清理资源优先用
with(__enter__/__exit__),finally兜底;两者里都别用return或返回True吞掉异常。 ExceptionGroup+except*(星号不能少)分类型处理一批无关异常,add_note()给异常追加__notes__备注而不改消息。