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 *”的编程 部分)。

    受 Trio 育儿室 [13] 的启发,在 asyncio 中实现更好的任务生成 API 是此 PEP 的主要动机。这项工作目前受阻于 Python 缺乏对异常组的原生语言级支持。

  • 重试操作时的多次失败。Python 标准库的 socket.create_connection 函数可能会尝试连接到不同的地址,如果所有尝试都失败,则需要向用户报告该情况。如何聚合这些错误是一个悬而未决的问题,尤其是在它们不同的情况下(请参阅问题 29980 [4])。
  • 多个用户回调失败。Python 的 atexit.register() 函数允许用户注册在系统退出时调用的函数。如果任何一个引发异常,则仅重新引发最后一个异常,但最好将所有异常一起重新引发(请参阅 atexit 文档 [5])。类似地,pytest 库允许用户注册在拆卸时执行的终结器。如果多个这些终结器引发异常,则仅向用户报告第一个异常。这可以通过 ExceptionGroups 进行改进,如 pytest 开发人员 Ran Benita 在此问题中所述(请参阅 pytest 问题 8217 [6])。
  • 复杂计算中的多个错误。Hypothesis 库执行自动错误减少(简化演示错误的代码)。在此过程中,它可能会发现产生不同错误的变化,并且(可选地)报告所有错误(请参阅 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 上定义的 derive,如果所有包含的异常都是 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,而不是对 egTs 的数量感兴趣。

但是,在某些情况下,需要检查单个叶子异常。例如,假设我们有一个异常组 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* 子句按顺序通过对 unhandled 异常组调用 split 来评估,该组最初等于 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()])

递归匹配

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

>>> 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* 子句引发和重新引发异常,以及需要传播的未处理异常。解释器需要将所有这些异常组合成结果,并引发该结果。

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

在聚合的异常组中,重新引发的异常和未处理的异常与原始异常中的相对结构相同,就像它们在一个 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 OS 错误,同时允许所有其他异常传播。

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

continuebreakreturnexcept* 子句中是不允许的,会导致 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 中,该子句捕获任何引发的异常。已引发和重新引发的异常都收集在同一个列表中。当需要将它们合并到结果中时,通过将已引发和重新引发的异常的元数据字段(上下文、原因、跟踪回溯)与最初引发的异常的元数据字段进行比较来区分它们。如上所述,重新引发的异常具有与原始异常相同的元数据,而已引发的异常则没有。

被拒绝的想法

使异常组可迭代

我们考虑过使异常组可迭代,以便 list(eg) 会生成组中包含的叶子异常的扁平列表。我们认为这不是一个健全的 API,因为组中各个异常的元数据(原因、上下文和跟踪回溯)是不完整的,这可能会导致问题。

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

使 ExceptionGroup 扩展 BaseException

我们考虑过让 ExceptionGroup 仅作为 BaseException 的子类,而不是 Exception 的子类。这样做的理由是,我们希望异常组以一种有目的的方式在需要的地方使用,并且仅由专门设计和记录为这样做的 API 引发。在这种情况下,从不打算引发异常组的 API 中逃逸的 ExceptionGroup 是一个错误,我们希望赋予它“致命错误”状态,以便 except Exception 不会意外地吞没它。这将与 except T: 对于所有其他类型,都不会捕获包含 T 的异常组的方式一致,并将有助于将 ExceptionGroups 限制在它们应该出现的程序部分。但是,从公开讨论中可以清楚地看出,T=Exception 是一个特例,并且有一些开发人员强烈认为 except Exception: 应该捕获“几乎所有内容”,包括异常组。这就是我们决定使 ExceptionGroup 成为 Exception 的子类的原因。

使在异常组中包装 BaseExceptions 成为不可能

决定使 ExceptionGroup 扩展 Exception 的一个结果是,ExceptionGroup 不应该包装 BaseExceptions(如 KeyboardInterrupt),因为它们目前不会被 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 会捕获包装在异常组中的 Ts。我们认为这种方法会给语言的语义带来相当大的复杂性,而不会使其更强大。即使它会使异常组的采用稍微容易一些(这一点并不完全明显),但这不是我们长期希望拥有的语义。

新的 except 替代方案

我们考虑引入一个新的关键字(例如catch),它可以用来处理裸异常和异常组。当捕获异常组时,它的语义与except*相同,但它不会将裸异常包装成异常组。这本来是长期计划的一部分,用catch替换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仅匹配包含Ts的异常组,但不匹配裸Ts。要了解为什么我们认为这不是一个理想的功能,请回到上一段中操作错误和控制流异常之间的区别。如果我们不知道应该从try块的主体中预期裸异常还是异常组,那么我们就无法处理操作错误。相反,我们可能正在调用一个相当通用的函数,并将处理错误以做出控制流决策。无论我们捕获类型为T的裸异常还是包含一个或多个Ts的异常组,我们都可能会做同样的事情。因此,必须显式处理这两者的负担不太可能带来语义上的好处。

如果确实需要进行区分,始终可以在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的异常组而不是仅处理裸Ts,这一点应该在声明T的同一位置指定。

替代语法选项

python-dev 上的讨论中评估了except*语法的替代方案,并建议使用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 都有非平凡的理解。我们不必使用try..except块,而是必须使用with块。我们需要显式地重新引发我们未处理的异常。处理更多异常类型或实现更复杂的异常处理逻辑只会进一步使代码复杂化,直到它变得难以阅读。

另请参阅

  • 对异常组在 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 Jewel、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

上次修改时间:2023-10-11 12:05:51 GMT