Python 3.11 的 ExceptionGroup 和 except* 是什么?解决了什么问题?
简化版
ExceptionGroup(Python 3.11+,PEP 654)解决的是「一次操作同时失败了好几件事,但传统异常机制只能抛一个」的问题。并发跑 10 个任务、其中 3 个失败了,旧写法只能挑一个抛出(另外两个的信息就丢了)或者自己收集成列表(就不再是异常、没有 traceback、也没法被 except 捕获)。ExceptionGroup("批量失败", [ValueError(...), TimeoutError(...), KeyError(...)]) 把多个异常打包成一个异常对象(可以嵌套成树),完整保留每个子异常的 traceback。配套的新语法 except* 用来「从组里挑出自己关心的那类,其余继续往外传」:
try:
... # 可能抛出 ExceptionGroup
except* ValueError as eg: # eg 是一个"只含 ValueError 的子组"
...
except* (TimeoutError, OSError) as eg:
...
关键语义:① except* 的每个子句最多执行一次,但多个子句都可能被执行(普通 except 是「只进第一个匹配的分支」);② as eg 拿到的永远是一个 ExceptionGroup(不是单个异常),要遍历 eg.exceptions;③ 没有被任何 except* 匹配到的子异常会自动重新抛出(不会被静默吞掉);④ except 和 except* 不能在同一个 try 里混用。最常见的接触点是 asyncio.TaskGroup(3.11+)——它失败时抛的就是 ExceptionGroup。核心记忆:ExceptionGroup 是「异常的集合」,except* 是「按类型从集合里筛出一部分处理、剩下的继续传」。
详细版
传统异常 vs 异常组:
| 维度 | 普通异常 | ExceptionGroup |
|---|---|---|
| 数量 | 一次只能抛一个 | 一次携带多个(可嵌套成树) |
| 捕获语法 | except X | except* X(也能被 except ExceptionGroup 整体捕获) |
| 分支执行 | 只进第一个匹配的 | 多个 except* 都可能执行,各最多一次 |
as 绑定 | 单个异常实例 | 总是一个组(含匹配到的子异常) |
| 未匹配部分 | —— | 自动重新抛出(不会静默丢失) |
| 典型来源 | 任何地方 | asyncio.TaskGroup、批量操作、多路清理 |
# ① 手动构造与捕获
def load_all(paths):
results, errors = [], []
for p in paths:
try:
results.append(open(p).read())
except OSError as e:
errors.append(e) # 先收集,别急着抛
if errors:
raise ExceptionGroup("部分文件加载失败", errors) # ★一次抛出全部★
return results
try:
load_all(["a.txt", "b.txt", "c.txt"])
except* FileNotFoundError as eg:
print("缺失文件:", [e.filename for e in eg.exceptions])
except* PermissionError as eg:
print("无权限:", len(eg.exceptions), "个")
# 未被匹配的(比如 IsADirectoryError)会自动继续向外抛
# ② except* 的核心语义:多个分支都可能执行
def demo():
raise ExceptionGroup("mixed", [ValueError("v"), TypeError("t"), KeyError("k")])
try:
demo()
except* ValueError as eg:
print("A:", eg.exceptions) # A: (ValueError('v'),)
except* TypeError as eg:
print("B:", eg.exceptions) # B: (TypeError('t'),) ★A 执行了 B 还会执行★
# KeyError 没人处理 → 剩余部分作为新的 ExceptionGroup 继续抛出
# ③ eg 永远是组,不是单个异常
try:
raise ExceptionGroup("g", [ValueError("only one")])
except* ValueError as eg:
# print(eg.args) # ✗ 这是组的 args,不是 ValueError 的
for e in eg.exceptions: # ✓ 要遍历
print(type(e).__name__, e)
# ④ 嵌套结构与工具方法
inner = ExceptionGroup("inner", [ValueError("v1"), ValueError("v2")])
outer = ExceptionGroup("outer", [inner, TypeError("t")])
print(len(outer.exceptions)) # 2(一个子组 + 一个 TypeError)
sub = outer.subgroup(ValueError) # 只保留 ValueError(★保持嵌套结构★)
match_, rest = outer.split(TypeError) # 一分为二:匹配的 / 剩余的
print(sub) # ExceptionGroup('outer', [ExceptionGroup('inner', [...])])
# ⑤ asyncio.TaskGroup(最常遇到 ExceptionGroup 的地方)
import asyncio
async def main():
try:
async with asyncio.TaskGroup() as tg: # 3.11+
tg.create_task(fail_a())
tg.create_task(fail_b())
except* ValueError as eg:
print("有", len(eg.exceptions), "个任务抛了 ValueError")
# ⑥ 只关心"有没有出错"时,也可以整体捕获(不需要 except*)
try:
load_all(["x"])
except ExceptionGroup as eg: # ★普通 except 照样能捕获整个组★
print("失败了", len(eg.exceptions), "项")
⚠️ 四条必须记住的规则:①
except和except*不能在同一个try语句里混用(语法错误)——因为两者的分支执行模型完全不同(前者只进一个分支,后者可能进多个),混用无法给出一致语义。②except*子句里不能写ExceptionGroup或BaseExceptionGroup本身(except* ExceptionGroup:会报TypeError),因为「从组里筛出组」逻辑上会无限套娃;想整体处理就用普通的except ExceptionGroup。③except*块里break/continue/return是被禁止的(SyntaxError)——因为多个分支都可能执行,提前跳出会让「剩余异常重抛」的语义无法保证。④BaseExceptionGroup和ExceptionGroup的分工:前者是基类、可以容纳KeyboardInterrupt/SystemExit这类BaseException;后者要求所有子异常都是Exception的子类。ExceptionGroup(...)构造时如果传入了BaseException,会直接TypeError——需要混装时用BaseExceptionGroup(它会根据内容自动返回合适的类型)。
完整版教学
一、要解决的问题:一次失败好几件事
场景:并发请求 10 个下游服务,其中 3 个失败了(超时、404、连接拒绝)
3.11 之前的三种做法,全都有损失:
做法 A:抛第一个错
for r in results:
if isinstance(r, Exception): raise r
✗ 另外两个错误的信息★彻底丢失★(用户以为只有一个问题)
✗ 抛哪个取决于顺序,重跑一次可能报不同的错
做法 B:收集成列表返回
return successes, errors
✗ errors 不是异常 → 不会中断流程、不会被 except 捕获、调用方容易忘记检查
✗ traceback 丢失(只剩异常对象,看不到出错的调用栈)
做法 C:自定义一个 MultiError 异常(trio 等库的老方案)
class MultiError(Exception):
def __init__(self, errors): self.errors = errors
✗ 每个库自己发明一套,互不兼容
✗ except ValueError 抓不到"包在 MultiError 里的 ValueError"
→ 调用方必须知道你用了 MultiError,写法完全不通用
PEP 654 的答案:把"多个异常"变成语言级的一等公民
raise ExceptionGroup("批量失败", [TimeoutError(...), HTTPError(...), OSError(...)])
✓ 是真正的异常:能 raise、能被 except 捕获、每个子异常保留完整 traceback
✓ 标准化:asyncio、trio、任何库都用同一个类型
✓ 配套 except* 语法:调用方可以"只处理我关心的那类,其余继续往外传"
★ 一句话概括动机:并发和批量操作天然会产生"多个失败",
而 1970 年代设计的异常机制假设"一次只错一件事"——PEP 654 补上了这个缺口
理解 ExceptionGroup 要先理解它填的是什么坑:传统异常机制假设「一次只错一件事」,而并发与批量操作天然会同时产生多个失败。3.11 之前只有三条路,每条都有硬伤:只抛第一个错会丢失其余错误信息(而且抛哪个取决于顺序,不可复现);返回错误列表就不再是异常——不会中断流程、不会被 except 捕获、调用方很容易忘记检查,而且 traceback 全没了;自定义 MultiError 则是各库各造一套且不通用——调用方写 except ValueError 根本抓不到「包在 MultiError 里的 ValueError」。PEP 654 的做法是把「多个异常」提升为语言级的一等公民:它是真正的异常(可 raise、可 catch、子异常保留完整 traceback),是标准类型(asyncio、trio、第三方库共用),并且配了专门的 except* 语法来做「部分处理、部分继续传播」。
二、ExceptionGroup 的结构:一棵异常树
构造:ExceptionGroup(message, [子异常列表])
必须至少有一个子异常(空列表会 ValueError)
子异常列表里可以再放 ExceptionGroup → ★形成一棵树★
eg = ExceptionGroup("outer", [
ValueError("v"),
ExceptionGroup("inner", [TypeError("t1"), TypeError("t2")]),
])
树形结构:
ExceptionGroup("outer")
├── ValueError("v")
└── ExceptionGroup("inner")
├── TypeError("t1")
└── TypeError("t2")
eg.message → "outer"
eg.exceptions → (ValueError('v'), ExceptionGroup('inner', [...])) ★只有直接子节点★
len(eg.exceptions) → 2 (★不是 3!嵌套的要递归数★)
两个核心工具方法(理解 except* 的关键):
eg.subgroup(条件) → 只保留匹配的部分,★保持原有嵌套结构★,全不匹配则返回 None
eg.split(条件) → 返回 (匹配的组, 剩余的组),任一边可能是 None
条件可以是:异常类型、类型元组、或一个 lambda(接受异常返回 bool)
eg.split(lambda e: getattr(e, "errno", None) == 2) # 按属性筛
算例:
eg = ExceptionGroup("g", [ValueError("a"), TypeError("b"), ValueError("c")])
m, r = eg.split(ValueError)
m → ExceptionGroup("g", [ValueError('a'), ValueError('c')])
r → ExceptionGroup("g", [TypeError('b')])
★ split 出来的两半都保留原来的 message 和树形层级 ★
两个类的分工:
BaseExceptionGroup 基类,可容纳 KeyboardInterrupt / SystemExit 等 BaseException
ExceptionGroup 子类,要求★所有子异常都是 Exception★(传 BaseException 会 TypeError)
★ 调用 BaseExceptionGroup(msg, excs) 时,如果 excs 全是 Exception,
它会★自动返回 ExceptionGroup 实例★(构造函数里做了类型提升)
traceback 的展示(3.11 起解释器专门做了渲染):
+ Exception Group Traceback (most recent call last):
| ...
| ExceptionGroup: 批量失败 (3 sub-exceptions)
+-+---------------- 1 ----------------
| Traceback ...
| TimeoutError: 超时
+---------------- 2 ----------------
| Traceback ...
| FileNotFoundError: ...
→ ★每个子异常都有自己完整的 traceback★,这是它相比"错误列表"最大的优势
ExceptionGroup 本质是一棵异常树:message 是这一层的描述,exceptions 是直接子节点(可能又是组)。这里有个高频误解——len(eg.exceptions) 只数直接子节点,不递归,含嵌套时需要自己递归统计。两个工具方法是理解 except* 的钥匙:subgroup(条件) 筛出匹配的部分并保持原有嵌套结构(全不匹配返回 None),split(条件) 把树一分为二返回 (匹配的, 剩余的);条件可以是异常类型、类型元组,也可以是任意 lambda(按 errno、按错误码筛都行)。另外要分清两个类:BaseExceptionGroup 是基类,能容纳 KeyboardInterrupt、SystemExit;ExceptionGroup 要求所有子异常都是 Exception(塞 BaseException 会 TypeError)——直接调 BaseExceptionGroup(...) 时若内容全是 Exception,它会自动返回 ExceptionGroup 实例。最后,3.11 的解释器为异常组做了专门的 traceback 渲染,每个子异常都带自己完整的调用栈,这正是它比「返回错误列表」强的地方。
三、except* 的语义:和 except 完全不同的分支模型
普通 except:从上往下找,★只执行第一个匹配的分支★
try: raise ValueError()
except ValueError: print("A") ← 只有这里执行
except Exception: print("B") ← 不执行
except*:★每个分支都会被考察,可能多个都执行★(各自最多一次)
try:
raise ExceptionGroup("g", [ValueError("v"), TypeError("t"), KeyError("k")])
except* ValueError as eg: print("A", eg.exceptions) # A (ValueError('v'),)
except* TypeError as eg: print("B", eg.exceptions) # B (TypeError('t'),)
# KeyError 无人认领 → ★作为新的 ExceptionGroup 继续向外抛出★
执行过程(内部就是反复 split):
剩余 = 原始组
对每个 except* 子句 C:
匹配, 剩余 = 剩余.split(C 的类型)
if 匹配 is not None:
绑定 as 变量 = 匹配(★一个组★)
执行子句体
循环结束后:if 剩余 is not None: 重新抛出剩余
四条派生规则(都能从上面这个过程推出来):
① as 绑定的永远是 ExceptionGroup,★不是单个异常★
→ 即使只匹配到一个 ValueError,拿到的也是含它的组,要 eg.exceptions[0]
② 未被匹配的部分自动重抛 → ★不会静默吞掉错误★(这是好事)
③ 子句体里抛出的新异常,会和"剩余部分"合并成一个新组一起抛出
④ 单个异常(非组)遇到 except* 时,会被★自动包装成组★再匹配
try: raise ValueError("x")
except* ValueError as eg: print(eg) # ExceptionGroup('', (ValueError('x'),))
→ 所以 except* 对"可能是单个也可能是组"的情况同样有效
三条硬性限制(语法层面):
✗ except 和 except* 不能在同一个 try 里混用 → SyntaxError
✗ except* ExceptionGroup / BaseExceptionGroup → TypeError(从组里筛组,无限套娃)
✗ except* 块里写 break / continue / return → SyntaxError
(因为多个分支都可能执行,提前跳出会破坏"剩余异常重抛"的保证)
✓ else / finally 照常可用
except* 最反直觉的地方是分支模型完全不同于 except:普通 except 是「从上往下找,只进第一个匹配的分支」;except* 是「每个子句都被考察,可能多个都执行,各自最多一次」。内部实现其实就是反复调用 split():拿剩余部分和当前子句的类型做拆分,匹配到就绑定并执行子句体,然后把剩余部分交给下一个子句;所有子句处理完后,如果还有剩余,就重新抛出。理解了这个循环,四条派生规则就都是自然结果:as 绑定的永远是一个组(哪怕只匹配到一个异常,也要 eg.exceptions[0] 才能拿到它)、未匹配的部分自动重抛(不会静默吞错,这是安全设计)、子句体里新抛的异常会和剩余部分合并、以及单个非组异常遇到 except* 会被自动包装成组(所以 except* 对「可能是单个也可能是组」的情况同样有效)。三条语法限制也都源于这个模型:不能和 except 混用(语义无法统一)、不能 except* ExceptionGroup(从组里筛组会无限套娃)、块里不能 break/continue/return(提前跳出会破坏「剩余重抛」的保证)。
四、和 asyncio.TaskGroup:最常遇到它的地方
3.11 引入的 asyncio.TaskGroup 是 ExceptionGroup 的最大使用方:
async with asyncio.TaskGroup() as tg:
tg.create_task(fetch("a"))
tg.create_task(fetch("b"))
tg.create_task(fetch("c"))
# 退出 with 时等待全部完成;★任何任务失败 → 取消其余任务 → 抛 ExceptionGroup★
处理:
try:
async with asyncio.TaskGroup() as tg: ...
except* TimeoutError as eg:
logger.warning("超时 %d 个", len(eg.exceptions))
except* HTTPError as eg:
for e in eg.exceptions: report(e)
和老的 asyncio.gather 对比:
┌────────────────────────┬──────────────────────────────────────┐
│ gather(..., return_exceptions=False) │ 第一个异常立刻抛出,★其余任务仍在后台跑★(易泄漏)│
│ gather(..., return_exceptions=True) │ 异常混在结果列表里返回,★不是异常★,要自己遍历判断 │
│ TaskGroup(3.11+) │ 出错自动取消兄弟任务 + 抛 ExceptionGroup(结构化并发)│
└────────────────────────┴──────────────────────────────────────┘
→ TaskGroup 是"结构化并发":任务的生命周期不会超出 with 块,
错误不会丢失,这两点是 gather 做不到的
★ 一个必须知道的配套变化:asyncio.timeout / cancel 与 CancelledError
TaskGroup 里某个任务失败时,其余任务会收到 CancelledError;
CancelledError 继承自 BaseException(不是 Exception)
→ 组里混有 CancelledError 时,抛出的是 BaseExceptionGroup
→ 想捕获取消:except* asyncio.CancelledError(但通常不该拦截取消)
另外 3.11 的 asyncio.timeout() 超时后抛的是 TimeoutError,
而不是把 CancelledError 泄漏出来 —— 这也是同一轮改造的一部分
trio / anyio 的历史:
trio 早就有 MultiError(同样的思路),PEP 654 相当于把它标准化进语言,
trio 后来也迁移到了 ExceptionGroup
日常最容易撞见 ExceptionGroup 的地方是 asyncio.TaskGroup(3.11+):async with 块退出时等待所有任务,任何一个任务失败就取消其余任务并抛出 ExceptionGroup。这相比老的 gather 是实质性的改进——gather(return_exceptions=False) 只抛第一个异常,其余任务还在后台跑(容易泄漏、且它们的异常可能变成「never retrieved」警告);gather(return_exceptions=True) 则把异常混在结果列表里返回,它们不再是异常,调用方必须自己遍历判断(忘了就静默吞错)。TaskGroup 实现的是结构化并发:任务生命周期不超出 with 块,错误一个都不丢。有个配套细节要知道:CancelledError 继承自 BaseException,所以当组里混有取消异常时,抛出的是 BaseExceptionGroup 而不是 ExceptionGroup——用 except* Exception 是抓不到取消的(通常你也不该拦截取消)。
五、什么时候自己用它,什么时候别用
适合抛 ExceptionGroup 的场景(共同点:多个★独立★的失败都值得被知道):
① 批量操作:批量导入 1000 条记录,第 3/17/402 条失败
→ 用户需要知道全部三条,而不是"第 3 条失败了"就结束
② 并发调用:同时请求多个下游,多个失败
③ 多路清理:关闭 5 个资源,3 个 close() 抛错(ExitStack 内部就用它)
④ 校验/解析:一次性报告所有校验错误(而不是修一个报一个)
⑤ 插件/回调:广播事件给 N 个订阅者,多个抛错
★ 不适合的场景(别为了用而用):
✗ 顺序流程里的单点失败 —— 就一个错,普通异常更清晰
✗ 重试逻辑的中间失败 —— 应该记日志,不是攒起来一起抛
✗ 只是想"带上更多上下文" —— 用 raise X from e(异常链)或自定义异常属性
✗ 库的公开 API 突然改抛 ExceptionGroup → ★破坏性变更★
调用方的 except ValueError 会失效(组不是 ValueError)
→ 要么发大版本,要么保持"只有一个错时抛原异常、多个才抛组"
典型写法模板:
errors = []
for item in items:
try:
process(item)
except Exception as e:
e.add_note(f"处理 {item.id} 时失败") # ★3.11+ 的 add_note,给异常加上下文★
errors.append(e)
if errors:
if len(errors) == 1:
raise errors[0] # 只有一个 → 保持传统行为(可选)
raise ExceptionGroup(f"{len(errors)}/{len(items)} 项失败", errors)
兼容 3.10 及以下:
pip install exceptiongroup # backport 了 ExceptionGroup/BaseExceptionGroup
★ 但 except* 是★语法★,backport 不了 → 3.10 上只能:
except BaseExceptionGroup as eg:
matched, rest = eg.split(ValueError)
if matched: 处理(matched)
if rest: raise rest
→ 也就是手写 except* 的等价逻辑(这反过来说明 except* 就是 split 的语法糖)
判断该不该抛 ExceptionGroup,标准是「是否存在多个独立的失败、且每个都值得被调用方知道」:批量导入、并发请求、多路资源清理、一次性报告全部校验错误、广播回调——这些都该用。反过来,顺序流程里的单点失败、重试中的中间失败、只是想附加上下文(应该用 raise X from e 或 3.11 新增的 e.add_note("上下文"))都不该用。最需要警惕的是库的公开 API 从抛普通异常改成抛 ExceptionGroup 是破坏性变更——调用方原有的 except ValueError 会突然失效(组不是 ValueError 的实例),稳妥做法是「只有一个错时抛原异常、多个错才抛组」或干脆发大版本。兼容性上:exceptiongroup 这个 backport 包能在 3.10 及以下提供 ExceptionGroup 类,但 except* 是语法、无法 backport,老版本只能手写 split + 条件重抛——这也反过来印证了 except* 本质就是 split() 的语法糖。
六、traceback、日志与常见处理姿势
① 打印/记录异常组(logging 已支持,会渲染整棵树)
try:
...
except* Exception as eg:
logger.exception("批量任务失败") # ★会打印每个子异常的完整 traceback★
手动格式化:
import traceback
traceback.print_exception(eg) # 3.11 起自动渲染成 +-+---- 树形
② 递归遍历所有叶子异常(因为 exceptions 只有直接子节点)
def leaves(eg):
for e in eg.exceptions:
if isinstance(e, BaseExceptionGroup):
yield from leaves(e)
else:
yield e
print([type(e).__name__ for e in leaves(outer)])
③ 按业务条件筛(split 支持任意 predicate)
retryable, fatal = eg.split(lambda e: isinstance(e, (TimeoutError, ConnectionError)))
if retryable: schedule_retry(retryable)
if fatal: raise fatal # 不可重试的继续往外抛
④ 只想知道"有没有出错"→ 普通 except 就够
except ExceptionGroup as eg:
metrics.incr("batch.failed", len(eg.exceptions))
★ 注意:普通 except ExceptionGroup 捕获后★不会自动重抛剩余★(整个组都被你接管了)
⑤ 给子异常加上下文(3.11 的 add_note)
e.add_note(f"item_id={item.id}") # traceback 里会额外显示这一行
→ 批量场景里定位"是哪一条数据出错"的利器,比自定义异常类轻量
⑥ 常见处理姿势总结
想按类型分别处理、剩余继续传 → except*
只想整体感知/统计/兜底 → except ExceptionGroup
想按业务属性筛(错误码等) → except ExceptionGroup + eg.split(predicate)
写库、要兼容旧版 → 收集错误 + 只有一个时抛原异常
实践中的几个姿势值得固化。日志方面 logger.exception() 已经原生支持异常组,会把整棵树连同每个子异常的 traceback 打印出来(3.11 的 traceback 模块专门做了 +-+---- 树形渲染)。遍历时记得 eg.exceptions 只含直接子节点,要拿到所有叶子异常得写个递归生成器。split() 接受任意 predicate,所以「可重试的 vs 致命的」这类业务维度的划分可以一行完成:retryable, fatal = eg.split(lambda e: isinstance(e, (TimeoutError, ConnectionError))),然后重试一半、抛出另一半。如果只想统计和兜底,普通 except ExceptionGroup 就够了——但要注意它和 except* 的区别:普通 except 捕获后整个组都被你接管、不会自动重抛剩余部分。最后强烈推荐 3.11 的 e.add_note(f"item_id={item.id}"):在批量场景里给每个子异常打上「是哪条数据」的标记,比自定义异常类轻量得多,且会直接显示在 traceback 里。
记忆钩子:「ExceptionGroup(3.11,PEP 654)补的是『异常机制假设一次只错一件事』这个缺口——并发和批量天然会同时失败多件事,旧的三条路都有硬伤:只抛第一个错会丢信息、返回错误列表就不再是异常(不中断、不可捕获、没 traceback)、自定义 MultiError 各库一套且
except ValueError抓不到。它把『多个异常』变成语言级一等公民:一棵异常树(message + exceptions,★exceptions 只含直接子节点、不递归★),每个子异常保留完整 traceback,配 subgroup/split 两个工具方法。★配套语法 except* 的分支模型和 except 完全不同:except 只进第一个匹配分支,except* 是每个子句都被考察、可能多个都执行(各最多一次),内部就是反复 split。四条派生规则:as 绑定的永远是『组』不是单个异常(要 eg.exceptions[0])、没被任何子句匹配的部分会自动重新抛出(不静默吞错)、子句里新抛的异常会和剩余合并、单个非组异常遇到 except* 会被自动包装成组。★三条硬限制:except 和 except* 不能混用(SyntaxError)、不能写 except* ExceptionGroup(TypeError,从组里筛组会套娃)、块里不能 break/continue/return。★最常撞见它的地方是 asyncio.TaskGroup(结构化并发:一个任务失败就取消兄弟任务并抛组,比 gather 的『其余任务在后台裸奔』和『异常混在结果列表里』都强);组里混入 CancelledError 时抛的是 BaseExceptionGroup(CancelledError 是 BaseException)。★写库时注意:把公开 API 从普通异常改成抛组是破坏性变更,稳妥做法是『只有一个错时抛原异常、多个才抛组』;3.10 及以下可以 pip install exceptiongroup 拿到类,但 except* 是语法、backport 不了,只能手写 split + 条件重抛。」
七、常见误区与追问
- 误区:
except* ValueError as eg里的eg就是那个ValueError。 不是——as绑定的永远是一个ExceptionGroup,即使组里只匹配到一个ValueError,你拿到的也是「包含这一个 ValueError 的子组」。所以eg.args、str(eg)拿到的是组的信息而不是子异常的,必须遍历eg.exceptions才能拿到真正的异常对象(只有一个时是eg.exceptions[0])。这个设计是必然的:except*的语义是「从组里筛出匹配的那一部分」,而「一部分」在结构上仍然是组(还可能保留原来的嵌套层级)。写代码时把except*的处理块默认写成for e in eg.exceptions:循环,就不会出错。 - 误区:
except*和except一样,只会执行第一个匹配的分支。 这是最核心的语义差异:普通except从上往下找、只进第一个匹配的分支;except*会依次考察每一个子句,多个子句都可能被执行(各自最多一次)。原因在于一个异常组里可能同时含有ValueError和TimeoutError,两者都需要被各自的处理逻辑接管。内部实现就是反复split():每个子句从「剩余部分」里拆走自己匹配的,处理完所有子句后,如果还有剩余就重新抛出——这也意味着except*不会静默吞掉你没写到的异常类型,这是它相比「捕获整个组然后凭印象处理」更安全的地方。 - 误区:可以在一个
try里同时写except和except*,谁匹配用谁。 语法直接禁止(SyntaxError)。因为两者的分支执行模型根本冲突:except是「只进一个分支且不重抛剩余」,except*是「可进多个分支且自动重抛剩余」,混在一起无法定义一致的语义(先按哪套走?剩余部分还重抛吗?)。同一个try里要么全用except(把组当成一个整体异常处理),要么全用except*(按子异常类型分别处理)。另外两条相关限制也要记住:except* ExceptionGroup/except* BaseExceptionGroup会TypeError(「从组里筛出组」逻辑上无限套娃),想整体处理就用普通except ExceptionGroup;except*块里禁止break/continue/return(SyntaxError),因为提前跳出会让「剩余异常重抛」的保证失效。 - 误区:只要用了
except*,就不用担心漏掉某类异常。 恰恰相反——没被任何except*匹配到的子异常会继续向外抛出,这是设计上的安全保证(不静默吞错),但也意味着你的函数仍然会抛异常。很多人以为写了几个except*就等于「都处理干净了」,结果剩余部分作为一个新的ExceptionGroup继续向上传播,在更外层炸掉。如果确实想兜住所有情况,要么加一个except* Exception as eg:兜底子句,要么改用普通except ExceptionGroup(它会接管整个组,不重抛任何部分)——注意这两种写法的语义差别很大,选择时要清楚自己是想「筛选处理」还是「整体接管」。 - 误区:
len(eg.exceptions)就是失败的总数。eg.exceptions只包含直接子节点,异常组是可以嵌套的树——ExceptionGroup("outer", [ValueError(), ExceptionGroup("inner", [TypeError(), TypeError()])])的len(eg.exceptions)是 2 而不是 3。嵌套在实际场景里很常见(比如TaskGroup里的任务本身又用了TaskGroup,或库把多层批量操作的失败逐层打包)。要统计真正的失败数或遍历所有具体异常,需要写一个递归生成器:遇到BaseExceptionGroup就yield from递归下去,否则yield这个叶子异常。同理,eg.subgroup()和eg.split()是递归处理并保持嵌套结构的,它们返回的结果同样可能是多层的树。 - 追问:
asyncio.TaskGroup相比asyncio.gather好在哪,和 ExceptionGroup 是什么关系?gather有两种模式,各有硬伤:return_exceptions=False(默认)在第一个任务抛异常时立刻把它抛给调用方,但其余任务仍在后台继续运行——没人等待它们、它们的异常会变成「Task exception was never retrieved」警告,资源也可能泄漏;return_exceptions=True则把异常混在结果列表里返回,此时它们不再是异常(不中断流程、不会被except捕获),调用方必须自己遍历isinstance(r, Exception)判断,忘了就是静默吞错。TaskGroup(3.11+)实现的是结构化并发:async with块退出前必然等待所有任务结束,任何一个任务失败会自动取消其余兄弟任务,然后把所有失败(含取消导致的异常)打包成ExceptionGroup抛出。两个保证是gather给不了的:任务生命周期不会超出with块、没有任何错误会丢失。所以TaskGroup是ExceptionGroup最主要的产生者,两者是同一轮(PEP 654 + 结构化并发)改造的两半。 - 追问:
ExceptionGroup和BaseExceptionGroup有什么区别,什么时候会遇到后者?BaseExceptionGroup是基类,可以容纳任意BaseException(包括KeyboardInterrupt、SystemExit、asyncio.CancelledError这些不继承自Exception的异常);ExceptionGroup是它的子类,要求所有子异常都是Exception的实例——构造时传入一个BaseException会直接TypeError。有个贴心设计:直接调用BaseExceptionGroup(msg, excs)时,如果excs里全都是Exception,它会自动返回ExceptionGroup实例。最常遇到BaseExceptionGroup的场景是 asyncio:CancelledError继承自BaseException,所以TaskGroup里发生取消时抛出的就是BaseExceptionGroup——这意味着except* Exception抓不到取消(这通常正是你想要的,取消不应该被业务代码拦下来),而except BaseExceptionGroup才能整体兜住。写捕获逻辑时要想清楚自己是否真的要处理BaseException级别的东西。 - 追问:我的库要不要改成抛
ExceptionGroup?兼容性怎么处理? 先明确这是破坏性变更:调用方现有的except ValueError会突然失效,因为ExceptionGroup不是ValueError的实例——升级后他们的错误处理会静默失灵(异常一路往上抛到顶层)。稳妥的做法有两条:① 「一个错抛原异常、多个错才抛组」——保持单失败场景的向后兼容,只有真正出现多个独立失败时才抛组,并在文档和 CHANGELOG 里写清楚;② 发布主版本号并在迁移指南里明确说明。版本兼容方面:pip install exceptiongroup这个官方 backport 能在 Python 3.10 及以下提供ExceptionGroup/BaseExceptionGroup类和add_note等能力,但except*是语法特性、无法 backport(3.10 上包含except*的文件连import都会SyntaxError),旧版本只能手写等价逻辑:except BaseExceptionGroup as eg:里用matched, rest = eg.split(X)处理匹配部分、再raise rest抛出剩余——这也正说明了except*本质上就是split()加条件重抛的语法糖。
八、加强记忆
ExceptionGroup(Python 3.11,PEP 654)补的是「传统异常机制假设一次只错一件事」这个缺口——并发与批量操作天然会同时产生多个失败,而旧的三条路都有硬伤:只抛第一个错丢失其余信息(且抛哪个取决于顺序)、返回错误列表就不再是异常(不中断流程、不可 except、没有 traceback)、自定义 MultiError 则各库一套且 except ValueError 抓不到。它把「多个异常」变成语言级一等公民:本质是一棵异常树(message + exceptions,注意 exceptions 只含直接子节点、不递归),每个子异常保留完整 traceback,并提供 subgroup(条件)/split(条件) 两个递归且保持嵌套结构的工具方法(条件可以是类型,也可以是任意 predicate)。配套语法 except* 的分支模型和 except 完全不同:except 只进第一个匹配的分支,except* 会依次考察每个子句、可能多个都执行(各最多一次),内部就是反复 split。由此派生四条规则:as 绑定的永远是「组」而不是单个异常(要 eg.exceptions[0])、没被任何子句匹配的部分会自动重新抛出(不静默吞错,但也意味着函数仍会抛异常)、子句里新抛的异常会与剩余合并、单个非组异常遇到 except* 会被自动包装成组。三条硬限制:except 与 except* 不能混用(SyntaxError)、不能写 except* ExceptionGroup(TypeError,从组里筛组会套娃)、块里禁止 break/continue/return。最常撞见它的地方是 asyncio.TaskGroup(结构化并发:一个任务失败即取消兄弟任务并抛组,胜过 gather 的「其余任务后台裸奔」和「异常混在结果列表里」);由于 CancelledError 继承自 BaseException,混入取消时抛的是 BaseExceptionGroup(except* Exception 抓不到)。工程上:批量导入、并发调用、多路清理、一次性报告全部校验错误才该用它,顺序流程的单点失败别用;给子异常加上下文用 3.11 的 e.add_note(...);把公开 API 从普通异常改成抛组是破坏性变更,稳妥做法是「一个错抛原异常、多个才抛组」;3.10 及以下可 pip install exceptiongroup 拿到类,但 except* 是语法、无法 backport,只能手写 split + 条件重抛。