跳转至

异常处理与异常组

写代码这件事,最让新手崩溃的瞬间是哪种?各位先别急着回答,我来描述一个场景:你刚学完语法,兴冲冲写了几十行代码,按下回车,结果终端弹出一坨红字:

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 解释器,敲一行:

print(10 / 0)

输出:

Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ZeroDivisionError: division by zero

这就叫「异常」。Python 在执行 10 / 0 的时候发现:除数是零,这事儿数学上没法干,于是它创建了一个 ZeroDivisionError 对象,把这个对象「抛」出来。如果没人接,这个异常就会一路冒泡到最外层,最后由 Python 解释器接住,打印 traceback,然后退出程序。

各位再看一个:

nums = [1, 2, 3]
print(nums[10])

输出:

Traceback (most recent call last):
  File "<stdin>", line 2, in <module>
IndexError: list index out of range

IndexError,下标越界。再来一个:

d = {'name': '两点水'}
print(d['age'])

输出:

Traceback (most recent call last):
  File "<stdin>", line 2, in <module>
KeyError: 'age'

KeyError,字典里没这个键。

各位发现了吧?每一种「错误」都有它自己的名字。这些名字就是异常的「类型」,是 Python 内置的一组类。它们不是给你看着玩的,是供你用来「精确捕捉」的——不同类型用不同的处理方式。

最朴素的 try / except

来,我们把第一段代码改造一下。我们不希望除以零让整个程序崩溃,而是希望它在崩之前给出一句友好的提示,然后继续往下走:

try:
    result = 10 / 0
    print(result)
except ZeroDivisionError:
    print('哎呀,除数不能是零')

print('程序继续运行')

输出:

哎呀,除数不能是零
程序继续运行

是不是发现,红字 traceback 没了,程序也没崩,最后那句「程序继续运行」也乖乖打印出来了。

try / except 的语法就这么简单,记住三件事:

  • try: 块里放「可能出问题」的代码
  • except XxxError: 块里放「出了问题该怎么办」的代码
  • 如果 try 块没出错,except 块完全跳过,跟没写一样

各位再想想:如果 try 块里有十行代码,第三行炸了,后面七行还会执行吗?

答案是:不会。Python 一旦在 try 里发现异常,就立刻跳到对应的 excepttry 里剩下的代码全部跳过。我们写个例子验证一下:

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'))

输出:

4.0
年龄不能是 0
输入的不是数字

各位看到了吧?两个 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'))

输出:

4.0
输入有问题
输入有问题

把多个异常类型用一对小括号括起来,逗号分隔,就能一锅端。注意这个括号,不写括号语法就不对了。

拿到异常对象——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。这时候可以用一个「父类」来兜底:

try:
    risky_operation()
except Exception as e:
    print(f'未知错误:{e}')

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 又分成几个大家族:算术错误、查找错误、值错误、类型错误等等

理解了这个层级,各位就能玩一个小技巧了:捕获父类等于捕获所有子类。比如:

try:
    nums = [1, 2, 3]
    print(nums[10])
except LookupError as e:
    print(f'查找错误:{e}')

输出:

查找错误:list index out of range

IndexErrorLookupError 的子类,所以用父类去接子类,完全没问题。

那么各位再思考一下:如果 except 写了多个,顺序应该「父在前」还是「子在前」?

# 错误示范
try:
    nums = [1, 2, 3]
    print(nums[10])
except LookupError:
    print('查找错误')
except IndexError:  # 这一行永远跑不到
    print('下标错误')

LookupError 已经把 IndexError 一锅端了,后面那个 except IndexError 形同虚设。所以原则是:子在前,父在后。具体的异常先写,宽泛的兜底放最后。

else——没出错的时候才走

try / except 还有两个好搭档:elsefinally。我们先看 else

def divide(a, b):
    try:
        result = a / b
    except ZeroDivisionError:
        print('除数不能是零')
    else:
        print(f'计算成功,结果是 {result}')


divide(10, 2)
divide(10, 0)

输出:

计算成功,结果是 5.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'))

输出:

文件 not_exist.txt 不存在
清理工作执行了
None

各位发现了吗?「清理工作执行了」这句话,无论 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}')

输出:

参数有问题:年龄不能是负数,你传了 -5

raise XxxError('msg') 的意思是「我现在就抛一个 XxxError,里面带这条消息」。各位平时写函数的时候,遇到「调用方传的参数不合理」「业务规则不满足」这种情况,就应该主动 raise,让调用方知道「你这数据我不收」,而不是闷头返回一个奇怪的值。

为啥?想想看:

def set_age(age):
    if age < 0:
        return None      # 不好的写法
    return age

调用方拿到 None,根本不知道是「这次没数据」还是「我传错了」。但是 raise ValueError('年龄不能是负数'),调用方一看就明白:「啊,我传的参数不合规」。

异常是 Python 里传递错误信息的标准方式,比 return Nonereturn -1return False 表达力强得多。

异常链——raise ... from e

各位再来看一个稍微高级的场景。假设有这么一个函数:

def load_config(path):
    with open(path, 'r') as f:
        return f.read()

如果 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}')

输出:

业务错误:配置加载失败:not_exist.json

注意 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 各位看到了吧?自定义异常类其实就一行:

class ConfigError(Exception):
    pass

继承 Exception 就行。但是为啥要自定义?直接用内置的 ValueErrorRuntimeError 不行吗?

来,咱们用一个「打卡」业务场景说明。「水哥」在「做鸭事业部」搞了个打卡系统,业务规则有:

  • 还没到上班时间,不能打卡
  • 已经打过卡了,不能重复打
  • 不在公司网络,不能打卡

如果用内置异常:

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}')

输出:

时间问题:未到上班时间,请等到 9 点

各位看到了吧?

  • 每种错误有自己的类,调用方可以精确捕获
  • 一个公共父类 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}')

输出:

消息:未到上班时间
用户:123
时间:8

需要更多上下文信息时,把它放进异常对象。后端写日志、做监控的时候,这种结构化错误就特别值钱。

内置异常速查表

各位上面看了一些,可能还想问「常见的内置异常都有哪些?」我整理了一份速查表,工作里 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() 抛的 这个也接住

最后那两个再强调一下:KeyboardInterruptSystemExit 都是 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+ 上):

共有 3 个错误
  - ValueError: 参数不对:bad-2
  - ConnectionError: 连不上:down-3
  - ValueError: 参数不对:bad-5

这就是异常组的核心:一次抛出一组异常,调用方一次性拿到所有错误

需要 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}')

输出:

参数错误 2 个:
  - 参数不对:bad-2
  - 参数不对:bad-5
连接错误 1 个:
  - 连不上:down-3

各位看到了吗?两个 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 末尾):

KeyError: 'name'
处理用户 123 时出错
数据键:['age']

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))

输出:

5.0
  除零:10 / 0
None
  类型错:unsupported operand type(s) for /: 'str' and 'int'
None

朴素,但够用。再来批量版本——这个用异常组:

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+):

除零错误 2 个:
  - 10 / 0
  - 5 / 0
类型错误 1 个:
  - 'a' / 1 类型不对

各位看到了吧?整个流程下来:

  1. divide_strict 严格抛异常,参数不对绝不偷偷返回 None
  2. batch_divide 把所有异常收集起来,用 ExceptionGroup 一次性抛出
  3. 调用方用 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.subgroupeg.split——手动玩异常组

except* 已经够用了,但有时候各位希望更精细的控制——比如只接管「某个特定条件」的异常,而不是简单按类型分组。ExceptionGroup 提供了两个实用方法:subgroupsplit

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+):

ValueError 子组:2 个
匹配:2 个;剩下:2 个

subgroupsplit 都接收一个类型或者判断函数作为参数。判断函数版本特别灵活:

matched, rest = eg.split(lambda e: '坏' in str(e))

这就把「错误消息里包含『坏』字」的全挑出来了。各位写复杂的中间件、调度器代码时,这两个方法非常有用。

别犯这些反模式

各位见过的「异常处理写糟」的代码,几乎都集中在下面这几种反模式。我挨个说,每个都给出「错」「对」对照。

反模式一:吞异常

# 错
try:
    do_something()
except Exception:
    pass

这叫「吞异常」(exception swallowing)。出错了你既不打日志、也不重抛、也不返回特殊值——出问题永远查不到根因。

# 对
import logging

try:
    do_something()
except Exception:
    logging.exception('do_something 失败')
    raise

至少要打日志。如果决定不重抛,必须有明确的业务理由——而且强烈建议加注释说明「为啥不重抛」。

反模式二:抓得太宽

# 错
try:
    age = int(input('年龄:'))
    save_to_db(age)
except Exception:
    print('出错了')

这一坨 try 里有两件事:转换、入库。哪一步错都被一锅端,错误信息丢了。

# 对
try:
    age = int(input('年龄:'))
except ValueError:
    print('请输入合法数字')
    return

try:
    save_to_db(age)
except OSError as e:
    print(f'数据库写入失败:{e}')
    return

每个 try 块只包一件事,捕获它特有的异常类型。这就是异常处理的「最小作用域」原则。

反模式三:用异常做流程控制

# 错——查字典里有没有键
try:
    val = d['key']
    use(val)
except KeyError:
    use_default()

不是说不行,而是「用 if / else 能写」的逻辑就别用异常。异常的开销比普通分支大,而且让代码意图变得隐晦。

# 对
val = d.get('key')
if val is not None:
    use(val)
else:
    use_default()

那什么时候用异常合适?「正常情况下不该出错」——比如读文件、网络请求、用户输入校验。这些场景本质上是「错误处理」,用异常自然。

反模式四:忘记调用方需要原始信息

# 错
try:
    do_thing()
except SomeError as e:
    raise MyError('操作失败')

这把原始异常丢光了。调用方拿到 MyError,traceback 里没有 SomeError 的信息——根因丢失。

# 对
try:
    do_thing()
except SomeError as e:
    raise MyError('操作失败') from e

记得 from e。这是举手之劳,但能在生产事故里救你的命。

反模式五:用 except 替代类型检查

# 错
try:
    n = int(x)
except (TypeError, ValueError):
    n = 0

如果 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]))

输出:

2.0

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():直接打印到 stderr
  • traceback.format_exception(exc):传入异常对象,转 list of str
  • traceback.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 部分):

warning: DeprecationWarning: old_api 已废弃,请改用 new_api
6

warnings.warn(消息, 类别) 是「温柔的告知」——程序照常往下跑,但调用方能在 stderr 看到提示。

warnings 跟异常的关键差别:warnings 默认不会让程序中断。但是各位可以通过 warnings.filterwarnings('error') 把它升级成异常,方便测试时强制清理废弃用法。

写库给别人用的童鞋,这个东西一定要会,比直接 raise 友好得多。

try/except 在表达式里——不能写

各位可能用过其他语言的 try 表达式,比如 Rust 的 ?、Kotlin 的 runCatching。Python 提供这种语法。在 Python 里 try/except 只能是语句,不能写成表达式:

# 错——语法不允许
result = try:
    int(x)
except ValueError:
    0

要写一行版的「转换失败就给默认值」,标准做法是封装一个小工具函数:

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))

输出:

42
0
-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 = ''
# EAFP
try:
    with open('config.json', 'r') as f:
        data = f.read()
except FileNotFoundError:
    data = ''

很多其他语言(如 Java)偏向 LBYL,但 Python 文化偏向 EAFP。原因有两个:

  1. LBYL 在并发场景下有「检查后失效」的风险(比如 os.path.exists 通过了,但下一秒文件被别人删了,open 还是会炸)
  2. EAFP 写出来更接近「自然语言」——「我直接干了,干不了再说」

当然不是所有场景都适合 EAFP。比如查字典时用 dict.get(key, default)try / except KeyError 更清晰。各位的判断标准是:

  • 正常情况下大概率成功,偶尔失败 → 用 EAFP(异常)
  • 正常情况下经常需要分支 → 用 LBYL(if/else

最后碎碎念几句

把今天的内容捋一遍:

  1. try / except 是 Python 处理错误的核心结构,记住「子在前,父在后」
  2. 多异常except (A, B) 一锅端;用 as e 拿到异常对象
  3. 异常有家族层级,捕父类就等于捕所有子类;兜底用 Exception,别用裸 except:
  4. else 是「没出错才走」,finally 是「无论如何都走」
  5. raise 主动抛异常,raise ... from e 显式异常链,调试时能找到根因
  6. 自定义异常类 让错误也变成代码结构的一部分,业务复杂时是必备
  7. ExceptionGroup + except*(Python 3.11+)解决「一次报告多个错误」的痛点
  8. add_note(Python 3.11+)给异常加调试上下文,比重新抛更优雅

异常处理写得好不好,是新手和老手最大的分水岭之一。新手要么完全不写——程序一炸就完蛋;要么乱写——except Exception: pass 把所有错误都吞了,出问题神仙都救不回来。老手会精确捕捉、合理传递、必要时包装、绝不偷偷吞。

下一篇我们继续往前走,讲讲生成器和迭代器,让各位的代码再上一个台阶。各位加油!