geekdoc-python-zh/docs/realpython/python311-exception-groups.md

63 KiB
Raw Blame History

Python 3.11 预览版:任务和异常组

原文:https://realpython.com/python311-exception-groups/

Python 3.11 将于 2022 年 10 月发布。尽管十月份还有几个月的时间,但是您已经可以预览一些即将到来的特性,包括 Python 3.11 必须提供的新任务和异常组。任务组可以让您更好地组织异步代码,而异常组可以收集同时发生的几个错误,并让您以直接的方式处理它们。

在本教程中,您将:

  • 在你的电脑上安装 Python 3.11 alpha就在你当前安装的 Python 旁边
  • 探索异常组如何组织几个不相关的错误
  • 过滤器带有except*的异常组处理不同类型的错误
  • 使用任务组来设置你的异步代码
  • 测试 Python 3.11 中较小的改进,包括异常注释和一个新的内部异常表示

Python 3.11 中还有许多其他的改进和特性。请查看变更日志中的新增内容,获取最新列表。

免费奖励: 掌握 Python 的 5 个想法,这是一个面向 Python 开发者的免费课程,向您展示将 Python 技能提升到下一个水平所需的路线图和心态。

Python 3.11 Alpha

Python 的新版本在每年 10 月发布。代码是在发布日期前经过 17 个月的时间开发和测试的。新功能在 alpha 阶段实现,持续到五月,大约在最终发布前五个月。

大约每月一次在 alpha 阶段Python 的核心开发者发布一个新的 alpha 版本来展示新特性,测试它们,并获得早期反馈。目前 Python 3.11 的最新 alpha 版本是 3.11.0a7 ,发布于 2022 年 4 月 5 日。

**注:**本教程使用的是 Python 3.11 的第七个 alpha 版本。如果您使用更高版本,可能会遇到一些小的差异。特别是,任务组实现的一些方面仍在讨论中。然而,你可以期望你在这里学到的大部分内容在 alpha 和 beta 阶段以及 Python 3.11 的最终版本中保持不变。

Python 3.11 的第一个 beta 版本即将发布,计划于 2022 年 5 月 6 日发布。通常,在测试阶段不会添加新功能。相反,特性冻结和发布日期之间的时间被用来测试和固化代码。

Remove ads

很酷的新功能

Python 3.11 的一些最新亮点包括:

  • 异常组,允许程序同时引发和处理多个异常
  • 任务组,改善你如何运行异步代码
  • 增强的错误消息,这将帮助你更有效地调试你的代码
  • 优化,承诺使 Python 3.11 比以前的版本明显更快
  • 静态类型的改进,这将让你更精确地注释你的代码
  • TOML 支持,它允许你使用标准库解析 TOML 文档

Python 3.11 有很多值得期待的地方!要获得全面的概述,请查看 Python 3.11:供您尝试的酷新功能。您还可以在本系列的其他文章中更深入地研究上面列出的一些特性:

在本教程中,您将关注异常组如何一次处理多个不相关的异常,以及该特性如何为任务组铺平道路,从而使 Python 中的并发编程更加方便。您还将看到 Python 3.11 中其他一些更小的特性。

安装

要使用本教程中的代码示例,您需要在系统上安装 Python 3.11 版本。在这一小节中,你将学习几种不同的方法:使用 Docker ,使用 pyenv ,或者从安装。选择最适合您和您的系统的一个。

注意: Alpha 版本是即将推出的功能的预览。虽然大多数特性都可以很好地工作,但是您不应该在生产中依赖任何 Python 3.11 alpha 版本,也不应该依赖任何潜在错误会带来严重后果的地方。

如果您可以在您的系统上访问 Docker ,那么您可以通过拉取并运行python:3.11-rc-slim Docker 镜像来下载最新版本的 Python 3.11:

$ docker pull python:3.11-rc-slim
Unable to find image 'python:3.11-rc-slim' locally
latest: Pulling from library/python
[...]

$ docker run -it --rm python:3.11-rc-slim

这会将您带入 Python 3.11 REPL。查看 Docker 中的运行 Python 版本,了解更多关于通过 Docker 使用 Python 的信息,包括如何运行脚本。

pyenv 工具非常适合管理系统上不同版本的 Python如果你愿意你可以用它来安装 Python 3.11 alpha。它有两个不同的版本一个用于 Windows一个用于 Linux 和 macOS。使用下面的切换器选择您的平台:

**在 Windows 上,你可以使用 pyenv-win 。首先更新您的pyenv安装:

PS> pyenv update
:: [Info] ::  Mirror: https://www.python.org/ftp/python
[...]

进行更新可以确保您可以安装最新版本的 Python。你也可以手动更新pyenv

在 Linux 和 macOS 上,可以使用 pyenv 。首先使用 pyenv-update 插件更新您的pyenv安装:

$ pyenv update
Updating /home/realpython/.pyenv...
[...]

进行更新可以确保您可以安装最新版本的 Python。如果不想用更新插件可以手动更新pyenv

使用pyenv install --list查看 Python 3.11 有哪些版本。然后,安装最新版本:

$ pyenv install 3.11.0a7
Downloading Python-3.11.0a7.tar.xz...
[...]

安装可能需要几分钟时间。一旦你的新 alpha 版本安装完毕,你就可以创建一个虚拟环境来玩它:

PS> pyenv local 3.11.0a7
PS> python --version
Python 3.11.0a7

PS> python -m venv venv
PS> venv\Scripts\activate

您使用pyenv local激活您的 Python 3.11 版本,然后使用python -m venv设置虚拟环境。

$ pyenv virtualenv 3.11.0a7 311_preview
$ pyenv activate 311_preview
(311_preview) $ python --version
Python 3.11.0a7

在 Linux 和 macOS 上,你使用 pyenv-virtualenv 插件来设置虚拟环境并激活它。

你也可以从python.org的预发布版本中安装 Python。选择最新预发布,向下滚动到页面底部的文件部分。下载并安装与您的系统对应的文件。更多信息参见 Python 3 安装&设置指南

本教程中的许多示例将适用于旧版本的 Python但一般来说您应该使用 Python 3.11 可执行文件来运行它们。具体如何运行可执行文件取决于您的安装方式。如果您需要帮助,请参见关于 Dockerpyenv虚拟环境从源安装的相关教程。

Remove ads

Python 3.11 中的异常组和except*

处理异常是编程的重要部分。有时错误是因为代码中的错误而发生的。在这些情况下,好的错误消息将帮助你有效地调试你的代码。其他时候,错误的发生并不是因为代码的错误。可能是用户试图打开一个损坏的文件,可能是网络中断,或者可能是数据库的身份验证丢失。

通常,一次只会发生一个错误。如果您的代码继续运行,可能会发生另一个错误。但是 Python 通常只会报告它遇到的第一个错误。但是,在某些情况下,一次报告几个错误是有意义的:

  • 几个并发任务可能同时失败。
  • 清理代码可能会导致它自己的错误。
  • 代码可以尝试几种不同的方法,但都会引发异常。

在 Python 3.11 中,有了一个叫做异常组的新特性。它提供了一种将不相关的异常分组在一起的方法,并且提供了一种新的except*语法来处理它们。详细描述见 PEP 654:异常组和except*

PEP 654 由 CPython 的核心开发者之一 Irit Katriel 编写并实现,得到了asyncio维护者 Yury Selivanov 和前 BDFL 吉多·范·罗苏姆的支持。它是在 2021 年 5 月的 Python 语言峰会上提出并讨论的。

本节将教您如何使用异常组。在下一节的中,您将看到一个并发代码的实际例子,它使用异常组来同时引发和处理几个任务的错误。

except 处理常规异常

在探索异常组之前,您将回顾 Python 中常规异常处理的工作方式。如果您已经习惯了用 Python 处理错误,那么在这一小节中您不会学到任何新东西。然而,这篇综述将作为您稍后将了解的异常组的对比。您将在本教程的这一小节中看到的所有内容都适用于 Python 3 的所有版本,包括 Python 3.10。

异常打破了程序的正常流程。如果出现异常Python 会丢弃其他所有东西,寻找处理错误的代码。如果没有这样的处理程序,那么不管程序在做什么,程序都会停止。

您可以使用raise关键字自己提出一个错误:

>>> raise ValueError(654)
Traceback (most recent call last):
  ...
ValueError: 654

在这里,你用描述 654 明确地提出了一个ValueError。你可以看到 Python 提供了一个回溯,它告诉你有一个未处理的错误。

有时,您会在代码中引发这样的错误,以表示出现了问题。然而,更常见的是遇到由 Python 本身或您正在使用的某个库引发的错误。例如Python 不允许您添加一个字符串和一个整数,如果您尝试这样做,就会引发一个TypeError:

>>> "3" + 11
Traceback (most recent call last):
  ...
TypeError: can only concatenate str (not "int") to str

大多数异常都有描述,可以帮助您找出问题所在。在这种情况下,它告诉你,你的第二项也应该是字符串。

你用 tryexcept 块来处理错误。有时,您使用这些只是记录错误并继续运行。其他时候,您设法从错误中恢复过来,或者计算一些替代值。

一个短的tryexcept块可能如下所示:

>>> try:
...     raise ValueError(654)
... except ValueError as err:
...     print(f"Got a bad value: {err}")
...
Got a bad value: 654

您可以通过在控制台上打印一条消息来处理ValueError异常。注意,因为您处理了错误,所以在这个例子中没有回溯。但是,不处理其他类型的错误:

>>> try:
...     "3" + 11
... except ValueError as err:
...     print(f"Got a bad value: {err}")
...
Traceback (most recent call last):
  ...
TypeError: can only concatenate str (not "int") to str

即使错误发生在tryexcept块中,它也不会被处理,因为没有与TypeError匹配的except子句。您可以在一个块中处理几种错误:

>>> try:
...     "3" + 11
... except ValueError as err:
...     print(f"Got a bad value: {err}")
... except TypeError as err:
...     print(f"Got bad types: {err}")
...
Got bad types: can only concatenate str (not "int") to str

这个例子将处理ValueErrorTypeError异常。

异常在层级中定义。比如一个ModuleNotFoundError是一种ImportError,是一种Exception

**注意:**因为大多数异常都是从Exception继承的,所以你可以通过只使用except Exception块来简化你的错误处理。这通常是个坏主意。您希望您的异常块尽可能具体,以避免意外错误的发生,并打乱您的错误处理。

匹配错误的第一个except子句将触发异常处理:

>>> try:
...     import no_such_module
... except ImportError as err:
...     print(f"ImportError: {err.__class__}")
... except ModuleNotFoundError as err:
...     print(f"ModuleNotFoundError: {err.__class__}")
...
ImportError: <class 'ModuleNotFoundError'>

当你试图导入一个不存在的模块时Python 会抛出一个ModuleNotFoundError。然而,由于ModuleNotFoundError是一种ImportError,您的错误处理触发了except ImportError子句。请注意:

  • 最多会触发一个except子句
  • 匹配的第一个except子句将被触发

如果您以前处理过异常,这可能看起来很直观。然而,稍后您将看到异常组的行为有所不同。

虽然一次最多只有一个异常是活动的,但是可以将相关的异常链接起来。这种链接是由 PEP 3134 为 Python 3.0 引入的。例如,如果在处理错误时引发新的异常,观察会发生什么情况:

>>> try:
...     "3" + 11
... except TypeError:
...     raise ValueError(654)
...
Traceback (most recent call last):
  ...
TypeError: can only concatenate str (not "int") to str

During handling of the above exception, another exception occurred: 
Traceback (most recent call last):
  ...
ValueError: 654

注意第During handling of the above exception, another exception occurred行。在这一行之前有一个回溯,代表由您的代码引起的最初的TypeError。然后,在这条线下面有另一个回溯,代表您在处理TypeError时引发的新的ValueError

如果您的错误处理代码中碰巧有问题,这种行为特别有用,因为您可以获得关于原始错误和错误处理程序中的错误的信息。

您也可以使用一个 raisefrom语句将异常显式地链接在一起。虽然您可以使用链式异常一次引发几个异常,但请注意,该机制适用于相关的异常,特别是在处理另一个异常时发生的异常。

这不同于异常组被设计来处理的用例。异常组将不相关的异常组合在一起,也就是说它们彼此独立发生。当处理链式异常时,您只能捕捉和处理链中的最后一个错误。您将很快了解到,您可以在一个异常组中捕获所有的异常。

Remove ads

ExceptionGroup 分组异常

在这一小节中,您将探索 Python 3.11 中可用的新的ExceptionGroup类。首先,注意一个ExceptionGroup也是一种Exception:

>>> issubclass(ExceptionGroup, Exception)
True

由于ExceptionGroupException的子类,您可以使用 Python 的常规异常处理来处理它。您可以用raise引发一个ExceptionGroup,尽管您可能不会经常这么做,除非您正在实现一些低级的库。用except ExceptionGroup抓一个ExceptionGroup也是可以的。然而,正如您将在的下一小节中了解到的,使用新的except*语法通常会更好。

与大多数其他异常不同,异常组在初始化时有两个参数:

  1. 通常的描述
  2. 一系列子异常

子异常序列可以包括其他异常组,但不能为空:

>>> ExceptionGroup("one error", [ValueError(654)])
ExceptionGroup('one error', [ValueError(654)])

>>> ExceptionGroup("two errors", [ValueError(654), TypeError("int")])
ExceptionGroup('two errors', [ValueError(654), TypeError('int')])

>>> ExceptionGroup("nested",
...     [
...         ValueError(654),
...         ExceptionGroup("imports",
...             [
...                 ImportError("no_such_module"),
...                 ModuleNotFoundError("another_module"),
...             ]
...         ),
...     ]
... )
ExceptionGroup('nested', [ValueError(654), ExceptionGroup('imports',
 [ImportError('no_such_module'), ModuleNotFoundError('another_module')])])

>>> ExceptionGroup("no errors", [])
Traceback (most recent call last):
  ...
ValueError: second argument (exceptions) must be a non-empty sequence

在这个例子中,您正在实例化几个不同的异常组,这表明异常组可以包含一个异常、几个异常,甚至其他异常组。但是,异常组不允许为空。

您第一次遇到异常组很可能是它的回溯。例外组回溯被格式化以清楚地显示组内的结构。当您提出一个例外组时,您会看到一个回溯:

>>> raise ExceptionGroup("nested",
...     [
...         ValueError(654),
...         ExceptionGroup("imports",
...             [
...                 ImportError("no_such_module"),
...                 ModuleNotFoundError("another_module"),
...             ]
...         ),
...         TypeError("int"),
...     ]
... )
 + Exception Group Traceback (most recent call last):
 |   ...
 | ExceptionGroup: nested (3 sub-exceptions)
 +-+---------------- 1 ----------------
 | ValueError: 654
 +---------------- 2 ----------------
 | ExceptionGroup: imports (2 sub-exceptions)
 +-+---------------- 1 ----------------
 | ImportError: no_such_module
 +---------------- 2 ----------------
 | ModuleNotFoundError: another_module
 +------------------------------------
 +---------------- 3 ----------------
 | TypeError: int
 +------------------------------------

回溯列出了属于异常组所有异常。此外,以图形方式和通过列出每个组中有多少子异常来指示组内异常的嵌套树结构。

您之前已经了解到,ExceptionGroup也是一个常规的 Python 异常。这意味着您可以用常规的except块捕获异常组:

>>> try:
...     raise ExceptionGroup("group", [ValueError(654)])
... except ExceptionGroup:
...     print("Handling ExceptionGroup")
...
Handling ExceptionGroup

这通常不是很有帮助,因为您更感兴趣的是嵌套在异常组中的错误。请注意,您不能直接处理这些问题:

>>> try:
...     raise ExceptionGroup("group", [ValueError(654)])
... except ValueError:
...     print("Handling ValueError")
...
 + Exception Group Traceback (most recent call last):
 |   ...
 | ExceptionGroup: group (1 sub-exception)
 +-+---------------- 1 ----------------
 | ValueError: 654
 +------------------------------------

即使异常组包含一个ValueError,你也不能用except ValueError来处理它。相反,您应该使用一种新的except*语法来处理异常组。您将在下一节了解这是如何工作的。

except* 过滤异常

在 Python 的早期版本中,已经有人尝试处理多个错误。例如,流行的三重奏库包括一个MultiError异常,它可以包装其他异常。然而,因为 Python 倾向于一次处理一个错误,所以处理MultiError异常的不如理想的

Python 3.11 中新的except*语法使得同时处理几个错误更加方便。异常组有一些常规异常没有的属性和方法。特别是,您可以访问.exceptions来获得组中所有子异常的元组。例如,您可以将上一小节中的最后一个示例重写如下:

>>> try:
...     raise ExceptionGroup("group", [ValueError(654)])
... except ExceptionGroup as eg:
...     for err in eg.exceptions:
...         if isinstance(err, ValueError):
...             print("Handling ValueError")
...         elif isinstance(err, TypeError):
...             print("Handling TypeError")
...
Handling ValueError

一旦您捕获了一个ExceptionGroup,您就可以循环所有的子异常,并根据它们的类型来处理它们。虽然这是可能的,但很快就会变得很麻烦。还要注意,上面的代码不处理嵌套的异常组。

相反,您应该使用except*来处理异常组。您可以再次重写该示例:

>>> try:
...     raise ExceptionGroup("group", [ValueError(654)])
... except* ValueError:
...     print("Handling ValueError")
... except* TypeError:
...     print("Handling TypeError")
...
Handling ValueError

每个except*子句处理一个异常组,它是原始异常组的子组,包含所有与给定错误类型匹配的异常。考虑稍微复杂一点的例子:

>>> try:
...     raise ExceptionGroup(
...         "group", [TypeError("str"), ValueError(654), TypeError("int")]
...     )
... except* ValueError as eg:
...     print(f"Handling ValueError: {eg.exceptions}")
... except* TypeError as eg:
...     print(f"Handling TypeError: {eg.exceptions}")
...
Handling ValueError: (ValueError(654),)
Handling TypeError: (TypeError('str'), TypeError('int'))

注意,在这个例子中,两个except*子句都会触发。这不同于常规的except子句,在常规子句中,一次最多触发一个子句。

首先,从原始异常组中过滤出ValueError并进行处理。在被except* TypeError捕获之前,TypeError异常不会被处理。每个子句只触发一次,即使该类型有更多的异常。因此,您的处理代码必须处理异常组。

您可能最终只能部分处理异常组。例如,您可以只处理上一个示例中的ValueError:

>>> try:
...     raise ExceptionGroup(
...         "group", [TypeError("str"), ValueError(654), TypeError("int")]
...     )
... except* ValueError as eg:
...     print(f"Handling ValueError: {eg.exceptions}")
...
Handling ValueError: (ValueError(654),)
 + Exception Group Traceback (most recent call last):
 |   ...
 | ExceptionGroup: group (2 sub-exceptions)
 +-+---------------- 1 ----------------
 | TypeError: str
 +---------------- 2 ----------------
 | TypeError: int
 +------------------------------------

在这种情况下,ValueError被处理。但是这在异常组中留下了两个未处理的错误。这些错误然后冒泡出来,并创建一个追溯。注意,ValueError不是回溯的一部分,因为它已经被处理了。你可以看到except*的行为与except不同:

  • 可能会触发几个except*子句。
  • except*匹配错误的子句从例外组中删除该错误。

与普通的except相比,这是一个明显的变化,起初可能会感觉有点不直观。然而,这些变化使得处理多个并发错误变得更加方便。

您可以手动拆分例外组,尽管您可能不需要这样做:

>>> eg = ExceptionGroup(
...     "group", [TypeError("str"), ValueError(654), TypeError("int")]
... )
>>> eg
ExceptionGroup('group', [TypeError('str'), ValueError(654), TypeError('int')])

>>> value_errors, eg = eg.split(ValueError) >>> value_errors
ExceptionGroup('group', [ValueError(654)])
>>> eg
ExceptionGroup('group', [TypeError('str'), TypeError('int')])

>>> import_errors, eg = eg.split(ImportError) >>> print(import_errors)
None
>>> eg
ExceptionGroup('group', [TypeError('str'), TypeError('int')])

>>> type_errors, eg = eg.split(TypeError) >>> type_errors
ExceptionGroup('group', [TypeError('str'), TypeError('int')])
>>> print(eg)
None

您可以在异常组上使用.split()将它们分成两个新的异常组。第一组由与给定错误匹配的错误组成,而第二组由剩余的错误组成。如果任何一个组最终是空的,那么它们将被None替换。如果您想手动操作异常组,请参见 PEP 654文档了解更多信息。

异常组不会替换常规异常!相反,它们被设计用来处理特定的用例,在这种情况下,同时处理几个异常是有用的。库应该清楚地区分可以引发常规异常的函数和可以引发异常组的函数。

PEP 654 的作者建议将函数从引发异常改为引发异常组应被视为重大更改,因为任何使用该库的人都需要更新他们处理错误的方式。在下一节中,您将了解任务组。它们是 Python 3.11 中的新特性,是标准库中引发异常组的第一部分。

您已经看到,在常规的except块中处理异常组是可能的,但是很麻烦。也可以反其道而行之。except*可以处理常规异常:

>>> try:
...     raise ValueError(654)
... except* ValueError as eg:
...     print(type(eg), eg.exceptions)
...
<class 'ExceptionGroup'> (ValueError(654),)

即使您引发了一个单独的ValueError异常,except*机制也会在处理它之前将异常包装在一个异常组中。理论上,这意味着你可以用except*替换你所有的except积木。实际上,这是个坏主意。异常组旨在处理多个异常。除非需要,否则不要使用它们!

异常组是 Python 3.11 中的新增功能。但是,如果您使用的是旧版本的 Python那么您可以使用 exceptiongroup backport 来访问相同的功能。代替except*,反向端口使用exceptiongroup.catch() 上下文管理器来处理多个错误。

Remove ads

Python 3.11 中的异步任务组

在上一节中,您了解了异常组。你什么时候会使用它们?如上所述,异常组和except*并不意味着取代常规异常和except

事实上,您很可能没有在自己的代码中引发异常组的良好用例。它们可能主要用在低级别的库中。随着 Python 3.11 越来越普及,您所依赖的包可能会开始产生异常组,因此您可能需要在应用程序中处理它们。

引入异常组的一个激励用例是处理并发代码中的错误。如果您同时运行几个任务其中几个可能会遇到问题。直到现在Python 还没有很好的方法来处理这个问题。几个异步库,像 TrioAnyIOCurio ,增加了一种多错误容器。但是如果没有语言支持,处理并发错误仍然很复杂。

如果你想看异常组及其在并发编程中的使用的视频演示,看看ukasz Langa 的演示异常组将如何改进 AsyncIO 中的错误处理。

在本节中,您将探索一个模拟同时分析几个文件的玩具示例。您将从一个基本的同步应用程序构建这个示例,在这个应用程序中,文件被按顺序分析,直到一个完整的异步工具使用新的 Python 3.11 asyncio任务组。其他异步库中也存在类似的任务组,但是新的实现首次使用异常组来平滑错误处理。

您的第一个版本的分析工具将与旧版本的 Python 一起工作,但是您将需要 Python 3.11 来利用最终示例中的任务和异常组。

按顺序分析文件

在这一小节中,您将实现一个可以计算几个文件中的行数的工具。输出将会是动画的,这样您就可以很好地看到文件大小的分布情况。最终结果看起来会像这样:

Files being analyzed, one after another

您将扩展这个程序,探索异步编程的一些特性。虽然这个工具本身并不一定有用,但它很明确,因此您可以清楚地看到发生了什么,而且它很灵活,因此您可以引入几个异常,并通过异常组来处理它们。

Colorama 是一个库,可以让你更好地控制终端的输出。当程序计算不同文件中的行数时,您将使用它来创建动画。先用 pip 安装:

$ python -m pip install colorama

顾名思义Colorama 的主要用例是为您的终端添加颜色。但是,您也可以使用它在特定位置打印文本。将以下代码写入名为count.py的文件中:

# count.py

import sys
import time

import colorama
from colorama import Cursor

colorama.init()

def print_at(row, text):
    print(Cursor.POS(1, 1 + row) + str(text))
    time.sleep(0.03)

def count_lines_in_file(file_num, file_name):
    counter_text = f"{file_name[:20]:<20} "
    with open(file_name, mode="rt", encoding="utf-8") as file:
        for line_num, _ in enumerate(file, start=1):
            counter_text += "□"
            print_at(file_num, counter_text)
        print_at(file_num, f"{counter_text} ({line_num})")

def count_all_files(file_names):
    for file_num, file_name in enumerate(file_names, start=1):
        count_lines_in_file(file_num, file_name)

if __name__ == "__main__":
    count_all_files(sys.argv[1:])

print_at()功能是动画的核心。它使用 Colorama 的Cursor.POS()在终端的特定行或线打印一些文本。接下来,会让休眠一会儿,以创造动画效果。

您使用count_lines_in_file()来分析一个文件并制作动画。该函数打开一个文件并遍历它,一次一行。对于每一行,它向字符串添加一个框(),并使用print_at()在同一行连续打印字符串。这将创建动画。最后,打印出总行数。

**注意:**用 Colorama 定位终端光标是创建简单动画的快捷方式。然而,它确实扰乱了终端的正常流程,您可能会遇到一些文本被覆盖的问题。

通过在分析文件之前清空屏幕,并在结束时将光标设置在动画下方,您会有更流畅的体验。您可以通过在主块中添加如下内容来实现这一点:

# count.py

# ...

if __name__ == "__main__":
 print(colorama.ansi.clear_screen())    count_all_files(sys.argv[1:])
 print(Cursor.POS(1, 1 + len(sys.argv)))

您还可以在这里和在print_at()中更改添加到Cursor.POS()的第二个参数的数字,以获得一个与您的终端设置相适应的行为。当您找到一个有效的数字时,您也应该在后面的示例中进行类似的定制。

你程序的入口点是count_all_files()。这将遍历您作为命令行参数提供的所有文件名,并对它们调用count_lines_in_file()

试试你的线计数器!您通过提供应该在命令行上分析的文件来运行程序。例如,您可以计算源代码中的行数,如下所示:

$ python count.py count.py
count.py             □□□□□□□□□□□□□□□□□□□□□□□□□□□□ (28)

这将计算count.py中的行数。您应该创建几个其他文件,用于研究您的行计数器。这些文件中的一些会暴露出你目前没有做任何异常处理。您可以使用以下代码创建几个新文件:

>>> import pathlib
>>> import string

>>> chars = string.ascii_uppercase
>>> data = [c1 + c2 for c1, c2 in zip(chars[:13], chars[13:])]
>>> pathlib.Path("rot13.txt").write_text("\n".join(data))
38

>>> pathlib.Path("empty_file.txt").touch()

>>> bbj = [98, 108, 229, 98, 230, 114, 115, 121, 108, 116, 101, 116, 248, 121]
>>> pathlib.Path("not_utf8.txt").write_bytes(bytes(bbj))
14

您已经创建了三个文件:rot13.txtempty_file.txtnot_utf8.txt。第一个文件包含在 ROT13 密码中相互映射的字母。第二个文件是一个完全空的文件,而第三个文件包含一些不是 UTF-8 编码的数据。您很快就会看到,最后两个文件会给您的程序带来问题。

要计算两个文件中的行数,需要在命令行中提供它们的名称:

$ python count.py count.py rot13.txt
count.py             □□□□□□□□□□□□□□□□□□□□□□□□□□□□ (28)
rot13.txt            □□□□□□□□□□□□□ (13)

您用命令行提供的所有参数调用count_all_files()。然后,该函数对每个文件名进行循环。

如果您提供了一个不存在的文件名,那么您的程序将引发一个异常告诉您:

$ python count.py wrong_name.txt
Traceback (most recent call last):
 ...
FileNotFoundError: [Errno 2] No such file or directory: 'wrong_name.txt'

如果您尝试分析empty_file.txtnot_utf8.txt,也会发生类似的事情:

$ python count.py empty_file.txt
Traceback (most recent call last):
 ...
UnboundLocalError: cannot access local variable 'line_num' where it is
 not associated with a value

$ python count.py not_utf8.txt
Traceback (most recent call last):
 ...
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xe5 in position 2:
 invalid continuation byte

这两种情况都会产生错误。对于empty_file.txt,问题是line_num是通过迭代文件的行来定义的。如果文件中没有行,那么line_num没有被定义,当你试图访问它的时候会得到一个错误。not_utf8.txt的问题在于,你试图对不是 UTF 8 编码的东西进行 UTF 8 解码。

在接下来的小节中,您将使用这些错误来探索异常组如何帮助您改进错误处理。现在,观察如果您试图分析两个都产生错误的文件会发生什么:

$ python count.py not_utf8.txt empty_file.txt
Traceback (most recent call last):
 ...
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xe5 in position 2:
 invalid continuation byte

请注意,只有对应于not_utf8.txt的第一个错误出现。这很自然,因为文件是按顺序分析的。这个错误发生在empty_file.txt打开之前很久。

Remove ads

同时分析文件

在这一小节中,您将重写程序以异步运行。这意味着对所有文件的分析是同时进行的,而不是按顺序进行的。看到更新后的程序运行很有启发性:

Files being analyzed, all at the same time

动画显示,所有文件中的行数是同时计算的,而不是像以前那样一次只计算一个文件中的行数。

通过使用asyncawait关键字将函数重写为异步协程,可以实现这种并发性。请注意,这个新版本仍然使用旧的异步实践,并且这个代码在 Python 3.7 和更高版本中是可运行的。在下一小节中,您将执行最后一步并使用新的任务组。

用以下代码创建一个名为count_gather.py的新文件:

# count_gather.py

import asyncio import sys

import colorama
from colorama import Cursor

colorama.init()

async def print_at(row, text):
    print(Cursor.POS(1, 1 + row) + str(text))
 await asyncio.sleep(0.03) 
async def count_lines_in_file(file_num, file_name):
    counter_text = f"{file_name[:20]:<20} "
    with open(file_name, mode="rt", encoding="utf-8") as file:
        for line_num, _ in enumerate(file, start=1):
            counter_text += "□"
 await print_at(file_num, counter_text) await print_at(file_num, f"{counter_text} ({line_num})") 
async def count_all_files(file_names):
 tasks = [ asyncio.create_task(count_lines_in_file(file_num, file_name)) for file_num, file_name in enumerate(file_names, start=1) ] await asyncio.gather(*tasks) 
if __name__ == "__main__":
 asyncio.run(count_all_files(sys.argv[1:]))

如果您将这段代码与前一小节中的count.py进行比较,那么您会注意到,大多数更改只是将async添加到函数定义中,或者将await添加到函数调用中。asyncawaitT5】关键字构成了 Python 做异步编程的 API。

注意: asyncio是 Python 标准库中包含的用于异步编程的库。不过 Python 的异步计算模型还是比较通用的,你可以用其他第三方库比如 TrioCurio 代替asyncio

或者,你可以使用第三方库,比如 uvloopQuattro 。这些不是asyncio的替代品。相反,他们在此基础上增加了性能或额外的功能。

接下来,请注意count_all_files()发生了显著变化。不是顺序调用count_lines_in_file(),而是为每个文件名创建一个任务。每个任务都为count_lines_in_file()准备了相关的参数。所有的任务都收集在一个列表里,传递给 asyncio.gather() 。最后,count_all_files()是通过调用 asyncio.run() 发起的。

这里发生的是asyncio.run()创建了一个事件循环。任务由事件循环执行。在动画中,看起来所有文件都是同时分析的。然而,虽然这些行是同时计数的,但它们不是并行计数的。你的程序中只有一个线程,但是这个线程不断地切换它正在执行的任务。

异步编程有时被称为协作多任务,因为每个任务都主动放弃控制权,让其他任务运行。把await想象成代码中的一个标记,在那里你决定可以切换任务。在这个例子中,这主要是当代码在下一个动画步骤之前休眠的时候。

注意: 线程实现了类似的结果,但是使用了抢占式多任务,其中操作系统决定何时切换任务。异步编程通常比线程编程更容易推理,因为您知道任务何时会中断。参见使用并发性加速 Python 程序了解线程、异步编程和其他类型的并发性的比较。

在几个不同的文件上运行您的新代码,并观察它们是如何被并行分析的:

$ python count_gather.py count.py rot13.txt count_gather.py
count.py             □□□□□□□□□□□□□□□□□□□□□□□□□□□□ (28)
rot13.txt            □□□□□□□□□□□□□ (13)
count_gather.py      □□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□ (31)

当你的文件在你的控制台中显示动画时,你会看到rot13.txt在其他任务之前完成。接下来,尝试分析您之前创建的一些麻烦的文件:

$ python count_gather.py not_utf8.txt empty_file.txt
Traceback (most recent call last):
 ...
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xe5 in position 2:
 invalid continuation byte

即使现在同时分析了not_utf8.txtempty_file.txt,您也只能看到其中一个产生的错误。正如您之前了解到的,常规的 Python 异常是一个接一个处理的,而asyncio.gather()受此限制。

**注意:**等待asyncio.gather()时可以用return_exceptions=True作为论元。这将从所有任务中收集异常,并在所有任务完成后将它们返回到一个列表中。然而,正确处理这些异常是很复杂的,因为它们没有使用 Python 正常的错误处理。

像 Trio 和 Curio 这样的第三方库做了一些特殊的错误处理能够处理多个异常。例如Trio 的 MultiError 包装了两个或多个异常,并提供了处理它们的上下文管理器。

更方便地处理多个错误正是异常组被设计来处理的用例之一。在您的计数器应用程序中,您会希望看到一个组,其中包含每个未被分析的文件的一个异常,并有一种简单的方法来处理它们。是时候给新的 Python 3.11 TaskGroup一个旋转了!

Remove ads

用任务组控制并发处理

任务组一直是asyncio的一个计划功能。Yuri Selivanov 在他在 PyBay 2018 上发表的演讲 asyncio:下一步是什么中提到它们是对 Python 3.8 的可能增强。其他图书馆也有类似的功能,包括三人托儿所古玩任务组夸特罗任务组

实现花费这么多时间的主要原因是任务组需要同时正确处理几个异常。Python 3.11 中新的异常组特性也为包含异步任务组铺平了道路。它们最终由尤里·塞利万诺夫吉多·范·罗苏姆实现,并在 Python 3.11.0a6 中发布

在这一小节中,您将重新实现您的计数器应用程序来使用asyncio.TaskGroup而不是asyncio.gather()。在下一小节中,您将使用except*来方便地处理您的应用程序可能引发的不同异常。

将以下代码放入名为count_taskgroup.py的文件中:

# count_taskgroup.py

import asyncio
import sys

import colorama
from colorama import Cursor

colorama.init()

async def print_at(row, text):
    print(Cursor.POS(1, 1 + row) + str(text))
    await asyncio.sleep(0.03)

async def count_lines_in_file(file_num, file_name):
    counter_text = f"{file_name[:20]:<20} "
    with open(file_name, mode="rt", encoding="utf-8") as file:
        for line_num, _ in enumerate(file, start=1):
            counter_text += "□"
            await print_at(file_num, counter_text)
        await print_at(file_num, f"{counter_text} ({line_num})")

async def count_all_files(file_names):
 async with asyncio.TaskGroup() as tg: for file_num, file_name in enumerate(file_names, start=1): tg.create_task(count_lines_in_file(file_num, file_name)) 
if __name__ == "__main__":
    asyncio.run(count_all_files(sys.argv[1:]))

将此与count_gather.py进行比较。您会注意到唯一的变化是在count_all_files()中如何创建任务。在这里,您使用上下文管理器创建任务组。之后,您的代码与count.py中最初的同步实现非常相似:

def count_all_files(file_names):
    for file_num, file_name in enumerate(file_names, start=1):
        count_lines_in_file(file_num, file_name)

TaskGroup中创建的任务是并发运行的,类似于由asyncio.gather()运行的任务。只要您使用的是 Python 3.11,对文件计数的工作方式应该和以前一样:

$ python count_taskgroup.py count.py rot13.txt count_taskgroup.py
count.py             □□□□□□□□□□□□□□□□□□□□□□□□□□□□ (28)
rot13.txt            □□□□□□□□□□□□□ (13)
count_taskgroup.py   □□□□□□□□□□□□□□□□□□□□□□□□□□□□□ (29)

不过,一个很大的改进是错误的处理方式。通过分析一些麻烦的文件来激发您的新代码:

$ python count_taskgroup.py not_utf8.txt empty_file.txt
 + Exception Group Traceback (most recent call last):
 |   ...
 | ExceptionGroup: unhandled errors in a TaskGroup (2 sub-exceptions)
 +-+---------------- 1 ----------------
 | Traceback (most recent call last):
 |   File "count_taskgroup.py", line 18, in count_lines_in_file
 |     for line_num, _ in enumerate(file, start=1):
 |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
 | UnicodeDecodeError: 'utf-8' codec can't decode byte 0xe5 in position 2:
 |                     invalid continuation byte
 +---------------- 2 ----------------
 | Traceback (most recent call last):
 |   File "count_taskgroup.py", line 21, in count_lines_in_file
 |     await print_at(file_num, f"{counter_text} ({line_num})")
 |                                                 ^^^^^^^^
 | UnboundLocalError: cannot access local variable 'line_num' where it is
 |                    not associated with a value
 +------------------------------------

注意,您会得到一个带有两个子异常的Exception Group Traceback,每个子异常对应一个未被分析的文件。这已经是对asyncio.gather()的改进了。在下一小节中,您将学习如何在代码中处理这些类型的错误。

Yuri Selivanov 指出,新的任务组比旧的任务组提供了更好的 API因为任务组是“可组合的、可预测的和安全的”。此外他指出任务组:

  1. 运行一组嵌套任务。如果一个任务失败,所有其他仍在运行的任务都将被取消。
  2. 允许在计划嵌套任务之间执行代码(包括等待)。
  3. 多亏了 ExceptionGroups所有的错误都被传播并可以被处理/报告。

尤里·谢利万诺夫 ( 来源)

在下一小节中,您将尝试在并发代码中处理和报告错误。

处理并发错误

您编写了一些有时会引发错误的并发代码。如何正确处理这些异常?您很快就会看到错误处理的例子。但是,首先,您将添加代码可能失败的另一种方式。

到目前为止,您在代码中看到的问题都是在文件分析开始之前出现的。为了模拟在分析过程中可能发生的错误,假设你的工具患有恐怖症,这意味着它不理智地害怕数字 13。在count_lines_in_file()中增加两行:

# count_taskgroup.py

# ...

async def count_lines_in_file(file_num, file_name):
    counter_text = f"{file_name[:20]:<20} "
    with open(file_name, mode="rt", encoding="utf-8") as file:
        for line_num, _ in enumerate(file, start=1):
            counter_text += "□"
            await print_at(file_num, counter_text)
        await print_at(file_num, f"{counter_text} ({line_num})")
 if line_num == 13: raise RuntimeError("Files with thirteen lines are too scary!") 
# ...

如果一个文件正好有 13 行,那么在分析结束时会产生一个RuntimeError。你可以通过分析rot13.txt来看这个的效果:

$ python count_taskgroup.py rot13.txt
rot13.txt            □□□□□□□□□□□□□ (13)
 + Exception Group Traceback (most recent call last):
 |   ...
 | ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)
 +-+---------------- 1 ----------------
 | Traceback (most recent call last):
 |   File "count_taskgroup.py", line 23, in count_lines_in_file
 |     raise RuntimeError("Files with thirteen lines are too scary!")
 |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
 | RuntimeError: Files with thirteen lines are too scary!
 +------------------------------------

不出所料,你的新 triskaidekaphobic 代码在rot13.py的 13 行处停顿了。接下来,结合您之前看到的一个错误:

$ python count_taskgroup.py rot13.txt not_utf8.txt
rot13.txt            
 + Exception Group Traceback (most recent call last):
 |   ...
 | ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)
 +-+---------------- 1 ----------------
 | Traceback (most recent call last):
 |   File "count_taskgroup.py", line 18, in count_lines_in_file
 |     for line_num, _ in enumerate(file, start=1):
 |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
 | UnicodeDecodeError: 'utf-8' codec can't decode byte 0xe5 in position 2:
 |                     invalid continuation byte
 +------------------------------------

这一次,即使您知道两个文件都应该引发异常,也只报告一个错误。你只得到一个错误的原因是这两个问题会在不同的时间出现。任务组的一个特性是它们实现了一个取消范围。一旦某些任务失败,同一任务组中的其他任务就会被事件循环取消。

**注:**取消示波器是由三人组首创的。取消范围的最终实现以及它们将在asyncio中支持哪些功能仍在讨论中。以下示例适用于 Python 3.11.0a7,但在 Python 3.11 最终完成之前,情况可能仍会发生变化。

通常,有两种方法可以用来处理异步任务中的错误:

  1. 在协程中使用常规的tryexcept块来处理问题。
  2. 使用任务组之外的新tryexcept*模块来处理问题。

在第一种情况下,一个任务中的错误通常不会影响其他正在运行的任务。然而,在第二种情况下,一个任务中的错误将取消所有其他正在运行的任务。

自己尝试一下吧!首先,添加safe_count_lines_in_file(),它在您的协程中使用常规的异常处理:

# count_taskgroup.py

# ...

async def safe_count_lines_in_file(file_num, file_name):
 try: await count_lines_in_file(file_num, file_name) except RuntimeError as err: await print_at(file_num, err) 
async def count_all_files(file_names):
    async with asyncio.TaskGroup() as tg:
        for file_num, file_name in enumerate(file_names, start=1):
 tg.create_task(safe_count_lines_in_file(file_num, file_name)) 
# ...

你也把count_all_files()改成称呼新的safe_count_lines_in_file()而不是count_lines_in_file()。在这个实现中,只要一个文件有 13 行,您只需处理引发的RuntimeError

注意: safe_count_lines_in_file()不使用任务组的任何具体特性。你也可以使用类似的函数使count.pycount_gather.py更加健壮。

分析rot13.txt和其他一些文件,确认错误不再取消其他任务:

$ python count_taskgroup.py count.py rot13.txt count_taskgroup.py
count.py             □□□□□□□□□□□□□□□□□□□□□□□□□□□□ (28)
Files with thirteen lines are too scary!
count_taskgroup.py   □□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□ (37)

被处理的错误不会冒泡并影响其他任务。在本例中,count.pycount_taskgroup.py得到了正确分析,尽管对rot13.txt的分析失败了。

接下来,尝试使用except*来处理事后错误。例如,您可以将事件循环包装在一个tryexcept*块中:

# count_taskgroup.py

# ...

if __name__ == "__main__":
 try:        asyncio.run(count_all_files(sys.argv[1:]))
 except* UnicodeDecodeError as eg: print("Bad encoding:", *[str(e)[:50] for e in eg.exceptions])

回想一下except*与异常组一起工作。在这种情况下,您循环遍历组中的UnicodeDecodeError异常,并将它们的前 50 个字符打印到控制台以记录它们。

与其他一些文件一起分析not_utf8.txt看看效果:

$ python count_taskgroup.py rot13.txt not_utf8.txt count.py
rot13.txt            

count.py             
Bad encoding: 'utf-8' codec can't decode byte 0xe5 in position 2

与前一个例子相反,即使您处理了UnicodeDecodeError,其他任务也会被取消。注意在rot13.txtcount.py中只计算一行。

**注意:**在count.pycount_gather.py示例中,您可以将对count_all_files()的调用包装在常规的tryexcept块中。然而,这将只允许您处理最多一个错误。相反,任务组可以报告所有错误:

$ python count_taskgroup.py not_utf8.txt count_taskgroup.py empty.txt

count_taskgroup.py   

Bad text: ["'utf-8' codec can't decode byte 0xe5 in position 2"] Empty file: ["cannot access local variable 'line_num' where it i"]

这个例子展示了在您扩展上一个例子中的代码来处理UnicodeDecodeErrorUnboundLocalError之后,出现几个并发错误的结果。

如果您不处理引发的所有异常,那么未处理的异常仍然会导致您的程序因回溯而崩溃。要查看这一点,在您的分析中将count.py切换到empty_file.txt:

$ python count_taskgroup.py rot13.txt not_utf8.txt empty_file.txt
rot13.txt            
Bad encoding: 'utf-8' codec can't decode byte 0xe5 in position 2
 + Exception Group Traceback (most recent call last):
 |   ...
 | ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception) +-+---------------- 1 ----------------
 | Traceback (most recent call last):
 |   File "count_taskgroup.py", line 21, in count_lines_in_file
 |     await print_at(file_num, f"{counter_text} ({line_num})")
 |                                                 ^^^^^^^^
 | UnboundLocalError: cannot access local variable 'line_num' where it is
 |                    not associated with a value
 +------------------------------------

你得到熟悉的UnboundLocalError。请注意,部分错误消息指出有一个未处理的子异常。在您处理的UnicodeDecodeError子异常的回溯中没有记录。

现在,您已经看到了一个使用任务组来改进异步应用程序的错误处理的示例,特别是能够轻松地处理同时发生的几个错误。异常组和任务组的结合使 Python 成为一种非常适合异步编程的语言。

Remove ads

其他新功能

在 Python 的每一个新版本中少数几个特性获得了最多的关注。然而Python 的大部分发展都是一小步一小步地发生的,通过在这里或那里添加一个功能,改进一些现有的功能,或者修复一个长期存在的错误。

Python 3.11 也不例外。本节展示了 Python 3.11 中一些较小的改进。

用自定义注释注释异常

现在,您可以向例外添加自定义注释。这是对 Python 中异常处理方式的又一次改进。异常注释是由扎克·哈特菲尔德-多兹在 PEP 678:用注释丰富异常中提出的。PEP 已经被接受,并且该提议的早期版本已经在 Python 3.11.0a3 到 Python 3.11.0a7 上实现

在这些 alpha 版本中,您可以在异常时将字符串分配给一个.__note__属性,如果错误没有得到处理,该信息将变得可用。这里有一个基本的例子:

>>> try:
...     raise ValueError(678)
... except ValueError as err:
...     err.__note__ = "Enriching Exceptions with Notes" ...     raise
...
Traceback (most recent call last):
  ...
ValueError: 678
Enriching Exceptions with Notes

您正在向ValueError添加注释,然后再提升它。然后,您的注释会与常规错误消息一起显示在追溯的末尾。

**注意:**本节的其余部分于 2022 年 5 月 9 日更新,以反映随着 Python 3.11.0b1 的发布而变得可用的异常注释特性的变化。

讨论期间PEP .__note__被更改为.__notes__,它可以包含几个注释。在跟踪单个笔记很重要的特定用例中,笔记列表会很有用。这方面的一个例子是国际化和注释的翻译。

还有一个新的专用方法.add_note()可以用来添加这些注释。PEP 678 的完整实现在 Python 3.11 的首个测试版及以后版本中可用。

接下来,您应该按如下方式编写前面的示例:

>>> try:
...     raise ValueError(678)
... except ValueError as err:
...     err.add_note("Enriching Exceptions with Notes") ...     raise
...
Traceback (most recent call last):
  ...
ValueError: 678
Enriching Exceptions with Notes

您可以添加几个重复调用.add_note()的音符,并通过循环.__notes__来恢复它们。引发异常时,所有注释都将打印在追溯记录的下方:

>>> err = ValueError(678)
>>> err.add_note("Enriching Exceptions with Notes")
>>> err.add_note("Python 3.11")

>>> err.__notes__
['Enriching Exceptions with Notes', 'Python 3.11']
>>> for note in err.__notes__:
...     print(note)
...
Enriching Exceptions with Notes
Python 3.11

>>> raise err
Traceback (most recent call last):
  ...
ValueError: 678
Enriching Exceptions with Notes
Python 3.11

新的异常注释也与异常组兼容。

sys.exception() 引用活动异常

在内部Python 将异常表示为一个元组,其中包含关于异常类型、异常本身以及异常回溯的信息。这个在 Python 3.11 中改变了。现在Python 将只在内部存储异常本身。类型和回溯都可以从异常对象派生。

一般来说,你不需要考虑这个变化,因为它都在引擎盖下。但是,如果您需要访问一个活动异常,您现在可以使用sys模块中新的 exception() 函数:

>>> import sys

>>> try:
...     raise ValueError("bpo-46328")
... except ValueError:
...     print(f"Handling {sys.exception()}") ...
Handling bpo-46328

注意,你通常不会像上面那样在正常的错误处理中使用exception()。相反,在错误处理中使用的包装器库中使用它有时会很方便,但不能直接访问活动异常。在正常的错误处理中,您应该在except子句中命名您的错误:

>>> try:
...     raise ValueError("bpo-46328")
... except ValueError as err: ...     print(f"Handling {err}")
...
Handling bpo-46328

在 Python 3.11 之前的版本中,您可以从 sys.exc_info() 中获得相同的信息:

>>> try:
...     raise ValueError("bpo-46328")
... except ValueError:
...     sys.exception() is sys.exc_info()[1] ...
True

的确,sys.exception()sys.exc_info()[1]是一样的。这个新功能是由 Irit Katriel 在 bpo-46328 中添加的,尽管这个想法最初是在 PEP 3134 中提出的,可以追溯到 2005 年。

Remove ads

一致引用活动回溯

如前一小节所述Python 的旧版本将异常表示为元组。您可以通过两种不同的方式访问追溯信息:

>>> import sys

>>> try:
...     raise ValueError("bpo-45711")
... except ValueError:
...     exc_type, exc_value, exc_tb = sys.exc_info()
...     exc_value.__traceback__ is exc_tb ...
True

注意,通过exc_valueexc_tb访问回溯返回完全相同的对象。总的来说,这就是你想要的。然而,事实证明,有一个微妙的错误隐藏了一段时间。您可以在不更新exc_tb的情况下更新对exc_value的追溯。

为了演示这一点,编写以下程序,该程序在异常处理期间更改回溯:

 1# traceback_demo.py
 2
 3import sys
 4import traceback
 5
 6def tb_last(tb):
 7    frame, *_ = traceback.extract_tb(tb, limit=1)
 8    return f"{frame.name}:{frame.lineno}"
 9
10def bad_calculation():
11    return 1 / 0
12
13def main():
14    try:
15        bad_calculation()
16    except ZeroDivisionError as err:
17        err_tb = err.__traceback__
18        err = err.with_traceback(err_tb.tb_next) 19
20        exc_type, exc_value, exc_tb = sys.exc_info()
21        print(f"{tb_last(exc_value.__traceback__) = }")
22        print(f"{tb_last(exc_tb)                  = }")
23
24if __name__ == "__main__":
25    main()

您在第 18 行更改了活动异常的回溯。您很快就会看到,这不会更新 Python 3.10 和更早版本中异常元组的回溯部分。为了说明这一点,第 20 到 22 行比较了活动异常和回溯对象引用的回溯的最后一帧。

使用 Python 3.10 或更早版本运行此程序:

$ python traceback_demo.py
tb_last(exc_value.__traceback__) = 'bad_calculation:11'
tb_last(exc_tb)                  = 'main:15'

这里需要注意的重要一点是,这两个行引用是不同的。活动异常指向更新的位置,即bad_calculation()内的第 11 行,而回溯指向main()内的旧位置。

在 Python 3.11 中,异常元组的回溯部分总是从异常本身读取。因此,不一致性消失了:

$ python3.11 traceback_demo.py
tb_last(exc_value.__traceback__) = 'bad_calculation:11'
tb_last(exc_tb)                  = 'bad_calculation:11'

现在,访问回溯的两种方式给出了相同的结果。这修复了一个在 Python 中已经存在一段时间的 bug。不过值得注意的是这种不一致主要是学术上的。是的旧的方法是错误的但是它不太可能在实际代码中引起问题。

这个 bug 修复很有趣因为它揭开了更大的帷幕。正如您在前一小节中了解到的Python 的内部异常表示在 3.11 版中发生了变化。这一缺陷修复是这一变化的直接结果。

重构 Python 的异常是优化 Python 许多不同部分的更大努力的一部分。马克·香农已经启动了fast-cpython项目。简化例外只是该计划的想法之一。

您在本节中了解到的较小的改进举例说明了维护和开发编程语言的所有工作,而不仅仅是几个项目占据了大部分标题。您在这里学到的特性都与 Python 的异常处理相关。然而,还有许多其他的小变化也在发生。Python 3.11 中的新特性跟踪所有这些特性。

结论

在本教程中,您了解了 Python 3.11 将于 2022 年 10 月发布时带来的一些新功能。您已经看到了它的一些新特性,并探索了如何利用这些改进。

特别是,你已经:

  • 在你的电脑上安装了Python 3.11 的 alpha 版本
  • 探索了异常组以及如何使用它们来组织错误
  • 使用except*过滤异常组和处理不同类型的错误
  • 重写您的异步代码以使用任务组来启动并发工作流
  • 尝试了 Python 3.11 中一些较小的改进,包括异常注释和一个新的内部异常表示

试用 Python 3.11 中的任务和异常组!你有它们的用例吗?在下面评论分享你的经验。

免费奖励: 掌握 Python 的 5 个想法,这是一个面向 Python 开发者的免费课程,向您展示将 Python 技能提升到下一个水平所需的路线图和心态。**************