异常处理与异常组
写代码这件事,最让新手崩溃的瞬间是哪种?各位先别急着回答,我来描述一个场景:你刚学完语法,兴冲冲写了几十行代码,按下回车,结果终端弹出一坨红字:
Traceback (most recent call last):
File "main.py", line 5, in <module>
print(10 / 0)
ZeroDivisionError: division by zero
一脸懵。这玩意儿是啥?我哪里写错了?为啥程序就崩了?
其实啊,这就是 Python 的「异常机制」在工作。程序遇到了它处理不了的情况,比如除以零、读不存在的文件、把字符串当数字加,于是它就「抛」一个异常出来,然后整个程序停下来,告诉你:「我不干了,你来收拾」。
可是各位想想,真实世界里的程序怎么可能因为一个小问题就停摆?银行 ATM 你输错密码三次它会提示,不会直接死机;网页加载图片失败会显示一个占位图,不会整页白屏;微信收消息网络不通它会重试,不会闪退。这些场景背后,全都是「异常处理」在做事。
Python 早就给各位准备好了一整套工具:try / except / else / finally / raise / 自定义异常类,再加上 3.11 之后引入的「异常组」(ExceptionGroup)和 except*,整套机制非常完备。今天这篇,咱们就把这套机制从最朴素的写法一路捋到最新的 PEP 654,确保各位看完之后,再也不怕红字 traceback。
程序为什么会「炸」¶
我们先从最简单的例子开始。打开 Python 解释器,敲一行:
输出:
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
ZeroDivisionError: division by zero
这就叫「异常」。Python 在执行 10 / 0 的时候发现:除数是零,这事儿数学上没法干,于是它创建了一个 ZeroDivisionError 对象,把这个对象「抛」出来。如果没人接,这个异常就会一路冒泡到最外层,最后由 Python 解释器接住,打印 traceback,然后退出程序。
各位再看一个:
输出:
Traceback (most recent call last):
File "<stdin>", line 2, in <module>
IndexError: list index out of range
IndexError,下标越界。再来一个:
输出:
KeyError,字典里没这个键。
各位发现了吧?每一种「错误」都有它自己的名字。这些名字就是异常的「类型」,是 Python 内置的一组类。它们不是给你看着玩的,是供你用来「精确捕捉」的——不同类型用不同的处理方式。
最朴素的 try / except¶
来,我们把第一段代码改造一下。我们不希望除以零让整个程序崩溃,而是希望它在崩之前给出一句友好的提示,然后继续往下走:
输出:
是不是发现,红字 traceback 没了,程序也没崩,最后那句「程序继续运行」也乖乖打印出来了。
try / except 的语法就这么简单,记住三件事:
try:块里放「可能出问题」的代码except XxxError:块里放「出了问题该怎么办」的代码- 如果
try块没出错,except块完全跳过,跟没写一样
各位再想想:如果 try 块里有十行代码,第三行炸了,后面七行还会执行吗?
答案是:不会。Python 一旦在 try 里发现异常,就立刻跳到对应的 except,try 里剩下的代码全部跳过。我们写个例子验证一下:
try:
print('第一行')
print(10 / 0)
print('第三行——这一行不会被执行')
except ZeroDivisionError:
print('我接住了')
print('程序继续')
输出:
果然,「第三行」没打印出来。这个细节非常重要,很多新手 debug 半天找不到问题,就是因为以为 try 里抛了异常之后下面还会跑——不会的,跳过了。
一次捕获多种异常¶
刚才的例子只捕了 ZeroDivisionError,但实际写代码时,一段代码往往可能抛好几种异常。比如:
def parse_age(s):
try:
age = int(s)
return 100 / age
except ZeroDivisionError:
return '年龄不能是 0'
except ValueError:
return '输入的不是数字'
print(parse_age('25'))
print(parse_age('0'))
print(parse_age('abc'))
输出:
各位看到了吧?两个 except 分支,分别处理不同的异常类型。Python 会按顺序匹配,第一个匹配上的分支被执行,后面的跳过。
那如果两种异常想用同一段代码处理呢?写两次太啰嗦。有简便写法:
def parse_age(s):
try:
age = int(s)
return 100 / age
except (ZeroDivisionError, ValueError):
return '输入有问题'
print(parse_age('25'))
print(parse_age('0'))
print(parse_age('abc'))
输出:
把多个异常类型用一对小括号括起来,逗号分隔,就能一锅端。注意这个括号,不写括号语法就不对了。
拿到异常对象——as e¶
各位光知道「出错了」还不够,有时候你需要知道「具体错在哪儿」。Python 允许你用 as 把异常对象抓出来:
def parse_age(s):
try:
age = int(s)
return 100 / age
except (ZeroDivisionError, ValueError) as e:
return f'出错啦:{type(e).__name__} - {e}'
print(parse_age('0'))
print(parse_age('abc'))
输出:
出错啦:ZeroDivisionError - division by zero
出错啦:ValueError - invalid literal for int() with base 10: 'abc'
e 就是异常对象本身。type(e).__name__ 是它的类名,str(e) 是它的错误信息。在做日志、做调试的时候,这种写法比简单打印「出错啦」要有用得多。
万能兜底——except Exception¶
有时候,各位不知道代码里到底会抛什么异常,又不想写一长串 except。这时候可以用一个「父类」来兜底:
Exception 是绝大多数异常的「祖宗」,写它就等于「能接住几乎所有异常」。
但是!这里有个坑:很多新手喜欢写 except: 后面什么类型都不写,或者写 except BaseException:。这两种写法都太狠了——它们会把 KeyboardInterrupt(你按 Ctrl+C 想中断程序)和 SystemExit(程序主动 sys.exit())也接住,导致你的程序按 Ctrl+C 都停不下来。
记住:兜底用 Exception,不要用 BaseException,更不要写裸 except:。这是江湖规矩。
异常的家族——继承层级¶
各位可能要问:「Exception 是几乎所有异常的祖宗,那它上面还有谁?下面又有哪些?」
来,咱们看一下异常的家族图谱:
BaseException
├── SystemExit
├── KeyboardInterrupt
├── GeneratorExit
└── Exception
├── ArithmeticError
│ ├── ZeroDivisionError
│ └── OverflowError
├── LookupError
│ ├── IndexError
│ └── KeyError
├── ValueError
├── TypeError
├── AttributeError
├── NameError
├── OSError
│ └── FileNotFoundError
└── ...
这棵树读起来要点有几个:
- 最顶层是
BaseException,所有异常都继承自它 SystemExit/KeyboardInterrupt/GeneratorExit这三个跟「程序流程控制」相关,不应该被普通的try/except接住- 其他业务相关的异常都在
Exception下面 Exception又分成几个大家族:算术错误、查找错误、值错误、类型错误等等
理解了这个层级,各位就能玩一个小技巧了:捕获父类等于捕获所有子类。比如:
输出:
IndexError 是 LookupError 的子类,所以用父类去接子类,完全没问题。
那么各位再思考一下:如果 except 写了多个,顺序应该「父在前」还是「子在前」?
# 错误示范
try:
nums = [1, 2, 3]
print(nums[10])
except LookupError:
print('查找错误')
except IndexError: # 这一行永远跑不到
print('下标错误')
LookupError 已经把 IndexError 一锅端了,后面那个 except IndexError 形同虚设。所以原则是:子在前,父在后。具体的异常先写,宽泛的兜底放最后。
else——没出错的时候才走¶
try / except 还有两个好搭档:else 和 finally。我们先看 else。
def divide(a, b):
try:
result = a / b
except ZeroDivisionError:
print('除数不能是零')
else:
print(f'计算成功,结果是 {result}')
divide(10, 2)
divide(10, 0)
输出:
else 块只在 try 块没有抛异常的时候执行。各位可能要问:「我把 print(f'...') 直接写在 try 里不也一样吗?」
不一样。区别在于「捕获范围」。各位看:
def divide(a, b):
try:
result = a / b
print(f'计算成功,结果是 {result}') # 假设这里也可能抛异常
except ZeroDivisionError:
print('除数不能是零')
如果 print 那一行也抛了 ZeroDivisionError(举例而已),它会被同一个 except 接住——可这就误伤了,因为你的本意是只接住「计算」那一步的错误。
else 把「成功后才做的事」隔离出来,让 try 块里只放「真正可能出错的那一行」,逻辑更干净。这是它的核心价值。
finally——不管啥情况都得跑¶
finally 块更狠:不管 try 块成功还是失败,不管异常被接住还是没被接住,finally 一定会执行。
def read_first_line(path):
f = open(path, 'r')
try:
return f.readline()
finally:
f.close()
print(f'已关闭 {path}')
为什么需要 finally?想想各位平时写代码:打开文件、连数据库、申请资源……这些操作完事儿都得「关闭」「释放」。如果中间出错了直接跳走,资源就泄露了。finally 就是用来确保「无论如何这段清理代码都得跑」的。
finally 还能跟 except 一起用:
def safe_read(path):
try:
f = open(path, 'r')
return f.readline()
except FileNotFoundError:
print(f'文件 {path} 不存在')
return None
finally:
print('清理工作执行了')
print(safe_read('not_exist.txt'))
输出:
各位发现了吗?「清理工作执行了」这句话,无论 try 出不出错,都跑了。
不过呢,开文件这种场景,现代 Python 推荐用 with 语句,它内部就用了 finally 的机制,写法更简洁:
def safe_read(path):
try:
with open(path, 'r') as f:
return f.readline()
except FileNotFoundError:
print(f'文件 {path} 不存在')
return None
with 自带「无论如何都关文件」的能力,本质上跟 finally 是一回事。
主动抛异常——raise¶
到目前为止各位都是在「接」异常,那能不能「抛」一个?当然能。raise 关键字就是干这个的。
def set_age(age):
if age < 0:
raise ValueError(f'年龄不能是负数,你传了 {age}')
if age > 200:
raise ValueError(f'年龄不能超过 200,你传了 {age}')
return age
try:
set_age(-5)
except ValueError as e:
print(f'参数有问题:{e}')
输出:
raise XxxError('msg') 的意思是「我现在就抛一个 XxxError,里面带这条消息」。各位平时写函数的时候,遇到「调用方传的参数不合理」「业务规则不满足」这种情况,就应该主动 raise,让调用方知道「你这数据我不收」,而不是闷头返回一个奇怪的值。
为啥?想想看:
调用方拿到 None,根本不知道是「这次没数据」还是「我传错了」。但是 raise ValueError('年龄不能是负数'),调用方一看就明白:「啊,我传的参数不合规」。
异常是 Python 里传递错误信息的标准方式,比 return None、return -1、return False 表达力强得多。
异常链——raise ... from e¶
各位再来看一个稍微高级的场景。假设有这么一个函数:
如果 path 不存在,会抛 FileNotFoundError。但是站在「使用配置」这一层来看,「文件不存在」这个错误太底层了。我希望对外抛一个更业务化的错误,比如 ConfigError('配置加载失败'),但又不想丢掉「底层到底是啥错」这个信息。怎么办?
PEP 3134 给出了答案——「异常链」:
class ConfigError(Exception):
pass
def load_config(path):
try:
with open(path, 'r') as f:
return f.read()
except FileNotFoundError as e:
raise ConfigError(f'配置加载失败:{path}') from e
try:
load_config('not_exist.json')
except ConfigError as e:
print(f'业务错误:{e}')
输出:
注意 raise ConfigError(...) from e 这个写法。from e 就是把「原始异常」挂在新异常的 __cause__ 上。如果不被外层接住,traceback 会同时打印两层错误,长这样:
Traceback (most recent call last):
File "...", line X, in load_config
with open(path, 'r') as f:
FileNotFoundError: [Errno 2] No such file or directory: 'not_exist.json'
The above exception was the direct cause of the following exception:
Traceback (most recent call last):
File "...", line Y, in <module>
load_config('not_exist.json')
File "...", line Z, in load_config
raise ConfigError(...) from e
ConfigError: 配置加载失败:not_exist.json
「The above exception was the direct cause of the following exception」——这句话就是异常链的标志。各位看到这种 traceback,要知道:底下那个错才是根因,上面那个错是它包装出来的。
那如果不写 from e,直接 raise ConfigError(...) 呢?Python 会自动给你加一个 __context__,traceback 里会写「During handling of the above exception, another exception occurred」(在处理上面那个异常的过程中,又抛了下面这个异常)。这种叫「隐式链」。
显式 from e 表达的是「我故意把底层异常包装成业务异常」;不写 from e 表达的是「我处理上一个异常的时候不小心又出错了」。语义不同,建议各位显式包装的时候一定加 from e。
自定义异常类¶
刚才那个 ConfigError 各位看到了吧?自定义异常类其实就一行:
继承 Exception 就行。但是为啥要自定义?直接用内置的 ValueError、RuntimeError 不行吗?
来,咱们用一个「打卡」业务场景说明。「水哥」在「做鸭事业部」搞了个打卡系统,业务规则有:
- 还没到上班时间,不能打卡
- 已经打过卡了,不能重复打
- 不在公司网络,不能打卡
如果用内置异常:
def punch(user, time, ip):
if time < 9:
raise ValueError('未到上班时间')
if user.already_punched_today:
raise ValueError('今天已经打过卡了')
if not ip.startswith('192.168.'):
raise ValueError('不在公司网络')
调用方接到 ValueError,根本分不清是哪种情况。如果想分别处理,只能去字符串匹配——这非常脆弱。
自定义异常体系:
class PunchError(Exception):
"""打卡相关错误的基类。"""
pass
class PunchTimeError(PunchError):
pass
class PunchDuplicateError(PunchError):
pass
class PunchLocationError(PunchError):
pass
def punch(user_already_punched, hour, ip):
if hour < 9:
raise PunchTimeError('未到上班时间')
if user_already_punched:
raise PunchDuplicateError('今天已经打过卡了')
if not ip.startswith('192.168.'):
raise PunchLocationError('不在公司网络')
return '打卡成功'
# 调用方
try:
punch(False, 8, '192.168.1.1')
except PunchTimeError as e:
print(f'时间问题:{e},请等到 9 点')
except PunchDuplicateError as e:
print(f'已打卡:{e}')
except PunchLocationError as e:
print(f'位置问题:{e},请连公司 wifi')
except PunchError as e:
print(f'打卡失败:{e}')
输出:
各位看到了吧?
- 每种错误有自己的类,调用方可以精确捕获
- 一个公共父类
PunchError,调用方也可以一锅端兜底 - 类名本身就是文档——不用看消息,光看类名就知道出啥事了
这就是自定义异常类的价值——让错误也变成代码结构的一部分。
那自定义异常类还可以带数据,比如:
class PunchError(Exception):
def __init__(self, message, user_id=None, hour=None):
super().__init__(message)
self.user_id = user_id
self.hour = hour
try:
raise PunchError('未到上班时间', user_id=123, hour=8)
except PunchError as e:
print(f'消息:{e}')
print(f'用户:{e.user_id}')
print(f'时间:{e.hour}')
输出:
需要更多上下文信息时,把它放进异常对象。后端写日志、做监控的时候,这种结构化错误就特别值钱。
内置异常速查表¶
各位上面看了一些,可能还想问「常见的内置异常都有哪些?」我整理了一份速查表,工作里 90% 的场景都能覆盖:
| 异常类 | 触发场景 | 例子 |
|---|---|---|
ValueError |
类型对了但值不合法 | int('abc') |
TypeError |
类型不对 | 'a' + 1 |
KeyError |
字典里没这个键 | {'a': 1}['b'] |
IndexError |
序列下标越界 | [1, 2][5] |
AttributeError |
对象没这个属性 | 'abc'.foo |
NameError |
引用了未定义的变量 | print(undefined_x) |
FileNotFoundError |
文件不存在 | open('not_exist.txt') |
ZeroDivisionError |
除以零 | 1 / 0 |
ImportError |
导入失败 | import not_exist_module |
ModuleNotFoundError |
模块找不到(ImportError 子类) |
import not_exist_module |
RuntimeError |
运行时通用错误 | 用得不多,能避就避 |
NotImplementedError |
抽象方法没实现 | 接口/抽象类里常用 |
StopIteration |
迭代器没了 | 一般 Python 自己处理 |
OSError |
操作系统级错误 | 比如读写权限不够 |
PermissionError |
权限不足(OSError 子类) |
写只读文件 |
RecursionError |
递归太深 | 没写终止条件的递归 |
KeyboardInterrupt |
用户按 Ctrl+C | 这个别接住 |
SystemExit |
sys.exit() 抛的 |
这个也别接住 |
最后那两个再强调一下:KeyboardInterrupt 和 SystemExit 都是 BaseException 的直接子类,不是 Exception 的子类。所以正常写 except Exception 是接不到它们的——这是好事,避免误伤。
一次想报告多个错误?——异常组(Python 3.11+)¶
各位看到这里,常规的异常处理已经全会了。但接下来要讲一个比较新的特性,是 Python 3.11 引入的「异常组」(PEP 654)。
先抛痛点。假设「水哥」在「做鸭事业部」搞了个批量任务:要给五个 URL 发请求,把结果都存下来。代码大概长这样:
def fetch_one(url):
# 模拟可能失败
if 'bad' in url:
raise ValueError(f'参数不对:{url}')
if 'down' in url:
raise ConnectionError(f'连不上:{url}')
return f'{url} 的内容'
def fetch_all(urls):
results = []
for url in urls:
results.append(fetch_one(url))
return results
各位想想:如果 urls 里有两个会抛 ValueError、一个会抛 ConnectionError,传统写法会怎样?
第一个 URL 抛了之后,fetch_all 直接退出,剩下的根本没跑。后面那两个错你压根不知道。可你的业务需求是「一次性看到所有出错的 URL」,让 ops 同学一次修完,不用反复跑。
传统的 try/except 写法只能告诉你第一个错误,搞不定这种场景。各位说怎么办?
土办法是把每个 URL 的异常自己收集起来:
def fetch_all(urls):
results = []
errors = []
for url in urls:
try:
results.append(fetch_one(url))
except Exception as e:
errors.append(e)
return results, errors
能用,但是有几个问题:
- 调用方拿到
(results, errors)这个二元组,签名就丑了 - 错误以「列表」形式返回,调用方还得自己 if 判断、自己写循环报告
- 这套机制是「自己手搓」的,不同库各搞一套,不通用
PEP 654 就是来解决这事儿的。Python 3.11 引入了 ExceptionGroup,专门用来「把多个异常打包成一个」抛出去:
def fetch_all(urls):
results = []
errors = []
for url in urls:
try:
results.append(fetch_one(url))
except Exception as e:
errors.append(e)
if errors:
raise ExceptionGroup('部分 URL 抓取失败', errors)
return results
调用一下:
urls = ['ok-1', 'bad-2', 'down-3', 'ok-4', 'bad-5']
try:
fetch_all(urls)
except ExceptionGroup as eg:
print(f'共有 {len(eg.exceptions)} 个错误')
for e in eg.exceptions:
print(f' - {type(e).__name__}: {e}')
输出(在 Python 3.11+ 上):
这就是异常组的核心:一次抛出一组异常,调用方一次性拿到所有错误。
需要 Python 3.11+,老版本运行不了。各位的 Python 是不是 3.11+ 直接 python3 --version 就能看到。
用 except* 拆解异常组¶
各位可能要问:「我接住 ExceptionGroup 之后,能不能像普通 except 那样按异常类型分别处理?」
可以。Python 3.11 同时引入了一个新语法:except*(带星号)。它专门用来按类型拆分异常组。来看:
def fetch_all(urls):
results = []
errors = []
for url in urls:
try:
results.append(fetch_one(url))
except Exception as e:
errors.append(e)
if errors:
raise ExceptionGroup('部分 URL 抓取失败', errors)
return results
urls = ['ok-1', 'bad-2', 'down-3', 'ok-4', 'bad-5']
try:
fetch_all(urls)
except* ValueError as eg:
print(f'参数错误 {len(eg.exceptions)} 个:')
for e in eg.exceptions:
print(f' - {e}')
except* ConnectionError as eg:
print(f'连接错误 {len(eg.exceptions)} 个:')
for e in eg.exceptions:
print(f' - {e}')
输出:
各位看到了吗?两个 except* 分支,自动把 ExceptionGroup 按类型拆成了两份,分别处理。这是普通 except 做不到的——普通 except 一旦匹配就跑一次然后结束,但 except* 会遍历整个组,把符合类型的都挑出来。
except* 的几个关键点:
- 语法是
except* ExceptionType:,注意星号紧贴except - 接收到的
eg是一个新的ExceptionGroup,里面只装匹配的异常 - 所有
except*分支都会被检查(不是只跑第一个),不匹配的异常会被「重新打包」继续往外抛 - 不能跟普通的
except混用,try里只能要么全是except、要么全是except*
那么各位再想想:如果异常组里既有 ValueError 又有 OSError,但你只写了 except* ValueError,那 OSError 们会怎么样?
答:它们会被重新打包成一个新的 ExceptionGroup 继续抛出。这就是 except* 跟 except 最根本的区别——except 是「拿走一个」,except* 是「拿走匹配的一组,剩下的继续传」。
add_note——给异常加备注(Python 3.11+)¶
异常组旁边还有一个小特性,叫 add_note,也是 3.11 引入的,配合异常调试特别好用。
各位调试时是不是经常想:「这个 KeyError 到底是哪一行抛的?哪个用户、哪个请求触发的?」光看异常本身只有键名,没上下文。add_note 就是来加上下文的:
def process_user(user_id, data):
try:
return data['name']
except KeyError as e:
e.add_note(f'处理用户 {user_id} 时出错')
e.add_note(f'数据键:{list(data.keys())}')
raise
process_user(123, {'age': 18})
输出(traceback 末尾):
add_note 把字符串挂在异常对象的 __notes__ 列表上。traceback 打印时,会自动把这些 note 跟在异常消息后面。
为啥不直接 raise KeyError('处理用户 123 时出错,数据键:[age]') 重新抛一个?因为那样会丢失原始 traceback 的一部分信息,而 add_note 是「保留原异常 + 附加备注」,更轻量、更优雅。
add_note 可以调用多次,每次加一条 note。在调试库代码、写中间件的时候特别有用。
小实战:safe_divide 与批量除法¶
各位讲了这么多,咱们用一个小实战收个尾。需求是:
- 写一个
safe_divide(a, b)函数,正常返回结果,除零返回None,其他错误也返回None - 写一个
batch_divide(pairs)函数,对一组(a, b)做除法,把所有出错的批量上报,用ExceptionGroup
先写第一个:
def safe_divide(a, b):
try:
return a / b
except ZeroDivisionError:
print(f' 除零:{a} / {b}')
return None
except TypeError as e:
print(f' 类型错:{e}')
return None
print(safe_divide(10, 2))
print(safe_divide(10, 0))
print(safe_divide('a', 1))
输出:
朴素,但够用。再来批量版本——这个用异常组:
def divide_strict(a, b):
if b == 0:
raise ZeroDivisionError(f'{a} / 0')
if not isinstance(a, (int, float)) or not isinstance(b, (int, float)):
raise TypeError(f'{a!r} / {b!r} 类型不对')
return a / b
def batch_divide(pairs):
results = []
errors = []
for a, b in pairs:
try:
results.append(divide_strict(a, b))
except Exception as e:
errors.append(e)
if errors:
raise ExceptionGroup(f'批量除法有 {len(errors)} 个错', errors)
return results
pairs = [(10, 2), (10, 0), ('a', 1), (8, 4), (5, 0)]
try:
print(batch_divide(pairs))
except* ZeroDivisionError as eg:
print(f'除零错误 {len(eg.exceptions)} 个:')
for e in eg.exceptions:
print(f' - {e}')
except* TypeError as eg:
print(f'类型错误 {len(eg.exceptions)} 个:')
for e in eg.exceptions:
print(f' - {e}')
输出(Python 3.11+):
各位看到了吧?整个流程下来:
divide_strict严格抛异常,参数不对绝不偷偷返回Nonebatch_divide把所有异常收集起来,用ExceptionGroup一次性抛出- 调用方用
except*按类型分组,一次性把所有错误全报告完
这就是异常组带来的「部分失败」(partial failure)处理范式:该成功的成功,该失败的失败,所有错误一并上报。这种范式在 asyncio 并发、批量 RPC、并行抓取场景里特别常见,Python 3.11 之前都得自己手搓,3.11 之后就是语言级支持了。
嵌套的异常组——递归结构¶
各位再深入一点。ExceptionGroup 本身也是一个异常,那它能不能装进另一个 ExceptionGroup?
可以的。异常组是一个树形结构——一个组里既能装普通异常,也能再装组。这种嵌套在「分布式任务、子任务再分子任务」的场景下很常见。
来个例子。假设「水哥」要批量更新员工信息,每个员工有「基础信息」和「打卡记录」两部分要分别更新,每一部分都可能失败:
def update_employee(emp):
sub_errors = []
try:
update_basic_info(emp)
except Exception as e:
sub_errors.append(e)
try:
update_punch_record(emp)
except Exception as e:
sub_errors.append(e)
if sub_errors:
raise ExceptionGroup(f'员工 {emp} 更新失败', sub_errors)
def batch_update(employees):
errors = []
for emp in employees:
try:
update_employee(emp)
except Exception as e:
errors.append(e)
if errors:
raise ExceptionGroup('批量更新部分失败', errors)
这种结构出来的异常组就是嵌套的,最外层那个组里装的是若干「员工 X 更新失败」的子组。except* 厉害的地方是:它能穿透任意嵌套层级,把指定类型的异常全部拎出来。
try:
batch_update(employees)
except* ConnectionError as eg:
print(f'网络相关错误共 {len(eg.exceptions)} 个分组')
except* ValueError as eg:
print(f'参数相关错误共 {len(eg.exceptions)} 个分组')
不管 ConnectionError 是装在第一层还是第二层,except* 都会找出来。这点是它跟普通 except 最大的区别。
eg.subgroup 与 eg.split——手动玩异常组¶
except* 已经够用了,但有时候各位希望更精细的控制——比如只接管「某个特定条件」的异常,而不是简单按类型分组。ExceptionGroup 提供了两个实用方法:subgroup 和 split。
errors = [
ValueError('坏值 1'),
TypeError('坏类型'),
ValueError('坏值 2'),
ConnectionError('断网'),
]
eg = ExceptionGroup('一堆错', errors)
# subgroup 按条件提取
value_eg = eg.subgroup(ValueError)
print(f'ValueError 子组:{len(value_eg.exceptions)} 个')
# split 按条件拆成「匹配的 / 不匹配的」两份
matched, rest = eg.split(ValueError)
print(f'匹配:{len(matched.exceptions)} 个;剩下:{len(rest.exceptions)} 个')
输出(Python 3.11+):
subgroup 跟 split 都接收一个类型或者判断函数作为参数。判断函数版本特别灵活:
这就把「错误消息里包含『坏』字」的全挑出来了。各位写复杂的中间件、调度器代码时,这两个方法非常有用。
别犯这些反模式¶
各位见过的「异常处理写糟」的代码,几乎都集中在下面这几种反模式。我挨个说,每个都给出「错」「对」对照。
反模式一:吞异常¶
这叫「吞异常」(exception swallowing)。出错了你既不打日志、也不重抛、也不返回特殊值——出问题永远查不到根因。
至少要打日志。如果决定不重抛,必须有明确的业务理由——而且强烈建议加注释说明「为啥不重抛」。
反模式二:抓得太宽¶
这一坨 try 里有两件事:转换、入库。哪一步错都被一锅端,错误信息丢了。
# 对
try:
age = int(input('年龄:'))
except ValueError:
print('请输入合法数字')
return
try:
save_to_db(age)
except OSError as e:
print(f'数据库写入失败:{e}')
return
每个 try 块只包一件事,捕获它特有的异常类型。这就是异常处理的「最小作用域」原则。
反模式三:用异常做流程控制¶
不是说不行,而是「用 if / else 能写」的逻辑就别用异常。异常的开销比普通分支大,而且让代码意图变得隐晦。
那什么时候用异常合适?「正常情况下不该出错」——比如读文件、网络请求、用户输入校验。这些场景本质上是「错误处理」,用异常自然。
反模式四:忘记调用方需要原始信息¶
这把原始异常丢光了。调用方拿到 MyError,traceback 里没有 SomeError 的信息——根因丢失。
记得 from e。这是举手之劳,但能在生产事故里救你的命。
反模式五:用 except 替代类型检查¶
如果 x 的类型本来就该校验,直接校验比靠异常兜底更清晰。当然,对外部输入做兜底是合理的——内部逻辑不该这么写。
assert——调试期的轻量级断言¶
异常处理之外,Python 还有一个表亲叫 assert。它的作用是:在代码里插入「我假定 XX 一定成立」的检查,不成立就抛 AssertionError。
def calculate_average(nums):
assert len(nums) > 0, '空列表没法算平均值'
return sum(nums) / len(nums)
print(calculate_average([1, 2, 3]))
输出:
assert 的语法是 assert 表达式, 错误消息。表达式为假就抛 AssertionError。
但是!assert 有个重要的坑:用 python -O(优化模式)跑代码时,所有的 assert 语句都会被丢弃。所以绝对不能用 assert 做生产环境的输入校验。它只该用在「开发调试」「测试断言」「内部不变式(invariant)保护」这些场景。
错误示范:
def withdraw(account, amount):
assert amount > 0, '取款金额必须为正数' # 错——优化模式下检查就没了
account.balance -= amount
正确做法:
def withdraw(account, amount):
if amount <= 0:
raise ValueError('取款金额必须为正数')
account.balance -= amount
各位记住一个简单原则:assert 是给程序员看的,raise 是给程序看的。
traceback 模块——把错误打印得更友好¶
异常被接住之后,有时候各位想自己控制「错误怎么打印」。比如写日志、写 Web 后端的错误页面。Python 标准库的 traceback 模块就是干这个的。
import traceback
def do_work():
return 1 / 0
try:
do_work()
except ZeroDivisionError:
# 把完整的 traceback 转成字符串
s = traceback.format_exc()
print('--- 错误日志 ---')
print(s)
print('--- 结束 ---')
输出:
--- 错误日志 ---
Traceback (most recent call last):
File "...", line 8, in <module>
do_work()
File "...", line 5, in do_work
return 1 / 0
~~^~~
ZeroDivisionError: division by zero
--- 结束 ---
traceback.format_exc() 把当前正在处理的异常的完整 traceback 转成字符串。这个字符串你想存日志、想发邮件、想入库,都可以。
类似的还有:
traceback.print_exc():直接打印到stderrtraceback.format_exception(exc):传入异常对象,转 list of strtraceback.print_exception(exc):传入异常对象,打印到stderr
写自己的中间件、写日志框架时,这些函数特别好用。
logging.exception——日志推荐写法¶
各位生产代码里基本都会用 logging 模块。它对异常有专门的支持:
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
def risky():
return 1 / 0
try:
risky()
except Exception:
logger.exception('调用 risky 失败')
输出(节选):
ERROR:__main__:调用 risky 失败
Traceback (most recent call last):
File "...", line 11, in <module>
risky()
File "...", line 7, in risky
return 1 / 0
~~^~~
ZeroDivisionError: division by zero
logger.exception(msg) 等价于 logger.error(msg, exc_info=True)。它会自动把当前异常的 traceback 附加在日志里。
各位写后端服务记住这一条:接异常的地方,几乎总是要 logger.exception 一下,否则线上排查问题会让你哭出来。
warnings——比异常温柔的提醒¶
异常是「炸了」,但有时候你想表达的是「能跑,但不推荐」。比如某个老 API 准备废弃,或者某个参数组合存在性能问题。这时候用 Python 的 warnings 模块。
import warnings
def old_api(x):
warnings.warn('old_api 已废弃,请改用 new_api', DeprecationWarning)
return x * 2
print(old_api(3))
输出(stderr 部分):
warnings.warn(消息, 类别) 是「温柔的告知」——程序照常往下跑,但调用方能在 stderr 看到提示。
warnings 跟异常的关键差别:warnings 默认不会让程序中断。但是各位可以通过 warnings.filterwarnings('error') 把它升级成异常,方便测试时强制清理废弃用法。
写库给别人用的童鞋,这个东西一定要会,比直接 raise 友好得多。
try/except 在表达式里——不能写¶
各位可能用过其他语言的 try 表达式,比如 Rust 的 ?、Kotlin 的 runCatching。Python 不提供这种语法。在 Python 里 try/except 只能是语句,不能写成表达式:
要写一行版的「转换失败就给默认值」,标准做法是封装一个小工具函数:
def safe_int(x, default=0):
try:
return int(x)
except (TypeError, ValueError):
return default
print(safe_int('42'))
print(safe_int('abc'))
print(safe_int(None, default=-1))
输出:
「Pythonic 的写法是封装一个小函数」,各位记住这点。
EAFP vs LBYL——Python 的哲学¶
最后说一下两种风格的对比,理解这个对各位组织代码很有帮助。
- LBYL(Look Before You Leap,跳之前先看看):先用
if判断条件成立,再做操作 - EAFP(Easier to Ask Forgiveness than Permission,先做了再道歉):直接做操作,出错了用
try/except兜底
举个例子,「打开文件读取内容」:
# LBYL
import os
if os.path.exists('config.json'):
with open('config.json', 'r') as f:
data = f.read()
else:
data = ''
很多其他语言(如 Java)偏向 LBYL,但 Python 文化偏向 EAFP。原因有两个:
- LBYL 在并发场景下有「检查后失效」的风险(比如
os.path.exists通过了,但下一秒文件被别人删了,open还是会炸) - EAFP 写出来更接近「自然语言」——「我直接干了,干不了再说」
当然不是所有场景都适合 EAFP。比如查字典时用 dict.get(key, default) 比 try / except KeyError 更清晰。各位的判断标准是:
- 正常情况下大概率成功,偶尔失败 → 用 EAFP(异常)
- 正常情况下经常需要分支 → 用 LBYL(
if/else)
最后碎碎念几句¶
把今天的内容捋一遍:
try / except是 Python 处理错误的核心结构,记住「子在前,父在后」- 多异常用
except (A, B)一锅端;用as e拿到异常对象 - 异常有家族层级,捕父类就等于捕所有子类;兜底用
Exception,别用裸except: else是「没出错才走」,finally是「无论如何都走」raise主动抛异常,raise ... from e显式异常链,调试时能找到根因- 自定义异常类 让错误也变成代码结构的一部分,业务复杂时是必备
ExceptionGroup+except*(Python 3.11+)解决「一次报告多个错误」的痛点add_note(Python 3.11+)给异常加调试上下文,比重新抛更优雅
异常处理写得好不好,是新手和老手最大的分水岭之一。新手要么完全不写——程序一炸就完蛋;要么乱写——except Exception: pass 把所有错误都吞了,出问题神仙都救不回来。老手会精确捕捉、合理传递、必要时包装、绝不偷偷吞。
下一篇我们继续往前走,讲讲生成器和迭代器,让各位的代码再上一个台阶。各位加油!