Python 3.11: новые классные возможности, которые вы можете попробовать

Оглавление

Python 3.11 был опубликован 24 октября 2022. Эта последняя версия Python стала быстрее и удобнее для пользователя. После семнадцати месяцев разработки она готова к использованию в первую очередь.

Как и каждая версия, Python 3.11 содержит множество улучшений и изменений. Список всех из них вы можете увидеть в документации. Здесь вы познакомитесь с самыми крутыми и влиятельными новыми возможностями.

В этом руководстве вы узнаете о новых возможностях и улучшениях, таких как:

  • Улучшенные сообщения об ошибках с более информативными отсылками
  • Более быстрое выполнение кода благодаря значительным усилиям в проекте Faster CPython
  • Группы задач и исключений, которые упрощают работу с асинхронным кодом
  • Несколько новых возможностей типизации, улучшающих поддержку статической типизации в Python
  • Родная поддержка TOML для работы с конфигурационными файлами

Если вы хотите попробовать любой из примеров в этом руководстве, то вам понадобится Python 3.11. В Python 3 Installation & Setup Guide и How Can You Install a Pre-Release Version of Python? вы узнаете о нескольких вариантах добавления новой версии Python в вашу систему.

Кроме того, что вы узнаете о новых возможностях языка, вы также получите несколько советов о том, что следует учесть перед переходом на новую версию. Нажмите на ссылку ниже, чтобы загрузить примеры кода, демонстрирующие новые возможности Python 3.11:

Бесплатная загрузка: Нажмите здесь, чтобы загрузить бесплатный пример кода, демонстрирующий некоторые из новых возможностей Python 3.11.

Более информативные трассировки ошибок

Python часто признается хорошим языком программирования для начинающих, с его читабельным синтаксисом и мощными структурами данных. Проблема для всех, но особенно для новичков в Python, заключается в том, как интерпретировать traceback, который отображается, когда Python сталкивается с ошибкой.

В версии Python 3.10 сообщения об ошибках Python были значительно улучшены. Аналогичным образом, одна из самых ожидаемых функций Python 3.11 также улучшит ваш опыт разработчика. Декоративные аннотации добавляются к трассировкам и могут помочь вам быстрее интерпретировать сообщение об ошибке.

Чтобы увидеть быстрый пример улучшенного отслеживания, добавьте следующий код в файл с именем inverse.py:

# inverse.py

def inverse(number):
    return 1 / number

print(inverse(0))

Вы можете использовать inverse() для вычисления мультипликативной обратной величины числа. Мультипликативной обратной величины 0 не существует, поэтому при выполнении вашего кода возникает ошибка:

$ python inverse.py
Traceback (most recent call last):
  File "/home/realpython/inverse.py", line 6, in <module>
    print(inverse(0))
          ^^^^^^^^^^
  File "/home/realpython/inverse.py", line 4, in inverse
    return 1 / number
           ~~^~~~~~~~
ZeroDivisionError: division by zero

Обратите внимание на символы ^ и ~, встроенные в обратную трассировку. Они используются для того, чтобы направить ваше внимание на код, вызывающий ошибку. Как обычно в случае с обратными ссылками, следует начинать с самого низа и двигаться вверх. В этом примере ошибка ZeroDivisionError вызвана делением 1 / number. На самом деле виновником является вызов inverse(0), так как 0 не имеет обратной величины.

Получение такой дополнительной помощи в выявлении ошибок полезно. Однако аннотированные трассировки становятся еще более мощными, если ваш код более сложен. Они могут передать информацию, которую вы не могли получить из трассировки как таковой.

Чтобы оценить возможности улучшенных трассировок, вы построите небольшой парсер информации о нескольких программистах . Предположим, что у вас есть файл с именем programmers.json со следующим содержимым:

[
    {"name": {"first": "Uncle Barry"}},
    {
        "name": {"first": "Ada", "last": "Lovelace"},
        "birth": {"year": 1815},
        "death": {"month": 11, "day": 27}
    },
    {
        "name": {"first": "Grace", "last": "Hopper"},
        "birth": {"year": 1906, "month": 12, "day": 9},
        "death": {"year": 1992, "month": 1, "day": 1}
    },
    {
        "name": {"first": "Ole-Johan", "last": "Dahl"},
        "birth": {"year": 1931, "month": 10, "day": 12},
        "death": {"year": 2002, "month": 6, "day": 29}
    },
    {
        "name": {"first": "Guido", "last": "Van Rossum"},
        "birth": {"year": 1956, "month": 1, "day": 31},
        "death": null
    }
]

Обратите внимание, что информация о программистах весьма противоречива. Хотя информация о Грейс Хоппер и Оле-Йохане Дале является полной, вам не хватает дня и месяца рождения Ады Лавлейс, а также года ее смерти. Естественно, у вас есть информация о рождении только Гидо ван Россума. В довершение всего, вы записали только имя дяди Барри

Вы создадите класс, который сможет обернуть эту информацию. Начните с чтения информации из файла JSON:

# programmers.py

import json
import pathlib

programmers = json.loads(
    pathlib.Path("programmers.json").read_text(encoding="utf-8")
)

Вы используете pathlib для чтения файла JSON и json для разбора информации в список словарей Python.

Далее, вы будете использовать класс data class для инкапсуляции информации о каждом программисте:

# programmers.py

from dataclasses import dataclass

# ...

@dataclass
class Person:
    name: str
    life_span: tuple[int, int]

    @classmethod
    def from_dict(cls, info):
        return cls(
            name=f"{info['name']['first']} {info['name']['last']}",
            life_span=(info["birth"]["year"], info["death"]["year"]),
        )

Каждый Person будет иметь name и life_span атрибут. Кроме того, вы добавляете удобный конструктор , который может инициализировать Person на основе информации и структуры в вашем JSON файле.

Вы также добавите функцию, которая может инициализировать два объекта Person за один раз:

# programmers.py

# ...

def convert_pair(first, second):
    return Person.from_dict(first), Person.from_dict(second)

Функция convert_pair() дважды использует конструктор .from_dict() для преобразования пары программистов из структуры JSON в Person объекты.

Пришло время изучить ваш код и, в частности, посмотреть на некоторые трассировки. Запустите вашу программу с флагом -i, чтобы открыть интерактивный REPL Python со всеми переменными, классами и функциями:

$ python -i programmers.py
>>> Person.from_dict(programmers[2])
Person(name='Grace Hopper', life_span=(1906, 1992))

Информация о Грейс завершена, поэтому вы можете инкапсулировать ее в объект Person с информацией о ее полном имени и продолжительности жизни.

Чтобы увидеть новую трассировку в действии, попробуйте преобразовать дядю Барри:

>>>

>>> programmers[0]
{'name': {'first': 'Uncle Barry'}}

>>> Person.from_dict(programmers[0])
Traceback (most recent call last):
  File "/home/realpython/programmers.py", line 17, in from_dict
    name=f"{info['name']['first']} {info['name']['last']}",
                                    ~~~~~~~~~~~~^^^^^^^^
KeyError: 'last'

Вы получаете KeyError, потому что отсутствует last. Хотя вы можете помнить, что last является подполем внутри name, аннотация сразу же указывает вам на это.

Аналогично, вспомните, что информация о продолжительности жизни Ады неполная. Вы не можете создать для нее объект Person:

>>>

>>> programmers[1]
{
    'name': {'first': 'Ada', 'last': 'Lovelace'},
    'birth': {'year': 1815},
    'death': {'month': 11, 'day': 27}
}

>>> Person.from_dict(programmers[1])
Traceback (most recent call last):
  File "/home/realpython/programmers.py", line 18, in from_dict
    life_span=(info["birth"]["year"], info["death"]["year"]),
                                      ~~~~~~~~~~~~~^^^^^^^^
KeyError: 'year'

Вы получаете еще один KeyError, на этот раз из-за отсутствия year. В этом случае отслеживание еще более полезно, чем в предыдущем примере. У вас есть два подполя year, одно для birth и одно для death. Аннотация отслеживания сразу же показывает, что вам не хватает года смерти.

Что будет с Гвидо? У вас есть информация только о его рождении:

>>>

>>> programmers[4]
{
    'name': {'first': 'Guido', 'last': 'Van Rossum'},
    'birth': {'year': 1956, 'month': 1, 'day': 31},
    'death': None
}

>>> Person.from_dict(programmers[4])
Traceback (most recent call last):
  File "/home/realpython/programmers.py", line 18, in from_dict
    life_span=(info["birth"]["year"], info["death"]["year"]),
                                      ~~~~~~~~~~~~~^^^^^^^^
TypeError: 'NoneType' object is not subscriptable

В этом случае возникает ошибка TypeError. Возможно, вы уже встречали подобные ошибки типа 'NoneType'. Они могут быть трудноотлаживаемыми, поскольку неясно, какой объект неожиданно None. Однако из аннотации видно, что в данном примере info["death"] является None.

В последнем примере вы изучите, что происходит с вызовами вложенных функций. Помните, что convert_pair() дважды вызывает Person.from_dict(). Теперь попробуйте соединить Аду и Оле-Йохана:

>>>

>>> convert_pair(programmers[3], programmers[1])
Traceback (most recent call last):
  File "/home/realpython/programmers.py", line 24, in convert_pair
    return Person.from_dict(first), Person.from_dict(second)
                                    ^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/realpython/programmers.py", line 18, in from_dict
    life_span=(info["birth"]["year"], info["death"]["year"]),
                                      ~~~~~~~~~~~~~^^^^^^^^
KeyError: 'year'

Попытка инкапсулировать Ada приводит к тому же KeyError, что и ранее. Однако обратите внимание на трассировку изнутри convert_pair(). Поскольку функция дважды вызывает .from_dict(), обычно требуется некоторое усилие, чтобы выяснить, была ли ошибка вызвана при обработке first или second. В последней версии Python сразу видно, что проблемы вызваны second.

Эти трассировки делают отладку в Python 3.11 проще, чем в предыдущих версиях. Вы можете увидеть больше примеров, больше информации о том, как реализованы отслеживание и другие инструменты, которые вы можете использовать для отладки в предварительном руководстве по Python 3.11 Even Better Error Messages. Для получения более подробной технической информации посмотрите PEP 657.

Аннотированные трассировки станут благом для вашей продуктивности как разработчика Python. Еще одно интересное событие - Python 3.11 является самой быстрой версией Python.

Быстрое выполнение кода

Python имеет репутацию медленного языка. Например, обычный цикл в Python на порядки медленнее, чем аналогичный цикл в C. Этому недостатку есть несколько способов противостоять. Часто производительность программиста важнее, чем время выполнения кода.

Python также способен обернуть библиотеки, написанные на более быстрых языках. Например, вычисления, выполненные в NumPy, намного быстрее, чем аналогичные вычисления, выполненные в чистом Python. В сочетании с простотой разработки кода это делает Python сильным соперником в области науки о данных.

Тем не менее, существует стремление сделать основной язык Python быстрее. Осенью 2020 года Марк Шеннон предложил несколько улучшений производительности, которые можно было бы реализовать в Python. Предложение, которое известно как План Шеннона, является очень амбициозным и надеется сделать Python в пять раз быстрее в течение нескольких релизов.

Microsoft включилась в работу и в настоящее время поддерживает группу разработчиков, включая Марка Шеннона и создателя Python Гвидо ван Россума, работающих над проектом Faster CPython, как он теперь называется. В Python 3.11 есть много улучшений, основанных на проекте Faster CPython. В этом разделе вы узнаете о специализированном адаптивном интерпретаторе. В последующих разделах вы также узнаете о ускоренном времени запуска и беззатратных исключениях.

PEP 659 описывает специализированный адаптивный интерпретатор. Основная идея заключается в том, чтобы ускорить код во время его выполнения путем оптимизации операций, которые выполняются часто. Это похоже на компиляцию just-in-time (JIT), за исключением того, что это не влияет на компиляцию. Вместо этого байткод Python адаптируется или изменяется на лету.

Примечание: Код Python перед выполнением компилируется в байткод. Байткод состоит из большего количества базовых инструкций, чем обычный код Python, поэтому каждая строка Python преобразуется в несколько операторов байткода.

Вы можете использовать dis, чтобы посмотреть на байткод Python. В качестве примера рассмотрим функцию, которая может преобразовывать футы в метры:

>>>

 1>>> def feet_to_meters(feet):
 2...     return 0.3048 * feet
 3...

Вы можете разобрать эту функцию на байткод, вызвав dis.dis():

>>>

>>> import dis
>>> dis.dis(feet_to_meters)
  1           0 RESUME                   0

  2           2 LOAD_CONST               1 (0.3048)
              4 LOAD_FAST                0 (feet)
              6 BINARY_OP                5 (*)
             10 RETURN_VALUE

В каждой строке отображается информация об одной инструкции байткода. Пять столбцов: номер строки, адрес байта, имя кода операции, параметры операции и интерпретация параметров в круглых скобках.

В целом, чтобы писать на Python, вам не нужно знать о байткоде. Однако это может помочь вам понять, как Python работает внутри.

В генерацию байткода был добавлен новый этап под названием quickening. Для этого берутся инструкции, которые могут быть оптимизированы во время выполнения, и заменяются на адаптивные инструкции. Каждая такая инструкция будет смотреть на то, как она используется, и может специализировать себя соответствующим образом.

Ускорение срабатывает после того, как функция была вызвана определенное количество раз. В CPython 3.11 это происходит после восьми вызовов. Вы можете наблюдать, как интерпретатор адаптирует байткод, вызывая dis() и устанавливая параметр adaptive. Сначала определите функцию и вызовите ее семь раз с числами с плавающей точкой в качестве аргументов:

>>>

>>> def feet_to_meters(feet):
...     return 0.3048 * feet
...

>>> feet_to_meters(1.1)
0.33528
>>> feet_to_meters(2.2)
0.67056
>>> feet_to_meters(3.3)
1.00584
>>> feet_to_meters(4.4)
1.34112
>>> feet_to_meters(5.5)
1.6764000000000001
>>> feet_to_meters(6.6)
2.01168
>>> feet_to_meters(7.7)
2.34696

Далее посмотрите на байткод выражения feet_to_meters():

>>>

>>> import dis
>>> dis.dis(feet_to_meters, adaptive=True)
  1           0 RESUME                   0

  2           2 LOAD_CONST               1 (0.3048)
              4 LOAD_FAST                0 (feet)
              6 BINARY_OP                5 (*)
             10 RETURN_VALUE

Вы пока не заметите ничего особенного. Эта версия байткода все еще такая же, как и неадаптивная. Все изменится, когда вы вызовете feet_to_meters() в восьмой раз:

>>>

>>> feet_to_meters(8.8)
2.68224

>>> dis.dis(feet_to_meters, adaptive=True)
  1           0 RESUME_QUICK                 0

  2           2 LOAD_CONST__LOAD_FAST        1 (0.3048)
              4 LOAD_FAST                    0 (feet)
              6 BINARY_OP_MULTIPLY_FLOAT     5 (*)
             10 RETURN_VALUE

Теперь несколько оригинальных инструкций были заменены специализированными. Например, BINARY_OP была специализирована на BINARY_OP_MULTIPLY_FLOAT, которая быстрее перемножает два числа float.

Даже если feet_to_meters() был оптимизирован для случая, когда feet является параметром float, он по-прежнему работает как обычно для других типов параметров, возвращаясь к исходной инструкции байткода. Внутренние операции изменились, но ваш код будет вести себя точно так же, как и раньше.

Специализированные инструкции по-прежнему адаптивны. Вызовите свою функцию еще пятьдесят два раза, но теперь с целочисленным аргументом:

>>>

>>> for feet in range(52):
...     feet_to_meters(feet)
...

>>> dis.dis(feet_to_meters, adaptive=True)
  1           0 RESUME_QUICK                 0

  2           2 LOAD_CONST__LOAD_FAST        1 (0.3048)
              4 LOAD_FAST                    0 (feet)
              6 BINARY_OP_MULTIPLY_FLOAT     5 (*)
             10 RETURN_VALUE

Интерпретатор Python все еще надеется, что сможет перемножить два числа float. Когда вы вызываете feet_to_meters() еще раз с целым числом, он уходит в отставку и преобразуется обратно в неспециализированную, адаптивную инструкцию:

>>>

>>> feet_to_meters(52)
15.8496

>>> dis.dis(feet_to_meters, adaptive=True)
  1           0 RESUME_QUICK              0

  2           2 LOAD_CONST__LOAD_FAST     1 (0.3048)
              4 LOAD_FAST                 0 (feet)
              6 BINARY_OP_ADAPTIVE        5 (*)
             10 RETURN_VALUE

В этом случае инструкция байткода изменяется на BINARY_OP_ADAPTIVE, а не BINARY_OP_MULTIPLY_INT, потому что один из операторов, 0.3048, всегда является числом с плавающей точкой.

Умножения между целыми числами и числами с плавающей запятой труднее оптимизировать, чем умножения между числами одного типа. По крайней мере, на данный момент не существует специализированной инструкции для выполнения умножения между float и int.

Этот пример призван дать вам некоторое представление о том, как работает адаптивный специализированный интерпретатор. В целом, вам не стоит беспокоиться об изменении существующего кода, чтобы воспользоваться его преимуществами. Большая часть вашего кода просто будет выполняться быстрее, чем сейчас.

Однако есть несколько случаев, когда вы можете рефакторизовать свой код, чтобы он мог быть специализирован более эффективно. Инструмент Брандта Бухера specialist визуализирует то, как ваш код обрабатывается интерпретатором. В tutorial показан пример ручного улучшения кода. Вы можете узнать еще больше на подкасте Talk Python to Me.

Несколько важных рекомендаций для проекта Faster CPython:

  • Проект не внесет никаких ломающих изменений в Python.
  • Производительность большинства кода должна быть улучшена.

В бенчмарках "CPython 3.11 в среднем на 25% быстрее, чем CPython 3.10" (Источник). Однако вас должно больше интересовать, как Python 3.11 работает в вашем коде, чем то, насколько хорошо он работает в бенчмарках. Разверните блок ниже, чтобы узнать, как можно измерить производительность собственного кода:

Проект Faster CPython - это постоянная работа, и уже есть несколько оптимизаций, которые планируется выпустить в Python 3.12 в октябре 2023 года. Вы можете следить за проектом на GitHub. Чтобы узнать больше, вы также можете ознакомиться со следующими обсуждениями и презентациями:

Faster CPython - это масштабный проект, который затрагивает все части Python. Адаптивный специализированный интерпретатор - это одна из частей этой работы. Позже в этом учебнике вы узнаете о двух других оптимизациях: более быстрый запуск и исключения без затрат.

Синтаксис Nicer для асинхронных задач

Поддержка асинхронного программирования в Python развивалась в течение длительного времени. Основы были заложены в эпоху Python 2 с добавлением generators. Библиотека asyncio была первоначально добавлена в Python 3.4, а ключевые слова async и await последовали за ней в Python 3.5.

Развитие продолжилось в последующих выпусках, и в асинхронные возможности Python было добавлено множество небольших улучшений. В Python 3.11 вы можете использовать группы задач, которые обеспечивают более чистый синтаксис для запуска и мониторинга асинхронных задач.

Примечание: Если вы еще не знакомы с асинхронным программированием в Python, то можете ознакомиться со следующими ресурсами для начала работы:

Вы также можете узнать еще больше подробностей об асинхронных группах задач в Python 3.11 Preview: Группы задач и исключений.

Библиотека asyncio является частью стандартной библиотеки Python. Однако это не единственный способ асинхронной работы. Существует несколько популярных библиотек сторонних разработчиков, которые предлагают те же возможности, включая Trio и Curio. Кроме того, такие пакеты, как uvloop, AnyIO и Quattro улучшают asyncio, повышая производительность и расширяя возможности.

Традиционный способ запуска нескольких асинхронных задач с помощью asyncio заключается в создании задач с помощью create_task(), а затем их ожидании с помощью gather(). Это обеспечивает выполнение задач, но немного громоздко в работе.

Для организации детских задач Curio представил группы задач, а Trio - ясли в качестве альтернативы. Новые группы задач asyncio в значительной степени вдохновлены ими.

Когда вы организуете свои асинхронные задачи с помощью gather(), часть вашего кода обычно выглядит так:

tasks = [asyncio.create_task(run_some_task(param)) for param in params]
await asyncio.gather(*tasks)

Вы вручную отслеживаете все свои задачи в списке, прежде чем передать их gather(). Ожидая gather(), вы убеждаетесь, что каждая задача выполнена, прежде чем двигаться дальше.

Эквивалентный код более прост с группами задач. Вместо использования gather(), вы используете менеджер контекста, чтобы определить, когда задачи будут ожидать:

async with asyncio.TaskGroup() as tg:
    for param in params:
        tg.create_task(run_some_task(param))

Вы создаете объект группы задач, названный tg в этом примере, и используете его метод .create_task() для создания новых задач.

Чтобы увидеть полноценный пример, рассмотрим задачу загрузки нескольких файлов. Вы хотите загрузить текст некоторых исторических документов PEP, которые показывают, как развивались асинхронные возможности Python. Для эффективности вы будете использовать стороннюю библиотеку aiohttp для асинхронной загрузки файлов.

Начните с импорта необходимых библиотек, и обратите внимание на URL-адрес репозитория, где хранится текст каждого PEP:

# download_peps_gather.py

import asyncio
import aiohttp

PEP_URL = (
    "https://raw.githubusercontent.com/python/peps/master/pep-{pep:04d}.txt"
)

async def main(peps):
    async with aiohttp.ClientSession() as session:
        await download_peps(session, peps)

Вы добавляете функцию main(), которая инициализирует сессию aiohttp для управления пулом соединений, которые могут быть использованы повторно. Пока что вы вызываете функцию с именем download_peps(), которую вы еще не написали. Эта функция создаст по одной задаче для каждого PEP, который необходимо загрузить:

# download_peps_gather.py

# ...

async def download_peps(session, peps):
    tasks = [asyncio.create_task(download_pep(session, pep)) for pep in peps]
    await asyncio.gather(*tasks)

Это следует шаблону, который вы видели ранее. Каждая задача состоит из выполнения download_pep(), которые вы определите далее. Как только вы установили все задачи, вы передаете их в gather().

Каждая задача загружает один PEP. Вы добавите несколько вызовов print(), чтобы вы могли видеть, что происходит:

# download_peps_gather.py

# ...

async def download_pep(session, pep):
    print(f"Downloading PEP {pep}")
    url = PEP_URL.format(pep=pep)
    async with session.get(url, params={}) as response:
        pep_text = await response.text()

    title = pep_text.split("\n")[1].removeprefix("Title:").strip()
    print(f"Downloaded PEP {pep}: {title}")

Для каждого PEP вы находите его индивидуальный URL и используете session.get() для его загрузки. Получив текст PEP, найдите название PEP и выведите его на консоль.

Наконец, запустите main() асинхронно:

# download_peps_gather.py

# ...

asyncio.run(main([492, 525, 530, 3148, 3156]))

Вы вызываете свой код с помощью списка номеров PEP, все они связаны с возможностями async в Python. Запустите свой сценарий, чтобы посмотреть, как он работает:

$ python download_peps_gather.py
Downloading PEP 492
Downloading PEP 525
Downloading PEP 530
Downloading PEP 3148
Downloading PEP 3156
Downloaded PEP 3148: futures - execute computations asynchronously
Downloaded PEP 492: Coroutines with async and await syntax
Downloaded PEP 530: Asynchronous Comprehensions
Downloaded PEP 3156: Asynchronous IO Support Rebooted: the "asyncio" Module
Downloaded PEP 525: Asynchronous Generators

Вы видите, что все загрузки происходят одновременно, потому что все задачи сообщают, что они начинают загружать PEP, прежде чем какая-либо из задач сообщает, что она завершена. Также обратите внимание, что задачи запускаются в порядке, который вы определили, с PEPs в числовом порядке.

Наоборот, задачи завершаются в случайном порядке. Вызов gather() гарантирует, что все задачи будут выполнены до того, как ваш код продолжится.

Вы можете обновить свой код, чтобы использовать группу задач вместо gather(). Сначала скопируйте download_peps_gather.py в новый файл с именем download_peps_taskgroup.py. Эти файлы будут совершенно одинаковыми. Вам нужно будет только отредактировать функцию download_peps():

# download_peps_taskgroup.py

# ...

async def download_peps(session, peps):
    async with asyncio.TaskGroup() as tg:
        for pep in peps:
            tg.create_task(download_pep(session, pep))

# ...

Обратите внимание, что ваш код следует общей схеме, описанной перед примером. Сначала вы устанавливаете группу задач внутри контекстного менеджера, а затем используете эту группу задач для создания дочерних задач: по одной задаче для каждого PEP для загрузки. Запустите обновленный код и убедитесь, что он ведет себя так же, как и предыдущая версия.

Одна из проблем при работе с несколькими асинхронными задачами заключается в том, что любая из них может выдать ошибку в любой момент. Теоретически, две или более задач могут даже выдать ошибку одновременно.

Такие библиотеки, как Trio и Curio справлялись с этим с помощью специального вида мультиошибочного объекта. Это работало, но было немного громоздким, потому что Python не предлагал много встроенной поддержки.

Для правильной поддержки обработки ошибок в группах задач в Python 3.11 появились группы исключений, предназначенные для отслеживания нескольких одновременных ошибок. Подробнее о них вы узнаете позже в этом учебнике.

Группы задач используют группы исключений для обеспечения лучшей поддержки обработки ошибок по сравнению со старым подходом. Для более подробного обсуждения групп задач смотрите Python 3.11 Preview: Группы задач и исключений. Вы можете узнать больше об основных принципах в статье Гвидо ван Россума Рассуждения о asyncio.Semaphore.

Улучшенные переменные типа

Python - динамически типизированный язык, но он поддерживает статическую типизацию с помощью необязательных подсказок type hints. Основы системы статических типов Python были определены в PEP 484 в 2015 году. Начиная с Python 3.5, в каждый выпуск Python вносится несколько новых предложений, связанных с типизацией

Для Python 3.11 анонсировано пять PEP, связанных с типизацией - рекордный показатель:

  • PEP 646: Вариативные дженерики
  • PEP 655: Маркировка отдельных элементов TypedDict как необходимых или потенциально отсутствующих
  • PEP 673: Self тип
  • PEP 675: Произвольный литеральный строковый тип
  • PEP 681: Преобразования класса данных

В этом разделе вы сосредоточитесь на двух из них: переменных дженериках и типе Self. Для получения дополнительной информации ознакомьтесь с документами PEP и освещением типизации в этом Python 3.11 preview.

Примечание: Поддержка функций проверки типов зависит не только от версии Python, но и от вашей программы проверки типов. Например, некоторые из новых возможностей не поддерживаются в mypy на момент выхода Python 3.11.

Переменные

типа являются частью системы статической типизации Python с начала. Вы используете их для параметризации генеративных типов. Другими словами, если у вас есть список, то вы можете использовать переменную типа для проверки типа элементов внутри списка:

from typing import Sequence, TypeVar

T = TypeVar("T")

def first(sequence: Sequence[T]) -> T:
    return sequence[0]

Функция first() выбирает первый элемент из типа последовательности, например, списка. Код работает одинаково независимо от типа элементов последовательности. Тем не менее, вам необходимо отслеживать типы элементов, чтобы знать тип возвращаемой функции first().

Переменная типа делает именно это. Например, если вы передадите список целых чисел в first(), то T будет установлена в int при проверке типа. Поэтому программа проверки типов может сделать вывод, что этот вызов first() возвращает int. В этом примере список называется генеративным типом , поскольку он может быть параметризован другими типами.

Один паттерн, который развивался с течением времени, пытается решить проблему подсказок типов, которые ссылаются на текущий класс. Вспомним класс Person из более ранних :

# programmers.py

from dataclasses import dataclass

# ...

@dataclass
class Person:
    name: str
    life_span: tuple[int, int]

    @classmethod
    def from_dict(cls, info):
        return cls(
            name=f"{info['name']['first']} {info['name']['last']}",
            life_span=(info["birth"]["year"], info["death"]["year"]),
        )

Конструктор .from_dict() возвращает объект Person. Однако, вы не можете использовать -> Person в качестве подсказки типа для возвращаемого значения .from_dict(), потому что класс Person не полностью определен на данном этапе вашего кода.

Кроме того, если бы вам разрешили использовать -> Person, то это плохо сочеталось бы с наследованием. Если бы вы создали подкласс Person, то .from_dict() возвращал бы этот подкласс, а не объект Person.

Одним из решений этой проблемы является использование переменной типа, привязанной к вашему классу:

# programmers.py

# ...

from typing import Any, Type, TypeVar

TPerson = TypeVar("TPerson", bound="Person")

@dataclass
class Person:
    name: str
    life_span: tuple[int, int]

    @classmethod
    def from_dict(cls: Type[TPerson], info: dict[str, Any]) -> TPerson:
        return cls(
            name=f"{info['name']['first']} {info['name']['last']}",
            life_span=(info["birth"]["year"], info["death"]["year"]),
        )

Вы указываете bound, чтобы гарантировать, что TPerson будет только Person или один из его подклассов. Этот шаблон работает, но он не особенно удобен для чтения. Он также заставляет вас аннотировать self или cls, что обычно не нужно.

Теперь вместо него можно использовать новый тип Self. Он всегда будет ссылаться на инкапсулирующий класс, поэтому вам не придется вручную определять переменную типа. Следующий код эквивалентен предыдущему примеру:

# programmers.py

# ...

from typing import Any, Self

@dataclass
class Person:
    name: str
    life_span: tuple[int, int]

    @classmethod
    def from_dict(cls, info: dict[str, Any]) -> Self:
        return cls(
            name=f"{info['name']['first']} {info['name']['last']}",
            life_span=(info["birth"]["year"], info["death"]["year"]),
        )

Вы можете импортировать Self из typing. Вам не нужно создавать переменную типа или аннотировать cls. Вместо этого вы отмечаете, что метод возвращает Self, который будет ссылаться на Person.

Другой пример использования Self приведен в Python 3.11 Preview. Вы также можете ознакомиться с PEP 673 для получения более подробной информации.

Одним из ограничений переменных типов является то, что они могут одновременно обозначать только один тип. Скажем, у вас есть функция, которая меняет порядок следования двух элементов кортежа:

# pair_order.py

def flip(pair):
    first, second = pair
    return (second, first)

Здесь pair предполагается как кортеж с двумя элементами. Элементы могут быть разных типов, поэтому вам нужны две переменные типа, чтобы аннотировать вашу функцию:

# pair_order.py

from typing import TypeVar

T0 = TypeVar("T0")
T1 = TypeVar("T1")

def flip(pair: tuple[T0, T1]) -> tuple[T1, T0]:
    first, second = pair
    return (second, first)

Это немного громоздко для написания, но все же нормально. Аннотации явные и читабельные. Сложность возникает, если вы хотите аннотировать вариант вашего кода, который работает для кортежей с произвольным количеством элементов:

# tuple_order.py

def cycle(elements):
    first, *rest = elements
    return (*rest, first)

С помощью cycle() вы перемещаете первый элемент в конец кортежа с произвольным количеством элементов. Если вы передаете пару элементов, то это работает эквивалентно flip().

Подумайте о том, как бы вы аннотировали cycle(). Если elements - это кортеж с n элементами, то вам потребуются переменные типа n. Но количество элементов может быть любым, поэтому вы не знаете, сколько переменных типа вам понадобится.

PEP 646 вводит TypeVarTuple для обработки этого случая использования. Кортеж TypeVarTuple может обозначать произвольное количество типов. Поэтому вы можете использовать его для аннотирования общего типа с вариативными параметрами.

Вы можете добавить подсказки типа к cycle() следующим образом:

# tuple_order.py

from typing import TypeVar, TypeVarTuple

T0 = TypeVar("T0")
Ts = TypeVarTuple("Ts")

def cycle(elements: tuple[T0, *Ts]) -> tuple[*Ts, T0]:
    first, *rest = elements
    return (*rest, first)

TypeVarTuple будет заменять любое количество типов, поэтому эта аннотация будет работать для кортежей с одним, тремя, одиннадцатью или любым другим количеством элементов.

Обратите внимание, что звезда (*) перед Ts является необходимой частью синтаксиса. Она напоминает синтаксис распаковки, который вы уже используете в своем коде, и напоминает, что Ts представляет произвольное количество типов.

Мотивацией для введения кортежей переменных типов является аннотирование формы многомерных массивов. Вы можете узнать больше об этом примере в этом Python 3.11 preview и в PEP.

В завершение этого раздела о подсказках типов напомним, что статическая типизация объединяет два разных инструмента: язык Python и средство проверки типов. Чтобы использовать новые возможности типизации, ваша версия Python должна их поддерживать. Кроме того, они должны поддерживаться вашим средством проверки типов.

Многие возможности типизации, включая Self и TypeVarTuple, перенесены в старые версии Python в пакете typing_extensions. В Python 3.10 вы можете использовать pip для установки typing-extensions в вашу виртуальную среду и затем реализовать последний пример следующим образом:

# tuple_order.py

from typing_extensions import TypeVar, TypeVarTuple, Unpack

T0 = TypeVar("T0")
Ts = TypeVarTuple("Ts")

def cycle(elements: tuple[T0, Unpack[Ts]]) -> tuple[Unpack[Ts], T0]:
    first, *rest = elements
    return (*rest, first)

Синтаксис

*Ts допустим только в Python 3.11. Эквивалентной альтернативой , которая работает на более старых версиях Python, является Unpack[Ts]. Даже если ваш код работает на вашей версии Python, еще не все программы проверки типов поддерживают TypeVarTuple.

Поддержка разбора конфигурации TOML

TOML - это сокращение от Tom's Obvious Minimal Language. Это формат конфигурационного файла, который стал популярным в последнее десятилетие. Сообщество Python приняло TOML в качестве формата, который выбирается при определении метаданных для пакетов и проектов.

TOML был разработан таким образом, чтобы его было легко читать людям и легко разбирать компьютерам. О самом формате конфигурационного файла можно узнать в Python и TOML: новые лучшие друзья.

Хотя TOML уже много лет используется многими различными инструментами, в Python не было встроенной поддержки TOML. Это изменится в Python 3.11, когда в стандартную библиотеку будет добавлен модуль tomllib. Этот новый модуль построен поверх популярной сторонней библиотеки tomli и позволяет разбирать файлы TOML.

Ниже приведен пример файла TOML с именем units.toml:

# units.toml

[second]
label   = { singular = "second", plural = "seconds" }
aliases = ["s", "sec", "seconds"]

[minute]
label      = { singular = "minute", plural = "minutes" }
aliases    = ["min", "minutes"]
multiplier = 60
to_unit    = "second"

[hour]
label      = { singular = "hour", plural = "hours" }
aliases    = ["h", "hr", "hours"]
multiplier = 60
to_unit    = "minute"

[day]
label      = { singular = "day", plural = "days" }
aliases    = ["d", "days"]
multiplier = 24
to_unit    = "hour"

[year]
label      = { singular = "year", plural = "years" }
aliases    = ["y", "yr", "years", "julian_year", "julian years"]
multiplier = 365.25
to_unit    = "day"

Файл содержит несколько секций с заголовками в квадратных скобках. Каждая такая секция в TOML называется table, а ее заголовок - key. Таблицы содержат пары ключ-значение. Таблицы могут быть вложенными таким образом, что значения являются новыми таблицами. В приведенном выше примере видно, что каждая таблица, кроме second, имеет одинаковую структуру, с четырьмя ключами: label, aliases, multiplier и to_unit.

Значения могут иметь различные типы. В этом примере вы можете видеть четыре типа данных:

  1. label - это инлайн таблица, аналогичная словарю Python.
  2. aliases - это массив, аналогичный списку Python.
  3. multiplier - это число, либо целое число, либо число с плавающей точкой.
  4. to_unit является строкой.

TOML поддерживает еще несколько типов данных, включая булевы и даты. Подробное описание формата и примеры его синтаксиса смотрите в Python и TOML: новые лучшие друзья.

Для чтения файла TOML можно использовать tomllib:

>>>

>>> import tomllib
>>> with open("units.toml", mode="rb") as file:
...     units = tomllib.load(file)
...
>>> units
{'second': {'label': {'singular': 'second', 'plural': 'seconds'}, ... }}

При использовании tomllib.load() вы передаете объект файла, который должен быть открыт в бинарном режиме, указывая mode="rb". В качестве альтернативы вы можете разобрать строку с помощью tomllib.loads():

>>>

>>> import tomllib
>>> import pathlib
>>> units = tomllib.loads(
...     pathlib.Path("units.toml").read_text(encoding="utf-8")
... )
>>> units
{'second': {'label': {'singular': 'second', 'plural': 'seconds'}, ... }}

В этом примере вы сначала используете pathlib для чтения units.toml в строку, которую затем разбираете с помощью loads(). Документы TOML должны храниться в кодировке UTF-8. Вы должны явно указать кодировку, чтобы гарантировать, что ваш код работает одинаково на всех платформах.

Далее обратите внимание на результат вызова load() или loads(). В приведенных выше примерах видно, что units является вложенным словарем. Так будет всегда: tomllib разбирает документы TOML в словари Python.

В оставшейся части этого раздела вы попрактикуетесь в работе с данными TOML в Python. Вы создадите небольшой конвертер , который разберет ваш файл TOML и использует полученный словарь.

Примечание: Если вы занимаетесь преобразованием единиц измерения по-настоящему, то вам стоит ознакомиться с Pint. Эта библиотека может конвертировать между сотнями единиц и хорошо интегрируется в другие пакеты, такие как NumPy.

Добавьте свой код в файл с именем units.py:

# units.py

import pathlib
import tomllib

# Read units from file
with pathlib.Path("units.toml").open(mode="rb") as file:
    base_units = tomllib.load(file)

Вы хотите иметь возможность искать каждый блок по его имени или одному из его псевдонимов. Вы достигаете этого, копируя информацию о единице так, чтобы каждый псевдоним можно было использовать в качестве ключа словаря:

# units.py

# ...

units = {}
for unit, unit_info in base_units.items():
    units[unit] = unit_info
    for alias in unit_info["aliases"]:
        units[alias] = unit_info

Ваш словарь units теперь, например, будет содержать ключ second, а также его псевдонимы s, sec и seconds, указывающие на таблицу second.

Далее вы определите to_baseunit(), который может преобразовать любую единицу в файле TOML в соответствующую базовую единицу. В этом примере базовой единицей всегда является second. Однако вы можете расширить таблицу, включив в нее, например, единицы длины с meter в качестве базовой единицы.

Добавьте определение to_baseunit() в ваш файл:

# units.py

# ...

def to_baseunit(value, from_unit):
    from_info = units[from_unit]
    if "multiplier" not in from_info:
        return (
            value,
            from_info["label"]["singular" if value == 1 else "plural"],
        )

    return to_baseunit(value * from_info["multiplier"], from_info["to_unit"])

Вы реализуете to_baseunit() как рекурсивную функцию . Если таблица, соответствующая from_unit, не содержит поля multiplier, то вы рассматриваете его как базовую единицу и возвращаете его значение и имя. С другой стороны, если есть поле multiplier, то вы конвертируете в следующую единицу в цепочке и снова вызываете to_baseunit().

Запустите свой REPL. Затем импортируйте units и преобразуйте несколько чисел:

>>>

>>> import units
>>> units.to_baseunit(7, "s")
(7, 'seconds')

>>> units.to_baseunit(3.11, "minutes")
(186.6, 'seconds')

В первом примере "s" интерпретируется как second, поскольку это псевдоним. Поскольку это базовая единица, 7 возвращается нетронутым. Во втором примере "minutes" заставляет вашу функцию искать в таблице minute. Она обнаруживает, что может преобразовать в second путем умножения на 60.

Цепочка преобразований может быть длиннее:

>>>

>>> units.to_baseunit(14, "days")
(1209600, 'seconds')

>>> units.to_baseunit(1 / 12, "yr")
(2629800.0, 'seconds')

Чтобы преобразовать "days" в базовую единицу, ваша функция сначала преобразует day в hour, затем hour в minute и, наконец, minute в second. Вы обнаружили, что в четырнадцати днях около 1,2 миллиона секунд, а в одной двенадцатой части года - около 2,6 миллиона секунд.

Примечание: В примере вы используете файл TOML для хранения информации о единицах измерения, которые поддерживает ваш конвертер единиц. В качестве альтернативы вы можете поместить информацию непосредственно в код, определив base_units как литеральный словарь.

Однако использование конфигурационного файла дает несколько преимуществ, связанных с разделением кода и данных:

  • Ваша логика отделенаот ваших данных.
  • Неразработчики могут вносить вклад в ваш конвертер единиц, не касаясь и даже не зная о коде Python.
  • При минимальных усилиях вы можете поддерживать дополнительные файлы конфигурации единиц. Пользователи могут добавлять их для включения пользовательских единиц измерения в конвертер.

Вам следует подумать о создании файла конфигурации для любого проекта, с которым вы работаете.

Как отмечалось ранее, tomllib основан на tomli. Если вы хотите анализировать документы TOML в коде, который должен поддерживать старые версии Python, то вы можете установить tomli и использовать его как backport tomllib следующим образом:

try:
    import tomllib
except ModuleNotFoundError:
    import tomli as tomllib

В Python 3.11 это импортирует tomllib как обычно. В более ранних версиях Python импорт вызывает ошибку ModuleNotFoundError. Здесь вы отлавливаете ошибку и импортируете tomli вместо него, псевдоним tomllib, чтобы остальная часть вашего кода работала без изменений.

Подробнее о tomllib вы можете узнать в Python 3.11 Preview: TOML и tomllib. Кроме того, PEP 680 описывает обсуждения, которые привели к добавлению tomllib в Python.

Другие довольно крутые возможности

До сих пор вы узнали о самых больших изменениях и улучшениях в Python 3.11. Тем не менее, есть еще много новых возможностей, которые необходимо изучить. В этом разделе вы рассмотрите некоторые из новых возможностей, которые могут скрываться под заголовками. Они включают в себя увеличение скорости, изменения в исключениях и небольшое улучшение форматирования строк.

Ускоренный запуск

Еще одним интересным результатом проекта Faster CPython является более быстрое время запуска. Когда вы запускаете сценарий Python, при инициализации интерпретатора происходит несколько вещей. Это приводит к тому, что даже самой простой программе требуется несколько миллисекунд для запуска:

PS> Measure-Command {python -c "pass"}
...
TotalMilliseconds : 25.9823

Вы используете -c для передачи программы непосредственно в командной строке. В этом случае вся ваша программа состоит из оператора pass, который ничего не делает.

Во многих случаях время, необходимое для запуска вашей программы, ничтожно мало по сравнению со временем, которое требуется для выполнения вашего кода. Однако в сценариях с более коротким временем выполнения, таких как типичные приложения командной строки, время запуска может существенно повлиять на производительность вашей программы.

Для конкретного примера рассмотрим следующий сценарий, вдохновленный классической программой cowsay:

# snakesay.py
import sys

message = " ".join(sys.argv[1:])
bubble_length = len(message) + 2
print(
    rf"""
       {"_" * bubble_length}
      ( {message} )
       {"‾" * bubble_length}
        \
         \    __
          \  [oo]
             (__)\
               λ \\
                 _\\__
                (_____)_
               (________)Oo°"""
)

В программе snakesay.py вы читаете сообщение из командной строки . Затем вы печатаете сообщение в речевом пузыре, сопровождаемом симпатичной змейкой. Теперь вы можете заставить змейку говорить что угодно:

$ python snakesay.py Faster startup!
       _________________
      ( Faster startup! )
       ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾
        \
         \    __
          \  [oo]
             (__)\
               λ \\
                 _\\__
                (_____)_
               (________)Oo°

Это базовый пример приложения командной строки. Как и многие другие приложения командной строки, оно выполняется быстро. Тем не менее, на выполнение потребуется несколько миллисекунд. Большая часть этих накладных расходов происходит при импорте Python модулей, даже тех, которые вы сами явно не импортируете.

Вы можете использовать опцию -X importtime, чтобы показать обзор времени, затраченного на импорт модулей:

$ python -X importtime -S snakesay.py Imports are faster!
import time: self [us] | cumulative | imported package
import time:       283 |        283 |   _io
import time:        56 |         56 |   marshal
import time:       647 |        647 |   posix
import time:       587 |       1573 | _frozen_importlib_external
import time:       167 |        167 |   time
import time:       191 |        358 | zipimport
import time:        90 |         90 |     _codecs
import time:       561 |        651 |   codecs
import time:       825 |        825 |   encodings.aliases
import time:      1136 |       2611 | encodings
import time:       417 |        417 | encodings.utf_8
import time:       174 |        174 | _signal
import time:        56 |         56 |     _abc
import time:       251 |        306 |   abc
import time:       310 |        616 | io
       _____________________
      ( Imports are faster! )
       ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾
        \
         \    __
          \  [oo]
             (__)\
               λ \\
                 _\\__
                (_____)_
               (________)Oo°

Числа в таблице измеряются в микросекундах. Обратите внимание на форматирование имен модулей в последнем столбце. Древовидная структура показывает, что существует несколько модулей верхнего уровня и что они импортируют другие модули. Например, io является импортом верхнего уровня, а abc был импортирован io.

Примечание: Вы использовали опцию -S выше. Согласно документации, это "отключит импорт модуля site и зависящие от сайта манипуляции с sys.path, которые он влечет за собой" (Source).

В случае с этой простой программой, -S заставляет ее выполняться быстрее, поскольку требуется меньше импорта. Однако такую оптимизацию нельзя использовать в большинстве скриптов, поскольку вы не сможете импортировать сторонние библиотеки.

Пример был запущен на Python 3.11. В следующей таблице сравниваются эти цифры в микросекундах с выполнением той же команды в Python 3.10:

Module Python 3.11 Python 3.10 Speed-up
_frozen_importlib_external 1573 2255 1.43x
zipimport 358 558 1.56x
encodings 2611 3009 1.15x
encodings.utf_8 417 409 0.98x
_signal 174 173 0.99x
io 616 1216 1.97x
Total 5749 7620 1.33x

Ваши числа будут отличаться, но вы должны увидеть ту же закономерность. Импорт быстрее в Python 3.11, и это помогает программам Python запускаться быстрее.

Одной из основных причин ускорения является способ хранения и считывания байткода cached. Как вы узнали, Python компилирует ваш исходный код в байткод, который запускается интерпретатором. В течение долгого времени Python хранит скомпилированный байткод в каталоге с именем __pycache__, чтобы избежать ненужной перекомпиляции.

Но в новейшей версии Python многие модули заморожены и хранятся таким образом, чтобы ускорить их загрузку в память. Подробнее о более быстром запуске вы можете прочитать в документации.

Исключения с нулевой стоимостью

Внутреннее представление исключений отличается в Python 3.11. Объекты исключений стали более легковесными, а обработка исключений изменилась таким образом, что в блоке try ... except до тех пор, пока не срабатывает предложение except.

Так называемые zero-cost exceptions вдохновлены другими языками, такими как C++ и Java. Цель состоит в том, чтобы счастливый путь - когда исключение не возникает - был практически бесплатным. Обработка исключения все равно займет некоторое время.

Исключения с нулевыми затратами реализуются путем создания компилятором таблиц переходов при компиляции исходного кода в байткод. К этим таблицам обращаются, если возникает исключение. Если исключений нет, то код в блоке try не имеет накладных расходов во время выполнения.

Вспомните пример с мультипликативной инверсией, с которым вы работали ранее. Вы добавляете небольшую обработку ошибок:

>>>

 1>>> def inverse(number):
 2...     try:
 3...         return 1 / number
 4...     except ZeroDivisionError:
 5...         print("0 has no inverse")
 6...

Если вы пытаетесь вычислить обратное нулю число, то возникает ошибка ZeroDivisionError. В вашей новой реализации вы отлавливаете эти ошибки и выводите описательное сообщение. Как и раньше, вы используете dis, чтобы заглянуть в байткод под капотом:

>>>

>>> import dis
>>> dis.dis(inverse)
  1           0 RESUME                   0

  2           2 NOP

  3           4 LOAD_CONST               1 (1)
              6 LOAD_FAST                0 (number)
              8 BINARY_OP               11 (/)
             12 RETURN_VALUE
        >>   14 PUSH_EXC_INFO

  4          16 LOAD_GLOBAL              0 (ZeroDivisionError)
             28 CHECK_EXC_MATCH
             30 POP_JUMP_FORWARD_IF_FALSE    19 (to 70)
             32 POP_TOP

  5          34 LOAD_GLOBAL              3 (NULL + print)
             46 LOAD_CONST               2 ('0 has no inverse')
             48 PRECALL                  1
             52 CALL                     1
             62 POP_TOP
             64 POP_EXCEPT
             66 LOAD_CONST               0 (None)
             68 RETURN_VALUE

  4     >>   70 RERAISE                  0
        >>   72 COPY                     3
             74 POP_EXCEPT
             76 RERAISE                  1
ExceptionTable:
  4 to 10 -> 14 [0]
  14 to 62 -> 72 [1] lasti
  70 to 70 -> 72 [1] lasti

Вам не нужно разбираться в деталях байткода. Но вы можете сравнить числа в крайнем левом столбце с номерами строк в исходном коде. Обратите внимание, что строка 2, которая является try:, транслируется в одну инструкцию NOP. Это операция no operation, которая ничего не делает. Более интересно то, что в конце дизассемблера находится таблица исключений. Это таблица переходов, которую интерпретатор будет использовать, если ему понадобится обработать исключение.

В Python 3.10 и более ранних версиях существует небольшая обработка исключений во время выполнения. Например, оператор try компилируется в инструкцию SETUP_FINALLY, которая включает указатель на первый блок исключений. Замена этого указателя таблицей переходов ускоряет выполнение блоков try, когда исключения не возникают.

Исключения с нулевой стоимостью хорошо вписываются в стиль кода проще попросить о прощении, чем разрешить, который обычно использует много try ... except блоков.

Группы исключений

Ранее вы узнали о группах задач и о том, как они способны обрабатывать несколько ошибок одновременно. Они делают это с помощью новой возможности, называемой группой исключений.

Одним из способов представления о группах исключений является то, что это обычные исключения, обернутые в несколько других обычных исключений. Однако, хотя группы исключений во многих отношениях ведут себя как обычные исключения, они также поддерживают специальный синтаксис, который помогает вам эффективно обрабатывать каждое из обернутых исключений.

Вы создаете группу исключений, давая ей описание и перечисляя исключения, которые она охватывает:

>>>

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

Здесь вы создали группу исключений с описанием "twice", которая включает в себя TypeError и ValueError. Если группа исключений поднимается без обработки, то выводится красивый трассировочный откат, иллюстрирующий группировку и вложенность ошибок:

>>>

>>> raise ExceptionGroup("twice", [TypeError("int"), ValueError(654)])
  + Exception Group Traceback (most recent call last):
  |   File "<stdin>", line 1, in <module>
  | ExceptionGroup: twice (2 sub-exceptions)
  +-+---------------- 1 ----------------
    | TypeError: int
    +---------------- 2 ----------------
    | ValueError: 654
    +------------------------------------

Это сообщение об ошибке говорит о том, что была поднята группа исключений с двумя подисключениями. Каждое обернутое исключение отображается на отдельной панели.

Помимо введения групп исключений, новая версия Python добавляет новый синтаксис для эффективной работы с ними. Вы можете делать except ExceptionGroup as eg и перебирать каждую ошибку в цикле eg. Однако это громоздко. Вместо этого следует использовать новое ключевое слово except*:

>>>

>>> try:
...     raise ExceptionGroup("twice", [TypeError("int"), ValueError(654)])
... except* ValueError as err:
...     print(f"handling ValueError: {err.exceptions}")
... except* TypeError as err:
...     print(f"handling TypeError: {err.exceptions}")
...
handling ValueError: (ValueError(654),)
handling TypeError: (TypeError('int'),)

В отличие от обычных утверждений except, могут срабатывать несколько утверждений except*. В данном примере были обработаны оба оператора ValueError и TypeError.

Необработанные исключения внутри группы исключений остановят вашу программу и покажут обратный путь, как обычно. Обратите внимание, что ошибки, которые обрабатываются except*, отфильтровываются из группы:

>>>

>>> try:
...     raise ExceptionGroup("twice", [TypeError("int"), ValueError(654)])
... except* ValueError as err:
...     print(f"handling ValueError: {err.exceptions}")
...
handling ValueError: (ValueError(654),)
  + Exception Group Traceback (most recent call last):
  |   File "<stdin>", line 2, in <module>
  | ExceptionGroup: twice (1 sub-exception)
  +-+---------------- 1 ----------------
    | TypeError: int
    +------------------------------------

Вы обрабатываете ValueError, но TypeError не трогаете. Это отражено в трассировке, где группа исключений twice теперь имеет только одно под-исключение.

Группы исключений и синтаксис except* не заменят обычные исключения и простые except. На самом деле, у вас, вероятно, не будет много случаев использования для создания групп исключений самостоятельно. Вместо этого они в основном будут создаваться библиотеками типа asyncio.

Можно ловить обычные исключения с помощью except*. Тем не менее, в большинстве случаев лучше придерживаться обычного except и использовать except* только для кода, который действительно может вызвать группу исключений.

Чтобы узнать больше о том, как работают группы исключений, как они могут быть вложенными, и о всех возможностях except*, смотрите Python 3.11 Preview: Группы задач и исключений. Ирит Катриэль, один из основных разработчиков Python, представил группы исключений на Python Language Summit в 2021 году и на PyCon UK в 2022 году.

Вы можете прочитать больше о мотивации групп исключений и обсуждениях, которые привели к текущей реализации в PEP 654.

Заметки об исключениях

Одним из расширений обычных исключений является возможность добавления произвольных примечаний. PEP 678 описывает, как эти заметки могут быть использованы для добавления информации к исключению в другой части кода, чем та, которая вызвала исключение изначально. Например, библиотека тестирования, такая как Hypothesis, может добавить информацию о том, какой тест не прошел.

Вы можете добавить примечание к любому исключению с помощью .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']

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

Если возникла ошибка, то все связанные с ней заметки выводятся в нижней части трассировки.

В следующем примере вы оборачиваете свой основной цикл в блок try ... except, который добавляет временную метку к ошибке. Это может быть полезно, если вам нужно сравнить сообщение об ошибке с запущенным log для вашей программы:

# timestamped_errors.py

from datetime import datetime

def main():
    inverse(0)

def inverse(number):
    return 1 / number

if __name__ == "__main__":
    try:
        main()
    except Exception as err:
        err.add_note(f"Raised at {datetime.now()}")
        raise

Как вы видели ранее, эта программа вычисляет мультипликативную обратную величину. Здесь вы добавили короткую функцию main(), которую вы позже вызовете.

Вы обернули вызов main() в блок try...except, который ловит любые Exception. Хотя обычно вы хотите быть более конкретными, вы используете Exception здесь, чтобы эффективно добавить контекст к любому исключению, которое ваша основная программа случайно поднимет.

Когда вы запустите этот код, вы увидите ожидаемое ZeroDivisionError. Кроме того, отслеживание содержит временную метку, которая может помочь вам в отладке:

$ python timestamped_errors.py
Traceback (most recent call last):
  ...
ZeroDivisionError: division by zero
Raised at 2022-10-24 12:18:13.913838

Вы можете использовать тот же шаблон для добавления другой полезной информации к исключениям. Смотрите это Python 3.11 preview и PEP 678 для получения дополнительной информации.

Форматирование отрицательного нуля

Одним из странных понятий, с которым вы можете столкнуться при выполнении вычислений с числами с плавающей точкой, является отрицательный ноль. Вы можете заметить, что отрицательный ноль и обычный ноль отображаются по-разному в вашем REPL:

>>>

>>> -0.0
-0.0
>>> 0.0
0.0

Обычно существует только один ноль, и он не является ни положительным, ни отрицательным. Однако, представление чисел с плавающей точкой проще, если допустить знаковые нули. Внутренне числа представляются со знаком и величиной как отдельные величины. Проще представлять ноль так же, как и любое другое число, с положительным или отрицательным знаком.

Python знает, что оба представления равны:

>>>

>>> -0.0 == 0.0
True

В целом, вам не нужно беспокоиться об отрицательных нулях в своих вычислениях. Тем не менее, вы можете получить неожиданные результаты при представлении данных с небольшими отрицательными числами, которые округляются:

>>>

>>> small = -0.00311
>>> f"A small number: {small:.2f}"
'A small number: -0.00'

Обычно, когда число округляется до нуля, оно представляется как беззнаковый ноль. В этом примере маленькое отрицательное число округляется до двух знаков после запятой при представлении в виде f-строки. Обратите внимание, что перед нулем показан отрицательный знак.

PEP 682 вводит небольшое расширение мини-языка format, используемого f-строками и str.format(). В Python 3.11 к строке формата можно добавить литерал z. Это заставит любые нули нормализоваться до положительного нуля перед форматированием:

>>>

>>> small = -0.00311
>>> f"A small number: {small:z.2f}"
'A small number: 0.00'

Вы добавили z к строке формата: z.2f. Это гарантирует, что отрицательные нули не просочатся в пользовательские представления ваших данных.

Мертвые батарейки

Одной из сильных сторон Python в его ранние дни было то, что он поставлялся с включенными батарейками. Эта несколько мифическая фраза используется для указания на то, что многие функциональные возможности включены в сам язык программирования. Например, Python был одним из самых ранних языков, включавших высокоуровневую поддержку таких контейнеров, как списки, кортежи и словари

Однако настоящие батареи доступны в стандартной библиотеке Python. Это набор пакетов, которые входят в каждую установку Python. Например, стандартная библиотека включает функциональность для:

В общей сложности стандартная библиотека состоит из нескольких сотен модулей:

>>>

>>> import sys
>>> len(sys.stdlib_module_names)
305

Вы можете увидеть, какие модули доступны в стандартной библиотеке, посмотрев sys.stdlib_module_names. Наличие такого количества возможностей, встроенных в язык, было благом для Python в первые дни.

Со временем полезность стандартной библиотеки уменьшилась, прежде всего потому, что распространение и установка сторонних модулей стали намного удобнее. Многие из самых популярных функций Python теперь живут за пределами основного дистрибутива. Библиотеки для работы с данными, такие как NumPy и pandas, инструменты визуализации Matplotlib и Bokeh, и веб-фреймворки Django и Flask разрабатываются независимо.

PEP 594 описывает инициативу по удалению севших батарей из стандартной библиотеки. Идея заключается в том, что модули, которые больше не актуальны, должны быть удалены из стандартной библиотеки. Это поможет сопровождающим Python направить свои усилия туда, где они наиболее необходимы и принесут наибольшую пользу. Кроме того, более компактная стандартная библиотека делает Python более подходящим для альтернативных платформ, таких как микроконтроллеры или браузеры.

В этом выпуске Python ни один модуль не удаляется из стандартной библиотеки. Вместо этого, несколько малоиспользуемых модулей помечены для удаления в Python 3.13. Данные модули начнут выдавать предупреждение в Python 3.11:

>>>

>>> import imghdr
<stdin>:1: DeprecationWarning: 'imghdr' is deprecated and slated for
           removal in Python 3.13

Если ваш код начинает выдавать подобные предупреждения, то вам следует задуматься о переписывании кода. В большинстве случаев можно найти более современную альтернативу. Например, если вы сейчас используете imghdr, то вы можете переписать свой код, чтобы вместо него использовать python-magic. Здесь вы определяете тип файла:

>>>

>>> import imghdr
<stdin>:1: DeprecationWarning: 'imghdr' is deprecated and slated for
           removal in Python 3.13
>>> imghdr.what("python-311.jpg")
'jpeg'

>>> import magic
>>> magic.from_file("python-311.jpg")
'JPEG image data, JFIF standard 1.02, precision 8, 1920x1080, components 3'

И старая, устаревшая imghdr, и сторонняя библиотека python-magic распознают, что python-311.jpg представляет файл изображения JPEG.

Примечание: Пакет python-magic полагается на библиотеку C, которую необходимо установить. Обратитесь к документации для получения подробной информации о том, как установить ее в вашей операционной системе.

Вы можете найти список всех устаревших модулей в мертвых батареях PEP.

Итак, стоит ли переходить на Python 3.11?

На этом мы завершаем обзор самых интересных улучшений и новых возможностей в Python 3.11. Важный вопрос - стоит ли вам переходить на новую версию Python? И если да, то когда лучше всего переходить?

Как обычно в таких вопросах, ответом будет громкий и ясный "зависит"!

Самым большим выигрышем в Python 3.11 являются улучшения в работе разработчика: улучшенные сообщения об ошибках и более быстрое выполнение кода. Это отличные стимулы для скорейшего обновления среды, которую вы используете для локальной разработки. Это также вид обновления, который несет наименьший риск, поскольку любые ошибки, с которыми вы столкнетесь, будут иметь ограниченный эффект.

Увеличение скорости также является хорошим аргументом для обновления вашей производственной среды. Однако, как всегда, следует быть осторожным при обновлении окружения, где ошибки и недочеты могут иметь серьезные последствия. Убедитесь, что вы провели надлежащее тестирование, прежде чем запускать переключатель. В рамках проекта Faster CPython внутренние изменения в новой версии были более значительными и масштабными, чем обычно. Пабло Галиндо Сальгадо, менеджер выпуска, рассказывает о том, как эти изменения повлияли на процесс выпуска в подкасте the Real Python.

Одна из распространенных проблем с новыми релизами заключается в том, что некоторые сторонние пакеты, от которых вы зависите, могут быть не готовы к новому релизу в первый же день. Для Python 3.11 такие крупные пакеты, как NumPy и SciPy, начали упаковывать колеса для 3.11 задолго до релиза. Надеюсь, в этот раз вам не придется ждать, пока ваши зависимости будут готовы к обновлению

Еще один аспект обновления - когда следует начинать использовать преимущества нового синтаксиса. Если вы поддерживаете библиотеку , которая поддерживает старые версии Python, то вы не можете использовать TaskGroup() или синтаксис типа except* в своем коде. Тем не менее, ваша библиотека будет работать быстрее для тех, кто использует Python 3.11.

Если вместо этого вы создаете приложение, где вы контролируете среду, в которой оно работает, то вы сможете использовать новые возможности, как только обновите среду.

Заключение

Новый выпуск Python - это всегда повод для праздника и признания всех усилий, вложенных в язык добровольцами со всего мира.

В этом руководстве вы увидели новые возможности и улучшения, такие как:

  • Улучшенные сообщения об ошибках с более информативными отсылками
  • Более быстрое выполнение кода благодаря значительным усилиям в проекте Faster CPython
  • Группы задач и исключений, которые упрощают работу с асинхронным кодом
  • Несколько новых возможностей типизации, улучшающих поддержку статической типизации в Python
  • Родная поддержка TOML для работы с конфигурационными файлами

Возможно, вы не сможете воспользоваться всеми возможностями сразу. Тем не менее, вы должны стремиться тестировать свой код на Python 3.11, чтобы убедиться, что ваш код готов к будущему. Заметили ли вы увеличение скорости? Поделитесь своим опытом в комментариях ниже.

Вернуться на верх