Following system colour scheme - Python 增强提案 Selected dark colour scheme - Python 增强提案 Selected light colour scheme - Python 增强提案

Python 增强提案

PEP 654 – 异常组和 except*

作者:
Irit Katriel <irit at python.org>, Yury Selivanov <yury at edgedb.com>, Guido van Rossum <guido at python.org>
讨论至:
Discourse 帖子
状态:
最终版
类型:
标准跟踪
创建日期:
2021年2月22日
Python 版本:
3.11
发布历史:
2021年2月22日, 2021年3月20日, 2021年10月3日
决议:
Discourse 消息

目录

重要

本 PEP 是一份历史文档。最新的规范文档现在可以在 异常组except* 子句 找到。

×

有关以用户为中心的教程,请参阅 引发和处理多个不相关异常

有关如何提出更改,请参阅 PEP 1

摘要

本文档提出了语言扩展,允许程序同时引发和处理多个不相关的异常。

  • 一种新的标准异常类型,ExceptionGroup,它表示一组不相关的异常正在一起传播。
  • 一种新的语法 except*,用于处理 ExceptionGroups

动机

解释器目前一次最多只能传播一个异常。 PEP 3134 中引入的链式特性将相互关联的异常链接起来作为原因或上下文,但在某些情况下,多个不相关的异常需要随着堆栈的展开一起传播。下面列举了一些实际用例。

  • 并发错误。异步并发库提供了用于调用多个任务并汇总返回其结果的 API。目前,此类库没有很好的方法来处理多个任务引发异常的情况。Python 标准库的 asyncio.gather() [1] 函数提供了两种选项:引发第一个异常,或者在结果列表中返回异常。Trio [2] 库有一个 MultiError 异常类型,它会引发该类型来报告一组错误。这项 PEP 的工作最初是受处理 MultiErrors [9] 的困难所驱动的,这些困难在改进版本 MultiError2 [3] 的设计文档中详细说明。该文档演示了在没有我们提议的语言更改的情况下,创建有效 API 来报告和处理多个错误是多么困难(另请参阅 不使用 'except *' 编程 部分)。

    在 asyncio 中实现一个受 Trio 托儿所 [13] 启发而改进的任务生成 API,是本 PEP 的主要动机。该工作目前因 Python 缺乏对异常组的原生语言级别支持而受阻。

  • 重试操作时发生多次故障。 Python 标准库的 socket.create_connection 函数可能会尝试连接到不同的地址,如果所有尝试都失败了,它需要向用户报告。如何聚合这些错误是一个悬而未决的问题,尤其是当它们不同时(参见问题 29980 [4])。
  • 多个用户回调失败。 Python 的 atexit.register() 函数允许用户注册在系统退出时调用的函数。如果其中任何一个引发异常,只会重新引发最后一个,但最好将它们一起重新引发(参见 atexit 文档 [5])。类似地,pytest 库允许用户注册在 teardown 时执行的终结器。如果这些终结器中有一个以上引发异常,只会向用户报告第一个。使用 ExceptionGroups 可以改进这一点,如 pytest 开发者 Ran Benita 在此问题中解释的(参见 pytest 问题 8217 [6])。
  • 复杂计算中的多个错误。 Hypothesis 库执行自动 bug 缩小(简化演示 bug 的代码)。在此过程中,它可能会发现产生不同错误的变体,并(可选地)报告所有这些错误(参见 Hypothesis 文档 [7])。我们在此提出的 ExceptionGroup 机制可以解决上述链接中提到的一些调试困难,这些困难是由于上下文/原因信息丢失造成的(由 Hypothesis 核心开发者 Zac Hatfield-Dodds 传达)。
  • 包装器代码中的错误。 Python 标准库的 tempfile.TemporaryDirectory 上下文管理器存在一个问题,即在 __exit__ 中清理期间引发的异常有效地掩盖了用户代码在上下文管理器作用域内引发的异常。虽然用户的异常被链式为清理错误的原因,但它并未被用户的 except 子句捕获(参见问题 40857 [8])。

    该问题已通过使清理代码忽略错误来解决,从而绕过了多重异常问题。有了我们在此提出的特性,__exit__ 可以引发一个包含其自身错误以及用户错误的 ExceptionGroup,这将允许用户通过其类型捕获自己的异常。

基本原理

将多个异常组合在一起无需更改语言,只需创建一个容器异常类型即可。Trio [2] 是一个库的示例,它在其 MultiError [9] 类型中使用了此技术。然而,这种方法要求调用代码捕获容器异常类型,然后检查它以确定发生的错误类型,提取它想要处理的错误,并重新引发其余的错误。此外,Python 中的异常在其 __traceback____cause____context__ 字段中附加了重要信息,设计一个保持此信息完整性的容器类型需要仔细考虑;它不像将异常收集到一个集合中那么简单。

需要对语言进行更改,以便按照现有异常处理机制的风格扩展对异常组的支持。至少,我们希望能够仅当异常组包含我们选择处理的类型的异常时才捕获它。同一组中其他类型的异常需要自动重新引发,否则用户代码很容易无意中吞噬它未处理的异常。

我们考虑了是否有可能以向后兼容的方式修改 except 的语义以达到此目的,并发现这是不可能的。有关更多信息,请参阅 被拒绝的想法 部分。

因此,本 PEP 的目的是添加 ExceptionGroup 内置类型和 except* 语法,用于在解释器中处理异常组。 except* 的预期语义与当前异常处理语义有足够的差异,因此我们不提议修改 except 关键字的行为,而是添加新的 except* 语法。

我们的前提是异常组和 except* 将有选择地使用,只有在需要时才使用。我们不期望它们成为异常处理的默认机制。从库中引发异常组的决定需要仔细考虑,并被视为 API 破坏性更改。我们预计这通常会通过引入新的 API 而不是修改现有 API 来完成。

规范

ExceptionGroup 和 BaseExceptionGroup

我们提议添加两种新的内置异常类型:BaseExceptionGroup(BaseException)ExceptionGroup(BaseExceptionGroup, Exception)。它们可以赋值给 Exception.__cause__Exception.__context__,并且它们可以像任何异常一样通过 raise ExceptionGroup(...)try: ... except ExceptionGroup: ...raise BaseExceptionGroup(...)try: ... except BaseExceptionGroup: ... 来引发和处理。

两者都带有一个构造函数,它接受两个仅限位置参数:一个消息字符串和一个嵌套异常序列,这些异常通过 messageexceptions 字段公开。例如:ExceptionGroup('issues', [ValueError('bad value'), TypeError('bad type')])。它们之间的区别在于 ExceptionGroup 只能包装 Exception 子类,而 BaseExceptionGroup 可以包装任何 BaseException 子类。BaseExceptionGroup 构造函数会检查嵌套异常,如果它们都是 Exception 子类,它将返回一个 ExceptionGroup 而不是 BaseExceptionGroupExceptionGroup 构造函数在任何嵌套异常不是 Exception 实例时会引发 TypeError。在本文档的其余部分,当我们提到异常组时,我们指的是 ExceptionGroupBaseExceptionGroup。当需要区分时,我们使用类名。为简洁起见,在同时适用于两者的代码示例中,我们将使用 ExceptionGroup

由于异常组可以嵌套,它表示一个异常树,其中叶子是普通异常,每个内部节点表示程序将一些不相关的异常组合成一个新组并一起引发的时间。

BaseExceptionGroup.subgroup(condition) 方法为我们提供了一种获取异常组的方法,该异常组具有与原始组相同的元数据(消息、原因、上下文、追溯信息),以及相同的嵌套组结构,但仅包含满足条件的异常。

>>> eg = ExceptionGroup(
...     "one",
...     [
...         TypeError(1),
...         ExceptionGroup(
...             "two",
...              [TypeError(2), ValueError(3)]
...         ),
...         ExceptionGroup(
...              "three",
...               [OSError(4)]
...         )
...     ]
... )
>>> import traceback
>>> traceback.print_exception(eg)
  | ExceptionGroup: one (3 sub-exceptions)
  +-+---------------- 1 ----------------
    | TypeError: 1
    +---------------- 2 ----------------
    | ExceptionGroup: two (2 sub-exceptions)
    +-+---------------- 1 ----------------
      | TypeError: 2
      +---------------- 2 ----------------
      | ValueError: 3
      +------------------------------------
    +---------------- 3 ----------------
    | ExceptionGroup: three (1 sub-exception)
    +-+---------------- 1 ----------------
      | OSError: 4
      +------------------------------------

>>> type_errors = eg.subgroup(lambda e: isinstance(e, TypeError))
>>> traceback.print_exception(type_errors)
  | ExceptionGroup: one (2 sub-exceptions)
  +-+---------------- 1 ----------------
    | TypeError: 1
    +---------------- 2 ----------------
    | ExceptionGroup: two (1 sub-exception)
    +-+---------------- 1 ----------------
      | TypeError: 2
      +------------------------------------
>>>

匹配条件也适用于内部节点(异常组),匹配会导致以该节点为根的整个子树包含在结果中。

空的嵌套组在结果中被省略,如上例中的 ExceptionGroup("three") 所示。如果没有异常符合条件,subgroup 返回 None 而不是空组。原始的 eg 不受 subgroup 更改,但返回的值不一定是完整的副本。叶子异常不被复制,完全包含在结果中的异常组也不被复制。当需要对一个组进行分区时,因为条件对其中一些异常成立,但不是全部,则会创建一个新的 ExceptionGroupBaseExceptionGroup 实例,同时 __cause____context____traceback__ 字段通过引用复制,因此它们与原始的 eg 共享。

如果需要子组及其补集,可以使用 BaseExceptionGroup.split(condition) 方法

>>> type_errors, other_errors = eg.split(lambda e: isinstance(e, TypeError))
>>> traceback.print_exception(type_errors)
  | ExceptionGroup: one (2 sub-exceptions)
  +-+---------------- 1 ----------------
    | TypeError: 1
    +---------------- 2 ----------------
    | ExceptionGroup: two (1 sub-exception)
    +-+---------------- 1 ----------------
      | TypeError: 2
      +------------------------------------
>>> traceback.print_exception(other_errors)
  | ExceptionGroup: one (2 sub-exceptions)
  +-+---------------- 1 ----------------
    | ExceptionGroup: two (1 sub-exception)
    +-+---------------- 1 ----------------
      | ValueError: 3
      +------------------------------------
    +---------------- 2 ----------------
    | ExceptionGroup: three (1 sub-exception)
    +-+---------------- 1 ----------------
      | OSError: 4
      +------------------------------------
>>>

如果拆分是微不足道的(其中一边是空的),那么另一边返回 None。

>>> other_errors.split(lambda e: isinstance(e, SyntaxError))
(None, ExceptionGroup('one', [
  ExceptionGroup('two', [
    ValueError(3)
  ]),
  ExceptionGroup('three', [
    OSError(4)])]))

由于按异常类型拆分是一个非常常见的用例,subgroupsplit 可以接受一个异常类型或异常类型元组,并将其视为匹配该类型的简写:eg.split(T)eg 分为匹配类型 T 的叶异常子组,以及不匹配的子组(使用与 except 相同的检查进行匹配)。

异常组的子类化

可以对异常组进行子类化,但在这样做时,通常需要指定 subgroup()split() 应如何为分区的匹配或不匹配部分创建新实例。BaseExceptionGroup 公开了一个实例方法 derive(self, excs),它在 subgroupsplit 需要创建新的异常组时被调用。参数 excs 是要包含在新组中的异常序列。由于 derive 可以访问 self,因此它可以将数据从 self 复制到新对象。例如,如果我们需要一个具有附加错误代码字段的异常组子类,我们可以这样做:

class MyExceptionGroup(ExceptionGroup):
    def __new__(cls, message, excs, errcode):
        obj = super().__new__(cls, message, excs)
        obj.errcode = errcode
        return obj

    def derive(self, excs):
        return MyExceptionGroup(self.message, excs, self.errcode)

请注意,我们重写的是 __new__ 而不是 __init__;这是因为 BaseExceptionGroup.__new__ 需要检查构造函数参数,并且其签名与子类的不同。另请注意,我们的 derive 函数不会复制 __context____cause____traceback__ 字段,因为 subgroupsplit 会为我们完成这些操作。

有了上面定义的类,我们得到以下结果

>>> eg = MyExceptionGroup("eg", [TypeError(1), ValueError(2)], 42)
>>>
>>> match, rest = eg.split(ValueError)
>>> print(f'match: {match!r}: {match.errcode}')
match: MyExceptionGroup('eg', [ValueError(2)], 42): 42
>>> print(f'rest: {rest!r}: {rest.errcode}')
rest: MyExceptionGroup('eg', [TypeError(1)], 42): 42
>>>

如果我们不覆盖 derive,则 split 调用 BaseExceptionGroup 上定义的那个,如果所有包含的异常都是 Exception 类型,则返回 ExceptionGroup 实例,否则返回 BaseExceptionGroup。例如

>>> class MyExceptionGroup(BaseExceptionGroup):
...     pass
...
>>> eg = MyExceptionGroup("eg", [ValueError(1), KeyboardInterrupt(2)])
>>> match, rest = eg.split(ValueError)
>>> print(f'match: {match!r}')
match: ExceptionGroup('eg', [ValueError(1)])
>>> print(f'rest: {rest!r}')
rest: BaseExceptionGroup('eg', [KeyboardInterrupt(2)])
>>>

异常组的追溯信息

对于常规异常,追溯信息表示一个简单的帧路径,从引发异常的帧到捕获它的帧,或者如果尚未捕获,则表示程序当前执行所在的帧。该列表由解释器构建,解释器会将它退出的任何帧追加到“当前异常”的追溯信息中(如果存在)。为了支持高效的追加,追溯信息帧列表中的链接是从最旧的帧到最新的帧。追加新帧只是插入一个新头到异常 __traceback__ 字段引用的链表。至关重要的是,追溯信息的帧列表是不可变的,因为帧只需要在头部添加,而无需删除。

我们不需要对这个数据结构进行任何更改。异常组实例的 __traceback__ 字段表示包含的异常在被合并成组后一起遍历的路径,而每个嵌套异常上的相同字段表示该异常到达合并帧的路径。

我们需要更改的是任何解释和显示追溯信息的代码,因为它现在需要继续深入到嵌套异常的追溯信息中,如下例所示:

>>> def f(v):
...     try:
...         raise ValueError(v)
...     except ValueError as e:
...         return e
...
>>> try:
...     raise ExceptionGroup("one", [f(1)])
... except ExceptionGroup as e:
...     eg = e
...
>>> raise ExceptionGroup("two", [f(2), eg])
 + Exception Group Traceback (most recent call last):
 |   File "<stdin>", line 1, in <module>
 | ExceptionGroup: two (2 sub-exceptions)
 +-+---------------- 1 ----------------
   | Traceback (most recent call last):
   |   File "<stdin>", line 3, in f
   | ValueError: 2
   +---------------- 2 ----------------
   | Exception Group Traceback (most recent call last):
   |   File "<stdin>", line 2, in <module>
   | ExceptionGroup: one (1 sub-exception)
   +-+---------------- 1 ----------------
     | Traceback (most recent call last):
     |   File "<stdin>", line 3, in f
     | ValueError: 1
     +------------------------------------
>>>

处理异常组

我们预计当程序捕获并处理异常组时,它们通常会查询以检查其是否存在满足某些条件的叶异常(使用 subgroupsplit)或格式化异常(使用 traceback 模块的方法)。

迭代单个叶子异常可能不太有用。要了解原因,假设一个应用程序捕获了由 asyncio.gather() 调用引发的异常组。在此阶段,每个特定异常的上下文都会丢失。此异常的任何恢复都应在它与其他异常分组之前执行 [10]。此外,应用程序很可能会以相同的方式响应某种异常类型的任意数量的实例,因此我们更可能想知道 eg.subgroup(T) 是否为 None,而不是对 egT 的数量感兴趣。

然而,在某些情况下,有必要检查单个叶异常。例如,假设我们有一个异常组 eg,并且我们希望记录具有特定错误代码的 OSErrors,并重新引发所有其他异常。我们可以通过将具有副作用的函数传递给 subgroup 来实现:

def log_and_ignore_ENOENT(err):
    if isinstance(err, OSError) and err.errno == ENOENT:
        log(err)
        return False
    else:
        return True

try:
    . . .
except ExceptionGroup as eg:
    eg = eg.subgroup(log_and_ignore_ENOENT)
    if eg is not None:
        raise eg

在前面的例子中,当 log_and_ignore_ENOENT 在叶异常上被调用时,只有该异常追溯信息的一部分可访问——由其 __traceback__ 字段引用的部分。如果我们需要完整的追溯信息,我们需要查看从根到该叶的路径上异常的追溯信息的串联。我们可以通过递归地直接迭代来获得它,如下所示:

def leaf_generator(exc, tbs=None):
    if tbs is None:
        tbs = []

    tbs.append(exc.__traceback__)
    if isinstance(exc, BaseExceptionGroup):
        for e in exc.exceptions:
            yield from leaf_generator(e, tbs)
    else:
        # exc is a leaf exception and its traceback
        # is the concatenation of the traceback
        # segments in tbs.

        # Note: the list returned (tbs) is reused in each iteration
        # through the generator. Make a copy if your use case holds
        # on to it beyond the current iteration or mutates its contents.

        yield exc, tbs
    tbs.pop()

然后我们可以处理叶异常的完整追溯信息。

>>> import traceback
>>>
>>> def g(v):
...     try:
...         raise ValueError(v)
...     except Exception as e:
...         return e
...
>>> def f():
...     raise ExceptionGroup("eg", [g(1), g(2)])
...
>>> try:
...     f()
... except BaseException as e:
...     eg = e
...
>>> for (i, (exc, tbs)) in enumerate(leaf_generator(eg)):
...     print(f"\n=== Exception #{i+1}:")
...     traceback.print_exception(exc)
...     print(f"The complete traceback for Exception #{i+1}:")
...     for tb in tbs:
...         traceback.print_tb(tb)
...

=== Exception #1:
Traceback (most recent call last):
  File "<stdin>", line 3, in g
ValueError: 1
The complete traceback for Exception #1
  File "<stdin>", line 2, in <module>
  File "<stdin>", line 2, in f
  File "<stdin>", line 3, in g

=== Exception #2:
Traceback (most recent call last):
  File "<stdin>", line 3, in g
ValueError: 2
The complete traceback for Exception #2:
  File "<stdin>", line 2, in <module>
  File "<stdin>", line 2, in f
  File "<stdin>", line 3, in g
>>>

except*

我们建议引入 try..except 语法的新变体,以简化异常组的处理。 * 符号表示每个 except* 子句可以处理多个异常。

try:
    ...
except* SpamError:
    ...
except* FooError as e:
    ...
except* (BarError, BazError) as e:
    ...

在传统的 try-except 语句中,只有一个异常需要处理,因此最多只有一个 except 子句的主体会执行;它是第一个匹配异常的子句。使用新语法,except* 子句可以匹配所引发异常组的一个子组,而其余部分则由后面的 except* 子句匹配。换句话说,一个异常组可以导致多个 except* 子句执行,但每个这样的子句最多执行一次(针对组中所有匹配的异常),并且每个异常要么由一个子句(第一个匹配其类型的子句)精确处理,要么最终被重新引发。每个异常由 try-except* 块处理的方式与组中任何其他异常无关。

例如,假设上述 try 块的主体引发 eg = ExceptionGroup('msg', [FooError(1), FooError(2), BazError()])except* 子句按顺序通过调用 splitunhandled 异常组上进行评估,该组最初等于 eg,然后随着异常被匹配和从中提取而缩小。在第一个 except* 子句中,unhandled.split(SpamError) 返回 (None, unhandled),因此此块的主体不执行,并且 unhandled 不变。对于第二个块,unhandled.split(FooError) 返回一个非平凡的拆分 (match, rest),其中 match = ExceptionGroup('msg', [FooError(1), FooError(2)])rest = ExceptionGroup('msg', [BazError()])。此 except* 块的主体执行,esys.exc_info() 的值设置为 match。然后,unhandled 设置为 rest。最后,第三个块匹配剩余的异常,因此它执行时 esys.exc_info() 设置为 ExceptionGroup('msg', [BazError()])

异常使用子类检查进行匹配。例如:

try:
    low_level_os_operation()
except* OSError as eg:
    for e in eg.exceptions:
        print(type(e).__name__)

可能输出

BlockingIOError
ConnectionRefusedError
OSError
InterruptedError
BlockingIOError

except* 子句的顺序与常规 try..except 一样,也是有意义的。

>>> try:
...     raise ExceptionGroup("problem", [BlockingIOError()])
... except* OSError as e:   # Would catch the error
...     print(repr(e))
... except* BlockingIOError: # Would never run
...     print('never')
...
ExceptionGroup('problem', [BlockingIOError()])

递归匹配

except* 子句与异常组的匹配是递归执行的,使用 split() 方法。

>>> try:
...     raise ExceptionGroup(
...         "eg",
...         [
...             ValueError('a'),
...             TypeError('b'),
...             ExceptionGroup(
...                 "nested",
...                 [TypeError('c'), KeyError('d')])
...         ]
...     )
... except* TypeError as e1:
...     print(f'e1 = {e1!r}')
... except* Exception as e2:
...     print(f'e2 = {e2!r}')
...
e1 = ExceptionGroup('eg', [TypeError('b'), ExceptionGroup('nested', [TypeError('c')])])
e2 = ExceptionGroup('eg', [ValueError('a'), ExceptionGroup('nested', [KeyError('d')])])
>>>

未匹配的异常

如果异常组中的所有异常都没有被 except* 子句匹配,那么组的其余部分将继续传播。

>>> try:
...     try:
...         raise ExceptionGroup(
...             "msg", [
...                  ValueError('a'), TypeError('b'),
...                  TypeError('c'), KeyError('e')
...             ]
...         )
...     except* ValueError as e:
...         print(f'got some ValueErrors: {e!r}')
...     except* TypeError as e:
...         print(f'got some TypeErrors: {e!r}')
... except ExceptionGroup as e:
...     print(f'propagated: {e!r}')
...
got some ValueErrors: ExceptionGroup('msg', [ValueError('a')])
got some TypeErrors: ExceptionGroup('msg', [TypeError('b'), TypeError('c')])
propagated: ExceptionGroup('msg', [KeyError('e')])
>>>

裸异常

如果在 try 主体中引发的异常不是 ExceptionGroupBaseExceptionGroup 类型,我们称之为 裸异常。如果它的类型匹配其中一个 except* 子句,它将被捕获并包装在一个带有空消息字符串的 ExceptionGroup(如果它不是 Exception 子类,则为 BaseExceptionGroup)中。这是为了使 e 的类型保持一致并静态可知。

>>> try:
...     raise BlockingIOError
... except* OSError as e:
...     print(repr(e))
...
ExceptionGroup('', [BlockingIOError()])

然而,如果一个裸异常没有被捕获,它将以其原始的裸形式传播。

>>> try:
...     try:
...         raise ValueError(12)
...     except* TypeError as e:
...         print('never')
... except ValueError as e:
...     print(f'caught ValueError: {e!r}')
...
caught ValueError: ValueError(12)
>>>

except* 块中引发异常

在传统的 except 块中,有两种引发异常的方式:raise e 明确地引发一个异常对象 e,或者裸 raise 重新引发“当前异常”。当 e 是当前异常时,这两种形式不等价,因为重新引发不会将当前帧添加到堆栈中。

def foo():                           | def foo():
    try:                             |     try:
        1 / 0                        |         1 / 0
    except ZeroDivisionError as e:   |     except ZeroDivisionError:
        raise e                      |         raise
                                     |
foo()                                | foo()
                                     |
Traceback (most recent call last):   | Traceback (most recent call last):
  File "/Users/guido/a.py", line 7   |   File "/Users/guido/b.py", line 7
   foo()                             |     foo()
  File "/Users/guido/a.py", line 5   |   File "/Users/guido/b.py", line 3
   raise e                           |     1/0
  File "/Users/guido/a.py", line 3   | ZeroDivisionError: division by zero
   1/0                               |
ZeroDivisionError: division by zero  |

这对于异常组也适用,但现在情况更加复杂,因为可能有异常从多个 except* 子句中引发和重新引发,以及需要传播的未处理异常。解释器需要将所有这些异常组合成一个结果,并引发该结果。

重新引发的异常和未处理的异常是原始组的子组,并共享其元数据(原因、上下文、追溯信息)。另一方面,每个显式引发的异常都有自己的元数据——追溯信息包含它被引发的行,其原因可能是它被显式链接到的任何内容,其上下文是 sys.exc_info() 在引发的 except* 子句中的值。

在聚合的异常组中,重新引发和未处理的异常具有与原始异常相同的相对结构,就像它们在一次 subgroup 调用中一起分离出来一样。例如,在下面的代码片段中,内部的 try-except* 块引发了一个 ExceptionGroup,其中包含所有 ValueErrorsTypeErrors,并以它们在原始 ExceptionGroup 中的相同形状合并回来。

>>> try:
...     try:
...         raise ExceptionGroup(
...             "eg",
...             [
...                 ValueError(1),
...                 TypeError(2),
...                 OSError(3),
...                 ExceptionGroup(
...                     "nested",
...                     [OSError(4), TypeError(5), ValueError(6)])
...             ]
...         )
...     except* ValueError as e:
...         print(f'*ValueError: {e!r}')
...         raise
...     except* OSError as e:
...         print(f'*OSError: {e!r}')
... except ExceptionGroup as e:
...     print(repr(e))
...
*ValueError: ExceptionGroup('eg', [ValueError(1), ExceptionGroup('nested', [ValueError(6)])])
*OSError: ExceptionGroup('eg', [OSError(3), ExceptionGroup('nested', [OSError(4)])])
ExceptionGroup('eg', [ValueError(1), TypeError(2), ExceptionGroup('nested', [TypeError(5), ValueError(6)])])
>>>

当异常被显式引发时,它们独立于原始异常组,并且不能与它合并(它们有自己的原因、上下文和追溯信息)。相反,它们被组合成一个新的 ExceptionGroup(或 BaseExceptionGroup),其中还包含上面描述的重新引发/未处理的子组。

在以下示例中,ValueErrors 被引发,因此它们位于自己的 ExceptionGroup 中,而 OSErrors 被重新引发,因此它们与未处理的 TypeErrors 合并。

>>> try:
...     raise ExceptionGroup(
...         "eg",
...         [
...             ValueError(1),
...             TypeError(2),
...             OSError(3),
...             ExceptionGroup(
...                 "nested",
...                 [OSError(4), TypeError(5), ValueError(6)])
...         ]
...     )
... except* ValueError as e:
...     print(f'*ValueError: {e!r}')
...     raise e
... except* OSError as e:
...     print(f'*OSError: {e!r}')
...     raise
...
*ValueError: ExceptionGroup('eg', [ValueError(1), ExceptionGroup('nested', [ValueError(6)])])
*OSError: ExceptionGroup('eg', [OSError(3), ExceptionGroup('nested', [OSError(4)])])
  | ExceptionGroup:  (2 sub-exceptions)
  +-+---------------- 1 ----------------
    | Exception Group Traceback (most recent call last):
    |   File "<stdin>", line 15, in <module>
    |   File "<stdin>", line 2, in <module>
    | ExceptionGroup: eg (2 sub-exceptions)
    +-+---------------- 1 ----------------
      | ValueError: 1
      +---------------- 2 ----------------
      | ExceptionGroup: nested (1 sub-exception)
      +-+---------------- 1 ----------------
        | ValueError: 6
        +------------------------------------
    +---------------- 2 ----------------
    | Exception Group Traceback (most recent call last):
    |   File "<stdin>", line 2, in <module>
    | ExceptionGroup: eg (3 sub-exceptions)
    +-+---------------- 1 ----------------
      | TypeError: 2
      +---------------- 2 ----------------
      | OSError: 3
      +---------------- 3 ----------------
      | ExceptionGroup: nested (2 sub-exceptions)
      +-+---------------- 1 ----------------
        | OSError: 4
        +---------------- 2 ----------------
        | TypeError: 5
        +------------------------------------
>>>

链式

显式引发的异常组与任何异常一样进行链式。以下示例展示了 ExceptionGroup “one”的一部分如何成为 ExceptionGroup “two”的上下文,而另一部分则与它合并成新的 ExceptionGroup

>>> try:
...     raise ExceptionGroup("one", [ValueError('a'), TypeError('b')])
... except* ValueError:
...     raise ExceptionGroup("two", [KeyError('x'), KeyError('y')])
...
  | ExceptionGroup:  (2 sub-exceptions)
  +-+---------------- 1 ----------------
    | Exception Group Traceback (most recent call last):
    |   File "<stdin>", line 2, in <module>
    | ExceptionGroup: one (1 sub-exception)
    +-+---------------- 1 ----------------
      | ValueError: a
      +------------------------------------
    |
    | During handling of the above exception, another exception occurred:
    |
    | Exception Group Traceback (most recent call last):
    |   File "<stdin>", line 4, in <module>
    | ExceptionGroup: two (2 sub-exceptions)
    +-+---------------- 1 ----------------
      | KeyError: 'x'
      +---------------- 2 ----------------
      | KeyError: 'y'
      +------------------------------------
    +---------------- 2 ----------------
    | Exception Group Traceback (most recent call last):
    |   File "<stdin>", line 2, in <module>
    | ExceptionGroup: one (1 sub-exception)
    +-+---------------- 1 ----------------
      | TypeError: b
      +------------------------------------
>>>

引发新异常

在前面的示例中,显式引发的是被捕获的异常,因此为了完整性,我们展示了如何引发新异常,并进行链式调用:

>>> try:
...     raise TypeError('bad type')
... except* TypeError as e:
...     raise ValueError('bad value') from e
...
  | ExceptionGroup:  (1 sub-exception)
  +-+---------------- 1 ----------------
    | Traceback (most recent call last):
    |   File "<stdin>", line 2, in <module>
    | TypeError: bad type
    +------------------------------------

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "<stdin>", line 4, in <module>
ValueError: bad value
>>>

请注意,在一个 except* 子句中引发的异常不能匹配同一 try 语句中的其他子句。

>>> try:
...     raise TypeError(1)
... except* TypeError:
...     raise ValueError(2) from None  # <- not caught in the next clause
... except* ValueError:
...     print('never')
...
Traceback (most recent call last):
  File "<stdin>", line 4, in <module>
ValueError: 2
>>>

引发一个裸异常的新实例不会导致该异常被异常组包装。相反,该异常按原样引发,如果需要与其他传播的异常结合,它将成为为此创建的新异常组的直接子级。

>>> try:
...     raise ExceptionGroup("eg", [ValueError('a')])
... except* ValueError:
...     raise KeyError('x')
...
  | ExceptionGroup:  (1 sub-exception)
  +-+---------------- 1 ----------------
    | Exception Group Traceback (most recent call last):
    |   File "<stdin>", line 2, in <module>
    | ExceptionGroup: eg (1 sub-exception)
    +-+---------------- 1 ----------------
      | ValueError: a
      +------------------------------------
    |
    | During handling of the above exception, another exception occurred:
    |
    | Traceback (most recent call last):
    |   File "<stdin>", line 4, in <module>
    | KeyError: 'x'
    +------------------------------------
>>>
>>> try:
...     raise ExceptionGroup("eg", [ValueError('a'), TypeError('b')])
... except* ValueError:
...     raise KeyError('x')
...
  | ExceptionGroup:  (2 sub-exceptions)
  +-+---------------- 1 ----------------
    | Exception Group Traceback (most recent call last):
    |   File "<stdin>", line 2, in <module>
    | ExceptionGroup: eg (1 sub-exception)
    +-+---------------- 1 ----------------
      | ValueError: a
      +------------------------------------
    |
    | During handling of the above exception, another exception occurred:
    |
    | Traceback (most recent call last):
    |   File "<stdin>", line 4, in <module>
    | KeyError: 'x'
    +---------------- 2 ----------------
    | Exception Group Traceback (most recent call last):
    |   File "<stdin>", line 2, in <module>
    | ExceptionGroup: eg (1 sub-exception)
    +-+---------------- 1 ----------------
      | TypeError: b
      +------------------------------------
>>>

最后,作为展示所提议语义如何帮助我们有效处理异常组的示例,以下代码忽略所有 EPIPE 操作系统错误,同时让所有其他异常传播。

try:
    low_level_os_operation()
except* OSError as errors:
    exc = errors.subgroup(lambda e: e.errno != errno.EPIPE)
    if exc is not None:
        raise exc from None

捕获的异常对象

需要指出的是,绑定到 except* 子句中 e 的异常组是一个临时对象。通过 raiseraise e 引发它不会导致原始异常组的整体形状发生变化。对 e 的任何修改都可能丢失。

>>> eg = ExceptionGroup("eg", [TypeError(12)])
>>> eg.foo = 'foo'
>>> try:
...     raise eg
... except* TypeError as e:
...     e.foo = 'bar'
... #   ^----------- ``e`` is an ephemeral object that might get
>>> #                      destroyed after the ``except*`` clause.
>>> eg.foo
'foo'

禁用组合

在同一个 try 语句中,不能同时使用传统的 except 块和新的 except* 子句。以下是一个 SyntaxError

try:
    ...
except ValueError:
    pass
except* CancelledError:  # <- SyntaxError:
    pass                 #    combining ``except`` and ``except*``
                         #    is prohibited

可以使用 except 捕获 ExceptionGroupBaseExceptionGroup 类型,但不能使用 except*,因为后者具有歧义。

try:
    ...
except ExceptionGroup:  # <- This works
    pass

try:
    ...
except* ExceptionGroup:  # <- Runtime error
    pass

try:
    ...
except* (TypeError, ExceptionGroup):  # <- Runtime error
    pass

不支持空的“匹配所有” except* 块,因为其含义可能令人困惑。

try:
    ...
except*:   # <- SyntaxError
    pass

except* 子句中不允许使用 continuebreakreturn,否则会导致 SyntaxError。这是因为 ExceptionGroup 中的异常被认为是独立的,其中一个异常的存在或缺失不应影响其他异常的处理,这可能会发生,如果我们允许 except* 子句改变控制流通过其他子句的方式。

向后兼容性

向后兼容性是我们设计的要求,本 PEP 中提出的更改不会破坏任何现有代码。

  • 新增的内置异常类型 ExceptionGroupBaseExceptionGroup 不影响现有程序。现有异常的处理和显示方式没有任何改变。
  • except 的行为未改变,因此现有代码将继续工作。程序仅在使用异常组和 except* 后才会受到本 PEP 中提出的更改的影响。
  • 一个重要的担忧是,except Exception: 将继续捕获几乎所有异常,通过使 ExceptionGroup 继承 Exception,我们确保了这一点。BaseExceptionGroups 将不会被捕获,这是合适的,因为它们包含的异常不会被 except Exception 捕获。

一旦程序开始使用这些功能,将需要考虑迁移问题。

  • 一个 except T: 子句包装的代码现在可能引发一个异常组,可能需要变成 except* T:,并且它的主体可能需要更新。这意味着引发异常组是一个 API 破坏性更改,很可能会在新的 API 中完成,而不是添加到现有的 API 中。
  • 需要支持旧版 Python 的库将无法使用 except* 或引发异常组。

如何教授此内容

异常组和 except* 将作为语言标准的一部分进行文档化。引发异常组的库,如 asyncio,需要在其文档中指定这一点,并阐明哪些 API 调用需要用 try-except* 而不是 try-except 包装。

参考实现

我们在参考实现 [11] 的帮助下开发了这些概念(以及本 PEP 的示例)。

它包含内置的 ExceptionGroup 以及对回溯格式化代码的更改,此外还有支持 except* 所需的语法、编译器和解释器更改。BaseExceptionGroup 将很快添加。

新增了两个操作码:一个通过 ExceptionGroup.split() 实现异常类型匹配检查,另一个用于 try-except 构造的末尾,以合并所有未处理、已引发和重新引发的异常(如果有)。已引发/重新引发的异常收集在运行时堆栈上的一个列表中。为此,每个 except* 子句的主体都封装在一个传统的 try-except 中,该 try-except 捕获任何引发的异常。已引发和重新引发的异常都收集在同一个列表中。当需要将它们合并成结果时,通过比较它们的元数据字段(上下文、原因、追溯)与最初引发的异常的元数据字段来区分已引发和重新引发的异常。如上所述,重新引发的异常具有与原始异常相同的元数据,而已引发的异常则没有。

被拒绝的想法

使异常组可迭代

我们考虑过让异常组可迭代,这样 list(eg) 就会生成一个包含在该组中的扁平叶子异常列表。我们认为这不是一个健全的 API,因为组中单个异常的元数据(原因、上下文和追溯信息)不完整,这可能会产生问题。

此外,正如我们在 处理异常组 部分解释的,我们认为对叶异常的迭代不太可能有很多用例。然而,我们确实在那里提供了遍历算法的代码,该算法正确地构建了每个叶异常的元数据。如果它在实践中确实有用,我们将来可以将其添加到标准库中,甚至使异常组可迭代。

使 ExceptionGroup 继承 BaseException

我们曾考虑让 ExceptionGroup 只继承 BaseException,而不继承 Exception。这样做的理由是,我们期望异常组只在需要时有意识地使用,并且只由专门设计和文档化的 API 引发。在这种情况下,一个不打算引发异常组的 API 泄露 ExceptionGroup 是一个 bug,我们希望将其赋予“致命错误”状态,以便 except Exception 不会无意中吞噬它。这将与 except T: 不捕获包含 T 的所有其他类型的异常组的方式一致,并将有助于将 ExceptionGroups 限制在程序中应该出现的部分。然而,从公开讨论中很明显,T=Exception 是一个特殊情况,有些开发者强烈认为 except Exception: 应该捕获“几乎所有东西”,包括异常组。这就是为什么我们决定让 ExceptionGroup 成为 Exception 的子类。

使 BaseExceptions 无法封装在异常组中

ExceptionGroup 扩展 Exception 的决定所带来的一个结果是,ExceptionGroup 不应封装 KeyboardInterruptBaseExceptions,因为它们目前不会被 except Exception: 捕获。我们考虑过简单地使其无法封装 BaseExceptions,但最终决定通过 BaseExceptionGroup 类型使其成为可能,该类型继承 BaseException 而不是 Exception。使其成为可能增加了语言的灵活性,并将权衡封装 BaseExceptions 而不是以其裸形式传播并丢弃任何其他异常的利弊留给程序员。

追溯信息表示

我们考虑过调整追溯信息数据结构以表示树形结构,但后来发现追溯信息树一旦脱离其所指的异常,便毫无意义。虽然一个简单的路径追溯信息可以通过 with_traceback() 调用附加到任何异常,但很难想象将追溯信息树分配给异常组的情况是有意义的。此外,有用的追溯信息显示包括有关嵌套异常的信息。出于这些原因,我们决定最好保持追溯信息机制不变,并修改追溯信息显示代码。

扩展 except 以处理异常组

我们考虑过扩展 except 的语义来处理异常组,而不是引入 except*。这里有两个向后兼容性问题。第一个是捕获的异常类型。考虑这个例子:

try:
    . . .
except OSError as err:
    if err.errno != ENOENT:
        raise

如果赋给 err 的值是一个包含所有引发的 OSErrors 的异常组,那么属性访问 err.errno 将不再起作用。因此,我们需要多次执行 except 子句的主体,对组中的每个异常执行一次。然而,这同样是一个潜在的破坏性更改,因为目前我们编写 except 子句时都知道它们只执行一次。如果存在非幂等操作,例如释放资源,重复执行可能会有害。

except 迭代异常组的叶异常的想法是 Nathaniel J. Smith 对本 PEP 的替代提案 的核心,关于该提案的讨论进一步阐述了在 Python 这种成熟语言中更改 except 语义以及偏离其他语言中并行构造语义的陷阱。

公开讨论中出现的另一个选择是添加 except*,但同时让 exceptExceptionGroups 视为特殊情况。except 随后将执行类似从组中提取一个匹配类型的异常以进行处理(同时丢弃组中所有其他异常)的操作。这些建议背后的动机是使异常组的采用更安全,即 except T 捕获包装在异常组中的 T。我们认为这种方法在不增加语言功能的情况下,显著增加了语言语义的复杂性。即使它能使异常组的采用稍微容易一些(这一点根本不明显),但从长远来看,这些并不是我们希望拥有的语义。

新的 except 替代方案

我们考虑过引入一个新的关键字(例如 catch),它可以用来处理裸异常和异常组。它的语义与捕获异常组时的 except* 相同,但它不会包装裸异常来创建异常组。这本来是替换 except 的长期计划的一部分,但我们认为此时弃用 except 而转而使用增强关键字对用户来说会过于混乱,因此引入 except* 语法来处理异常组更合适,而 except 继续用于简单的异常。

一次性对一个异常应用 except* 子句

我们前面解释过,在现有代码中多次执行 except 子句是不安全的,因为代码可能不是幂等的。我们考虑在新 except* 子句中这样做,其中不存在向后兼容性考虑。想法是始终对单个异常执行 except* 子句,当它匹配多个异常时,可能会多次执行相同的子句。我们最终决定每个 except* 子句最多执行一次,并为其提供一个包含所有匹配异常的异常组。做出此决定的原因是观察到,当程序需要知道正在处理的异常的特定上下文时,异常会在与其他异常分组并一起引发之前进行处理。

例如,KeyError 是一种通常与特定操作相关的异常。任何恢复代码都将局限于错误发生的位置,并使用传统的 except

try:
    dct[key]
except KeyError:
    # handle the exception

asyncio 用户不太可能想做这样的事情

try:
    async with asyncio.TaskGroup() as g:
        g.create_task(task1); g.create_task(task2)
except* KeyError:
    # handling KeyError here is meaningless, there's
    # no context to do anything with it but to log it.

当程序处理一组聚合到异常组中的异常时,它通常不会尝试从任何特定的失败操作中恢复,而是会使用错误的类型来确定它们应如何影响程序的控制流,或者需要进行哪些日志记录或清理。无论组中包含单个还是多个 KeyboardInterruptasyncio.CancelledError 等实例,此决定可能都是相同的。因此,一次性处理所有匹配 except* 的异常更为方便。如果确实有必要,处理程序可以检查异常组并处理其中的单个异常。

不在 except* 中匹配裸异常

我们曾考虑过让 except* T 仅匹配包含 T 的异常组,而不匹配裸 T。要了解我们为什么认为这不是一个理想的特性,请回到上一段中对操作错误和控制流异常的区别。如果我们不知道应该从 try 块的主体中期望裸异常还是异常组,那么我们就无法处理操作错误。相反,我们很可能正在调用一个相当通用的函数,并将通过处理错误来做出控制流决策。无论我们捕获类型为 T 的裸异常,还是包含一个或多个 T 的异常组,我们很可能会做同样的事情。因此,必须明确处理两者所带来的负担不太可能具有语义上的好处。

如果确实需要区分,总可以在 try-except* 子句中嵌套一个额外的 try-except 子句,该子句在 except* 子句有机会将裸异常包装在异常组中之前拦截并处理它。在这种情况下,指定两者的开销并非额外负担——我们确实需要编写单独的代码块来处理每种情况。

try:
    try:
        ...
    except SomeError:
        # handle the naked exception
except* SomeError:
    # handle the exception group

允许在同一个 try 中混合使用 except:except*:

此选项被拒绝,因为它增加了复杂性而没有增加有用的语义。大概意图是 except T: 块只处理类型为 T 的裸异常,而 except* T: 处理异常组中的 T。我们已经讨论过为什么这在实践中不太有用,如果需要,可以使用嵌套的 try-except 块来达到相同的结果。

try* 而不是 except*

由于 try 构造的所有子句要么都是 except*,要么都不是,我们考虑过改变 try 的语法,而不是所有 except* 子句。我们拒绝了这一点,因为它会不那么明显。我们正在处理 T 的异常组,而不是仅仅裸露的 T,这个事实应该在声明 T 的同一个地方指定。

替代语法选项

except* 语法的替代方案在 python-dev 上的讨论 中进行了评估,并建议使用 except group。经过仔细评估,这被拒绝了,因为以下内容会产生歧义,因为它目前是有效的语法,其中 group 被解释为可调用对象。对于任何有效的标识符也是如此。

try:
   ...
except group (T1, T2):
   ...

不使用 'except *' 编程

考虑下面这个简单的 except* 语法示例(假设 Trio 本地支持此提案)

try:
    async with trio.open_nursery() as nursery:
        # Make two concurrent calls to child()
        nursery.start_soon(child)
        nursery.start_soon(child)
except* ValueError:
    pass

这段代码在 Python 3.9 中的样子如下:

def handle_ValueError(exc):
    if isinstance(exc, ValueError):
        return None
    else:
        return exc   # reraise exc

with MultiError.catch(handle_ValueError):
    async with trio.open_nursery() as nursery:
        # Make two concurrent calls to child()
        nursery.start_soon(child)
        nursery.start_soon(child)

这个例子清楚地表明了在当前 Python 中处理多个错误是多么不直观和繁琐。异常处理逻辑必须在一个单独的闭包中,并且是相当低级的,要求编写者对 Python 异常机制和 Trio API 都有不平凡的理解。我们不得不使用 with 块而不是 try..except 块。我们需要显式地重新引发我们未处理的异常。处理更多的异常类型或实现更复杂的异常处理逻辑只会使代码进一步复杂化到难以阅读的程度。

另请参阅

  • 对 asyncio 程序中异常组可能如何使用的分析:[10]
  • except* 概念首次形式化的议题:[12]
  • MultiError2 设计文档:[3]
  • 在 Hypothesis 库中报告多个错误:[7]

致谢

我们感谢 Nathaniel J. Smith 和其他 Trio 开发者在结构化并发方面的工作。我们借鉴了 MultiError 中构建异常树(其节点是异常)的想法,以及 MultiError V2 设计文档中的 split() API。在 python-dev 和其他地方的讨论帮助我们从多个方面改进了 PEP 的初稿,包括设计和阐述。为此,我们感谢所有贡献想法并提出好问题的人:Ammar Askar, Matthew Barnett, Ran Benita, Emily Bowman, Brandt Bucher, Joao Bueno, Baptiste Carvello, Rob Cliffe, Alyssa Coghlan, Steven D’Aprano, Caleb Donovick, Steve Dower, Greg Ewing, Ethan Furman, Pablo Salgado, Jonathan Goble, Joe Gottman, Thomas Grainger, Larry Hastings, Zac Hatfield-Dodds, Chris Jerdonek, Jim Jewett, Sven Kunze, Łukasz Langa, Glenn Linderman, Paul Moore, Antoine Pitrou, Ivan Pozdeev, Patrick Reader, Terry Reedy, Sascha Schlemmer, Barry Scott, Mark Shannon, Damian Shaw, Cameron Simpson, Gregory Smith, Paul Sokolovsky, Calvin Spealman, Steve Stagg, Victor Stinner, Marco Sulla, Petr Viktorin 和 Barry Warsaw。

接受

PEP 6542021年9月24日由 Thomas Wouters 接受

参考资料


来源:https://github.com/python/peps/blob/main/peps/pep-0654.rst

最后修改:2025-02-01 08:55:40 GMT