前言

本文主要解释 Python 3.11 的重要新增特性,其实还有很多小的改变,就不挨个提了,有兴趣的还是应该看官网更新日志去了解。

速度改进

这个是最令人振奋的消息了,官网说:

CPython 3.11 is on average 25% faster than CPython 3.10 when measured with the pyperformance benchmark suite, and compiled with GCC on Ubuntu Linux. Depending on your workload, the speedup could be up to 10-60% faster.

也就说,速度提升在 10-60% 之间,平均比 Python 3.10 快 25%,这算是非常大的一个改进了。

Python 突然有这样的提升其实还挺有故事的,我这里就流水账的简单八卦一下吧。

事情的起源是 2020 年底,一个 (在我眼里) 不怎么知名的 Python 开发者 Mark Shannon 提出了一个叫做【A faster CPython】的计划 (延伸阅读链接 1),他计划在接下来 4 个 Python 新版本里面让 CPython 提升 5 倍的速度。在他的计划中列出了找到的一些 Python 实现中可以优化的点,也列出来在新的各个 Python 版本里面的计划。当时这个计划在圈内还小火了几天。

接着是 Python 之父 Guido van Rossum 在 2021 年 5 月的 Python Language Summit 上面宣布他已经投入了这项计划,他在微软资助下组建起一个小团队。当时成员还有知名 Python 核心开发 Eric Snow 和计划提出者 Mark Shannon。非常好的消息是,Mark Shannon 现在已经正式加入了微软,不得不感谢微软对于开源和 Python 的支持。👍🏻

所以这次的速度提升主要来源于这个计划。其中最主要的几个工作:

  1. PEP 659 – Specializing Adaptive Interpreter。由于对象的类型很少改变,解释器现在尝试分析运行代码并用特定类型的字节码替换通用字节码。例如,二进制运算(加法、减法等)可以替换为整数、浮点数和字符串的专用版本。
  2. 函数调用开销更小。函数调用的堆栈帧现在使用更少的内存并且设计得更高效。
  3. 运行时所需的核心模块可以更有效地存储和加载。
  4. ‘零开销’异常处理。

好了这个小节就说到这里了,可以看到 3.11 版本为 Python 的提速开了一个的好头。另外 Python 3.12 的目标和计划可以看延伸阅读链接 4,到时候我们再说。

PEP 654 – Exception Groups and except*

在 PEP 654 中引入了新的语法,可以让异常处理更简单化。正常情况下一次只会出现一个错误,代码运行由于程序逻辑或者外部因素抛错,然后打印错误信息的堆栈,很直观。这个 PEP 也列出了 5 种类型,其中复杂计算过程出现多种错误中引用的Hypothesis这个库我没有使用经验没找到实际例子外,其他类型都举个对应的例子吧

1. 多个并发任务可能同时失败

例如 asyncio 库编写的程序:

import aiohttp
import asyncio

async def core_success():
    return('success')


async def core_error1():
    raise asyncio.TimeoutError


async def core_error2():
    raise aiohttp.ClientOSError


async def core_error3():
    raise aiohttp.ClientConnectionError


async def raise_errors():
    results = await asyncio.gather(
        core_success(),
        core_error1(),
        core_error2(),
        core_error3(),
        return_exceptions=True)
    for r in results:
        match r:
            case asyncio.TimeoutError():
                print('timeout_error')
            case aiohttp.ClientOSError():
                print('client_os_error')
            case aiohttp.ClientConnectionError():
                print('client_connection_error')
            case _:
                print(r)


asyncio.run(raise_errors())

我这里就是演示,假设现在使用 asyncio 和 aiohttp 编写了一个抓取网页的程序,4 个任务只有core_success成功,其他的会抛出不同的错误。注意一定不能用return_exceptions=False会丢失异常信息。

这样的用法的问题是无法直接在gather执行任务时捕捉异常,需要得到结束执行后,在遍历结果是才从这个列表中获取到异常。

2. 清理代码也发生了其自身的错误。

我把 PEP 里面提到的Multiple user callbacks failErrors in wrapper code都归到了这条里面。例如atexit.register(),with 代码块结束的__exit__引起的。

from contextlib import ContextDecorator

class mycontext(ContextDecorator):
    def __enter__(self):
        return self

    def __exit__(self, *exc):
        raise RuntimeError('hah')
        return False


try:
    with mycontext() as c:
        raise TypeError()
except TypeError:
    print('catch!')

本来我们想要捕捉开发者自定义的异常,但是由于上下文清理退出时抛了错,异常没有被捕捉到:

Traceback (most recent call last):
  File "/Users/weiming.dong/mp/2022-10-24/cleanup.py", line 14, in <module>
    raise TypeError()
TypeError

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/Users/weiming.dong/mp/2022-10-24/cleanup.py", line 13, in <module>
    with mycontext() as c:
  File "/Users/weiming.dong/mp/2022-10-24/cleanup.py", line 8, in __exit__
    raise RuntimeError('hah')
RuntimeError: hah

3. 错误重试中隐藏了不同种的错误。

下面是简略版的标准库socket.create_connection的实现:

def create_connection(address, timeout=_GLOBAL_DEFAULT_TIMEOUT,
                      source_address=None):
    host, port = address
    err = None
    for res in getaddrinfo(host, port, 0, SOCK_STREAM):
        try:
            # do some stuff
            return sock

        except error as _:
            err = _
            if sock is not None:
                sock.close()

    if err is not None:
        try:
            raise err
        finally:
            err = None
    else:
        raise error("getaddrinfo returns an empty list")

我们知道网络请求可能出现各种类型错误,而这里只保留了最后一次错误信息,之前的都被忽略掉了。

语法介绍

改进方案就是引入异常组,异常组是一个可以带有子异常的结构:

In : eg = ExceptionGroup('request fail', [OSError('bad path'), ImportError('not found')])

In : eg.exceptions  # 异常元组,可以通过它找到对应的异常,很重要的属性
Out: (OSError('bad path'), ImportError('not found'))

In : eg.message
Out: 'request fail'

In : eg.args
Out: ('request fail', [OSError('bad path'), ImportError('not found')])

In : eg.split(OSError)  # 根据错误类型拆分组
Out:
(ExceptionGroup('request fail', [OSError('bad path')]),
 ExceptionGroup('request fail', [ImportError('not found')]))

我们再看一个复杂的结构:

In : exc = ExceptionGroup('d2 exc', [RuntimeError(1)])

In : exc2 = ExceptionGroup('d1 exc', [TypeError(2), exc])

In : exc3
Out:
ExceptionGroup('root exc',
               [ExceptionGroup('d1 exc',
                               [TypeError(2),
                                ExceptionGroup('d2 exc', [RuntimeError(1)])]),
                OSError(3),
                IndexError(4)])

In : exc3.exceptions[0].exceptions[1].exceptions[0]
Out: RuntimeError(1)

In : err = exc3.subgroup(lambda e: isinstance(e, RuntimeError))  # 条件过滤

In : err
Out:
ExceptionGroup('root exc',
               [ExceptionGroup('d1 exc',
                               [ExceptionGroup('d2 exc', [RuntimeError(1)])])])

In : import traceback

In : traceback.print_exception(err)
  | ExceptionGroup: root exc (1 sub-exception)
  +-+---------------- 1 ----------------
    | ExceptionGroup: d1 exc (1 sub-exception)
    +-+---------------- 1 ----------------
      | ExceptionGroup: d2 exc (1 sub-exception)
      +-+---------------- 1 ----------------
        | RuntimeError: 1
        +------------------------------------

上面我构建了一个共三层的异常组,大家感受下这个语法的结构和灵活,通过不同的层次可以表现出复杂的异常信息。

接着看另外一个语法except*。它就是为了搭配ExceptionGroup而用的,星号符号 (*) 表示每个except*子句可以处理多个异常:

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

我们再看一下更明显的例子:

In : eg
Out:
ExceptionGroup('d1 exc',
               [TypeError(2),
                ExceptionGroup('d2 exc', [TypeError(1), IndexError(3)]),
                RuntimeError(4)])

In : try:
...:     raise eg
...: except TypeError as e:  # 没有捕获到
...:     print(f'catch errors: {e=}')
...: except Exception as e2:
...:     print(f'other errors: {e2=}')
...:
other errors: e2=ExceptionGroup('d1 exc', [TypeError(2), ExceptionGroup('d2 exc', [TypeError(1), IndexError(3)]), RuntimeError(4)])

In : try:
...:     raise eg
...: except* TypeError as e:  # 成功捕获
...:     print(f'catch errors: {e=}')
...: except* Exception as e2:
...:     print(f'other errors: {e2=}')
...:
catch errors: e=ExceptionGroup('d1 exc', [TypeError(2), ExceptionGroup('d2 exc', [TypeError(1)])])
other errors: e2=ExceptionGroup('d1 exc', [ExceptionGroup('d2 exc', [IndexError(3)]), RuntimeError(4)])

在第一层和第二层都有TypeError,如果使用过去的except会捕获失败。

这个新语法另外一个主要特别是可以捕获一系列异常,所以如果异常 A 是某个异常的 B 子类,那么可以通过捕获 B 也能获取到 A,说的有点绕,来个例子:

In : eg = ExceptionGroup('root exc',
...:                [ExceptionGroup('d1 exc',
...:                                [BlockingIOError(2),
...:                                 ExceptionGroup('d2 exc', [ConnectionError(1)])]),
...:                 FileExistsError(3),
...:                 IndexError(4)])

In : try:
...:     raise eg
...: except* OSError as e:
...:     print(f'catch errors: {e=}')
...: except* Exception as e2:
...:     print(f'other errors: {e2=}')
...:
catch errors: e=ExceptionGroup('root exc', [ExceptionGroup('d1 exc', [BlockingIOError(2), ExceptionGroup('d2 exc', [ConnectionError(1)])]), FileExistsError(3)])
other errors: e2=ExceptionGroup('root exc', [IndexError(4)])

上述 4 个异常中,除了IndexError以外其他的都是OSError的子类,所以可以通过except* OSError一起捕获。同时也要注意,在后面再捕具体的类型会失败:

In : try:
...:     raise eg
...: except* OSError as e:
...:     print(f'catch errors: {e=}')
...: except* ConnectionError:  # 新加的
...:     print('not catch!')
...: except* Exception as e2:
...:     print(f'other errors: {e2=}')
...:

其中except* ConnectionError不会成功,因为会符合前面的条件到不了这里。

PEP 678 – Enriching Exceptions with Notes

PEP 678 提出给异常增加add_note方法可以给异常添加注释,这个注释信息会记录在__notes__属性里面:

 python  # 目前最新版IPython还不支持这个特性,先用Python解释器
>>> try:
...     raise TypeError('Bad type')
... except Exception as e:
...     e.add_note('Add some information')
...     raise
...
Traceback (most recent call last):
  File "<stdin>", line 2, in <module>
TypeError: Bad type
Add some information
>>> e.__notes__  # 默认异常还没有这个属性
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'TypeError' object has no attribute '__notes__'. Did you mean: '__ne__'?
>>> e.add_note('Add some information')
>>> e.__notes__
['Add some information']

异常组里面也可以用add_notes:

def task(num):
    raise TypeError(f'Error with {num}')


errors = []
for num in [1, 4, 9]:
    try:
        task(num)
    except TypeError as e:
        e.add_note(f'Note: {num}')
        errors.append(e)

if errors:
    raise ExceptionGroup('Task issues', errors)

效果:

+ Exception Group Traceback (most recent call last):
  |   File "/Users/weiming.dong/mp/2022-10-24/pep678.py", line 14, in <module>
  |     raise ExceptionGroup("Task issues", errors)
  | ExceptionGroup: Task issues (3 sub-exceptions)
  +-+---------------- 1 ----------------
    | Traceback (most recent call last):
    |   File "/Users/weiming.dong/mp/2022-10-24/pep678.py", line 8, in <module>
    |     task(num)
    |   File "/Users/weiming.dong/mp/2022-10-24/pep678.py", line 2, in task
    |     raise TypeError(f'Error with {num}')
    | TypeError: Error with 1
    | Note: 1
    +---------------- 2 ----------------
    | Traceback (most recent call last):
    |   File "/Users/weiming.dong/mp/2022-10-24/pep678.py", line 8, in <module>
    |     task(num)
    |   File "/Users/weiming.dong/mp/2022-10-24/pep678.py", line 2, in task
    |     raise TypeError(f'Error with {num}')
    | TypeError: Error with 4
    | Note: 4
    +---------------- 3 ----------------
    | Traceback (most recent call last):
    |   File "/Users/weiming.dong/mp/2022-10-24/pep678.py", line 8, in <module>
    |     task(num)
    |   File "/Users/weiming.dong/mp/2022-10-24/pep678.py", line 2, in task
    |     raise TypeError(f'Error with {num}')
    | TypeError: Error with 9
    | Note: 9
    +------------------------------------

也就是说,增加的注释会出现在异常信息下面。

PEP 657: Fine-grained error locations in tracebacks

我觉得这也属于类似于 Python 3.10 里面的【更好的错误提示】方向的工作,这个改进也有一点小故事。

19 年 7 月 22 日,Python 之父 Guido van Rossum 在 Medium 上写了一篇博文《PEG Parsers》。说他正在考虑使用 PEG Parser 代替现有的类 LL (1) Parser 重构 Python 解释器。原因是现在的 pgen 限制了 Python 语法的自由度,使得一些语法难以实现,也让当前的语法树不够整洁,一定程度上影响了语法树的表意,不能最好地体现设计者的意图。具体内容建议阅读原文,延伸阅读 8。

在之后他连续的写了几篇文章介绍这个 PEG,而 Python 3.9 时就已经基于 PEG (Parsing Expression Grammar) 的新解析器替代 LL (1)。新解析器的性能与旧解析器大致相当,但 PEG 在设计新语言特性时的形式化比 LL (1) 更灵活。

正是由于这个 PEG,才会在 Python 增加了 match-case 语法并做了很多更好的错误信息的改进。

在过去,发生异常时输入数据结构简单是可以快速找到问题点的,但是当处理复杂的结构时对于发生错误的代码位置报的是不准确的,这无疑增加了初级开发者解决问题的难度,在官网上列出了几个具体例子,我就不挨个介绍了,可以看延伸阅读链接 9。和之前写 Python 3.10 时一样,大家没必要具体了解每个错误提示的改进,反正知道从 Python 3.11 对于 Traceback 的错误点定位更准就好啦。

类型系统

具体的可以看我之前写的文章: Python 3.11 新加入的和类型系统相关的新特性

AsyncIO Task Groups

这个asyncio.TaskGroup未来用来替代之前的asyncio.gather这个 API。它主要的优势是支持新增的异常组特性可以捕获异步 IO 的异常。我举个例子来对比一下:

async def core_success():
    return 'success'


async def core_value_error():
    raise ValueError


async def core_type_error():
    raise TypeError


async def gather():
    results = await asyncio.gather(
        core_success(),
        core_value_error(),
        core_type_error(),
        return_exceptions=True)
    for r in results:
        match r:
            case ValueError():
                print('value_error')
            case TypeError():
                print('type_error')
            case _:
                print(r)

注意,gather如果参数是return_exceptions=False会丢失异常。在看一下asyncio.TaskGroup的例子:

async def task_group1():
    try:
        async with asyncio.TaskGroup() as g:
            task1 = g.create_task(core_success())
            task2 = g.create_task(core_value_error())
            task3 = g.create_task(core_type_error())
        results = [task1.result(), task2.result(), task3.result()]
    except* ValueError as e:
        raise
    except* TypeError as e:
        raise


asyncio.run(task_group1())

这个 2 个例子实现了一样的逻辑,但是asyncio.TaskGroup更灵活,在async with这个上下文里面,只要全部任务没有完成就可以随时加入新的任务,另外任务组的还有个优势在于取消任务更灵活:

async def core_success():
    print('success')


async def core_value_error():
    raise ValueError


async def core_long():
    try:
        await asyncio.sleep(1)
        print('long task done!')
    except asyncio.CancelledError:
        print('cancelled!')
        raise

async def task_group2():
    try:
        async with asyncio.TaskGroup() as g:
            task1 = g.create_task(core_success())
            task2 = g.create_task(core_value_error())
            task3 = g.create_task(core_long())
        results = [task1.result(), task2.result(), task3.result()]
    except* ValueError as e:
        print(f'{e=}')
    except* TypeError as e:
        print(f'{e=}')

    for r in [task1, task2, task3]:
        if not r.done():
            r.cancel()

在上面例子中,新增了一个core_long的任务 sleep 了 1 秒,而在执行结束后并没有等待直接判断任务状态,如果没有完成就可以方便的取消了。

➜ python asyncio_task_group.py
success
cancelled!
e=ExceptionGroup('unhandled errors in a TaskGroup', [ValueError()])

代码目录

本文代码可以在 mp 项目找到

延伸阅读

  1. https://github.com/markshannon/faster-cpython
  2. https://lwn.net/Articles/857754/
  3. https://peps.python.org/pep-0659/
  4. https://github.com/faster-cpython/ideas/wiki/Python-3.12-Goals
  5. https://peps.python.org/pep-0654/
  6. https://peps.python.org/pep-0678/
  7. https://peps.python.org/pep-0657/
  8. https://medium.com/@gvanrossum_83706/peg-parsers-7ed72462f97c
  9. https://docs.python.org/3.11/whatsnew/3.11.html#new-features
  10. https://www.youtube.com/watch?v=uARIj9eAZcQ