一览 Python 3.11 新语法:try-except*

4,684

官方文档(What’s New In Python 3.11)预告将会在下一个 Python 的正式版本中引入“异常组(exception group)”的概念,并添加与其对应的 except* 语法扩展:

官方预告

目前发布的 Python 3.11.0a3 仍未实现此功能,下一个 alpha 版本预计会在 2022 年 1 月 3 日发布。

Python 发布周期

异常组

这个概念的提出是为了让程序在一个时间内同时抛出/处理多个异常

BaseExceptionGroupExceptionGroup

这两种新的异常类型能组合几个不相关的异常并一起传播。简单来说,抛出一个异常组就等同于同时抛出 n 个异常。举个例子:

raise ExceptionGroup('bad param', [ValueError('bad value'), TypeError('bad type')])

错误信息展示如下:

  | ExceptionGroup
  +-+---------------- 1 ----------------
    | Exception Group Traceback (most recent call last):
    |   File "<stdin>", line 1, in <module>
    | ExceptionGroup: bad param
    +-+---------------- 1 ----------------
      | ValueError: bad value
      +------------------------------------
      | TypeError: bad type
      +------------------------------------

ExceptionGroup 的第一个参数为信息字符串 message,第二个参数为异常序列(listtuple 等)。当然多层嵌套的序列也是行的:

raise ExceptionGroup("one", [
  TypeError(1),
  ExceptionGroup("two", [TypeError(2), ValueError(3)]),
  ExceptionGroup("three", [OSError(4)]),
])

BaseExceptionGroup 继承自 BaseException 类,ExceptionGroup 继承自 Exception 类。

Exception 关系图

ExceptionGroup.subgroupExceptionGroup.split 方法

新增的两个方法。subgroup(condition) 可以根据 condition 函数的返回值递归地筛选异常组里的异常/异常组。

举个例子:

eg = ExceptionGroup("one", [
  TypeError(1),
  ExceptionGroup("two", [
      TypeError(2),
      ValueError(3)
  ]),
  ExceptionGroup("three", [
      OSError(4)
  ])
])

执行 eg.subgroup(lambda e: isinstance(e, TypeError)) 将得到:

ExceptionGroup("one", [
    TypeError(1),
    ExceptionGroup("two", [
        TypeError(2)
    ])
])

subgroup 类似的方法还有 split(condtion)split 返回一个元组,第一个值为匹配的结果,第二个值为不匹配的结果。

还是以上面的 eg 为例,eg.split(lambda e: isinstance(e, TypeError)) 将得到:

(
    ExceptionGroup("one", [
        TypeError(1),
        ExceptionGroup("two", [
            TypeError(2)
        ])
    ]),
    ExceptionGroup("one", [
        ExceptionGroup("two", [
            ValueError(3)
        ]),
        ExceptionGroup("three", [
            OSError(4)
        ])
    ])
)

except*

一个异常组能触发多个 except* 子句。

对于组中所有匹配的异常,每个 except* 子句最多执行一次。每个异常要么由第一个匹配其类型的子句处理,要么在最后重新抛出。

为了能更清楚的理解 try-except* 背后的整个执行过程,我将以下面这段代码作为例子:

try:
    raise ExceptionGroup('msg', [FooError(1), FooError(2), BazError()])
except* SpamError:
    ...
except* FooError:
    ...

我们在 try 子句中抛出了一个异常组:

ExceptionGroup("msg", [FooError(1), FooError(2), BazError()])

在第一个except* 子句中,Python 解释器将 unhandled 初始化为这个异常组,并调用 unhandled.split(SpamError),得到结果:

(
    None,  # 匹配的异常
    ExceptionGroup("msg", [FooError(1), FooError(2), BazError()])  # 其他异常
)

第一个值值为 None,表示没有匹配,这个 except* 块不执行。接着来到第二个 except* 子句,程序执行 unhandled.split(FooError),返回:

(
    ExceptionGroup('msg', [FooError(1), FooError(2)]),  # 匹配的异常
    ExceptionGroup('msg', [BazError()])  # 其他异常
)

第一个值不为 None,执行这个 except* 块。ExceptionGroup('msg', [BazError()]) 赋值给 unhandled

重复整个流程直至结束,如果最后 unhandled 的值不为 None,则重新抛出异常并打印错误信息。

exceptexcept* 的可组合性

这个混用 exceptexcept* 的概念大致上是为了让一个 except* T: 处理异常组中的 T 异常,再用一个 except T: 来处理不在异常组里的 T 异常(简称裸异常)。这个想法被官方拒绝了,认为这种语法没有增加有用语义,反而提高了复杂性。

这种方式在实践中意义不大,但如果需要,则可以使用嵌套的 try-except 块来实现相同的结果:

try:
    try:
        ...
    except SomeError:
        # 处理裸异常
except* SomeError:
    # 处理异常组

此外,except 能捕获 BaseExceptionGroupExceptionGroup,但 except* 不能(这个语法是模糊的,被禁止了)。

except ValueError:  // OK, 捕获裸异常
except ExceptionGroup:  // OK, 捕获异常组
except* ValueError:  // OK, 捕获裸异常 & 异常组
except* ExceptionGroup: // 错误!
except*: // 错误!

except* 捕获的裸异常会当作异常组处理:

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

为什么不直接拓展 except 的功能,而是引入了一种新的语法?

原因很简单:版本兼容性问题。

  1. 捕获的类型不同。

    假如我们之前有这样的一段代码:

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

    如果 except 的功能被拓展了,err 的类型将会是 ExceptionGroup,那么访问 err.errno 属性将会导致错误。

  2. 多个 except 子句只执行一次,但多个 except* 子句可执行多次。

    这是一个潜在的破坏性变化,因为目前它打破了我们对 except 只执行一次的认知。如果之前版本的 except 子句中包含非幂等操作(执行第一次和第二次结果不同的操作,例如释放资源)将会出现灾难性的问题。

新增 __note__ 属性

BaseException 新增了一个可变属性 __note__(默认为 None)。这个属性可以作为异常的注释,会连同错误信息一起打印出来。

至于用途嘛,就捕获异常后添加信息比较方便,总比用 print 来得好吧 (手动狗头)。

我装了 Python 3.11.0a3(下载链接)试试:

try:
    1 / 0
except Exception as e:
    e.__note__ = "Custom message"
    raise
Traceback (most recent call last):
  File "<pyshell#12>", line 2, in <module>
    1 / 0
ZeroDivisionError: division by zero
Custom message

参考资料

  1. docs.python.org/3.11/whatsn…
  2. www.python.org/dev/peps/pe…
  3. bugs.python.org/issue45607
  4. www.python.org/dev/peps/pe…