Предыстория

PEP 3107

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

Согласно ему аннотации функций:

  • совершенно необязательны к использованию;

  • способ связать произвольные выражения Python с различными частями функции во время ее определения интерпретатором.

Для Python аннотации не представляют никакого интереса и никак им не используются. Он просто делает аннотации доступными для сторонних инструментов.

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

def func(arg: "arg description"):
    pass

Или для обеспечения проверки типов аргументов и возвращаемого значения функций:

def func(arg: int) -> bool:
    pass

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

Доступ к аннотациям можно получить с помощью специального словаря __annotations__:

def func(arg: "arg description"):
    pass

print("Annotations: ", func.__annotations__)

Запустим скрипт:

$ python ./example.py

Annotations: {"arg": "arg description"}

PEP 484 - Подсказки типов

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

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

Так в Python 3.5 был представлен PEP 484, который включал в себя описание синтаксиса и способов использования подсказок типов. А также описание ситуаций, в которых подсказки типов не должны быть использованы или необходимо использовать специальный синтаксис их определения.

Описанное в PEP 484 было реализовано в модуле typing, который был добавлен в стандартную библиотеку.

В основном, команда разработки видела использование подсказок типов для статического анализа кода (статический анализатор кода mypy послужил вдохновением на написание PEP 484), хотя подсказки типов были доступны и в период выполнения программы, через атрибут __annotations__. Но проверка типов в период выполнения программы серьезно не рассматривалась.

Также стоит отметить предварительный статус этого PEP. Это означает, что все, в будущем добавленные изменения в модуль typing, должны быть добавлены и в предыдущие версии языка Python. К примеру, в Python 3.6 валидной стала следующая конструкция Dict[str, Tuple[S, T]], хотя в Python 3.5 это было не так. Это и прочие улучшения добавленные с выходом Python 3.6, так же были обратно-портированы в Python 3.5.

Опережающие ссылки

Подсказка типа указывающая на объект, который еще не был определен, называется опережающей ссылкой (forward reference).

class Tree:
    def __init__(self, height: int, children: List[Tree]):
        self.height = height
        self.children = children

Запуск приведенного выше кода, закончится неудачей. Подсказки типов оцениваются в момент определения функций, методов или модулей. Так как Tree, на момент оценки подсказок типов еще не определен, при их оценке интерпретатор возбудит исключение NameError с ошибкой name 'Tree' is not defined.

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

class Tree:
    def __init__(self, height: int, children: List['Tree']):
        self.height = height
        self.children = children

Позже, при статическом анализе кода, инструменты должны были воспользоваться eval для преобразования литералов строки в объект Python.

В связи с этим, на подсказки типов определенные таким образом, накладывались следующие требования:

  • строковый литерал должен содержать синтаксически правильное выражение Python;

  • он должен оцениваться интерпретатором без ошибок, как только модуль будет полностью загружен;

  • локальное и глобальное пространство имен, в которых происходит их оценка, должны совпадать.

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

PEP 563 - Отложенная оценка аннотаций

PEP 563 решал следующие проблемы:

  • проблему опережающих ссылок (странный синтаксис их определения);

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

Теперь разработчик мог определить подсказки типов следующим образом:

class Tree:
    def __init__(self, height: int, children: List[Tree]):
        self.height = height
        self.children = children

Достигалось это за счет изменения способа оценки подсказок типов, после введения данного PEP все подсказки типов представлялись как литералы строк. То есть, для интерпретатора приведенный выше код выглядел бы так:

class Tree:
    def __init__(self, height: "int", children: "List[Tree]"):
        self.height = height
        self.children = children

Такие подсказки типов никак не оценивались, а лишь записывались в __annotations__, что снижало затраты вычислительных ресурсов.

Статические анализаторы кода, не должны были испытывать особых проблем при анализе таких подсказок типа, так как уже умели их оценивать.

Для оценки подсказок типа в период выполнения, пользовательский код должен выполнить их преобразование из строки в объект Python, при помощи новой функции typing.get_type_hints.

Такой способ оценки аннотаций накладывал на них следующие требования:

  • строковый литерал должен содержать синтаксически правильное выражение Python;

  • для оценки должны быть использованы аннотации только с именами, представленными в пространстве имен модуля, поскольку отложенная оценка локальных пространств имен не надежна.

Данный PEP был добавлен вместе Python 3.7, однако его необходимо активировать вручную, путем импорта from __future__ import annotations. Описанный способ обработки аннотаций должен был стать способом по умолчанию с выходом Python 3.10.

Скрытая угроза

В то время, когда велось обсуждение PEP 563, подсказки типов в основном рассматривались в контексте статического анализа кода. Использование их в период выполнения всерьез не рассматривалось и особо не обсуждалось. Как было сказано выше, разработчики оставили возможность конвертации строк обратно в объекты Python, при помощи typing.get_type_hints.

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

Самый распространенный пример касается не глобального пространства имен, в котором генерируются типы (например, внутренние классы, классы внутри функций и т.д.). Но один из ярких примеров опрежающих ссылок: классы с методами, принимающими или возвращающими объекты своего собственного типа, также не обрабатываются должным образом с помощью typing.get_type_hints.

В преддверии "заморозки" Python 3.10 автор Pydantic создал issue на Github, где заявил, что Pydantic возможно никогда не сможет адаптировать новый способ оценки аннотаций для использования их в период выполнения программы. Также была другая проблема, так как отложенная оценка аннотаций станет способом по умолчанию, без возможности перехода на старый способ, это сломает большую часть Pydantic и нарушит работу FastAPI.

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

В течение нескольких дней после публикации issue, Управляющий совет Python связался с авторами библиотек Pydantic и FastAPI для обсуждения сложившейся ситуации. Как оказалось основные разработчики даже не слышали про эти библиотеки и были удивлены, что кто-то использует аннотации в период выполнения программы.

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

PEP 649 - Отложенная оценка аннотаций с использованием дескрипторов

В Python 3.9 доступно два способа оценки аннотаций типов:

  • изначальный способ, оценивающий аннотации во время определения функции (PEP 484);

  • отложенная оценка аннотаций (PEP 563).

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

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

В новом подходе, код для создания словаря аннотаций, записывается в функцию. При этом, __annotations__ является обычным дескриптором данных, который вызывает функцию вычисления словаря аннотаций и сохраняет результат ее выполнения. Такая реализация откладывает оценку аннотаций до обращения к дескриптору __annotations__, к этому моменту все проблемы, связанные с циклическими ссылками, будут уже решены. И если к __annotations__ не будет вообще обращений, то и код для оценки аннотаций не будет исполнен, что снижает вычислительные затраты.

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

Если предложенный способ будет принят, то он полностью заменит семантику PEP 563.

Рассмотрим следующий пример:

def foo(x: int = 3, y: MyType = None) -> float:
    ...

class MyType:
    ...
foo_y_type = foo.__annotations__['y']

Аннотации доступны в период выполнения программы через атрибут __annotations__ функции, класса или модуля. Когда аннотации определены для одного из этих объектов, __annotations__ это отображение имен аргументов на значение определенные в аннотациях к этим аргументам.

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

annotations = {'x': int, 'y': MyType, 'return': float}

def foo(x = 3, y = "abc"):
    ...
foo.__annotations__ = annotations
class MyType:
    ...
foo_y_type = foo.__annotations__['y']

Этот код не выполнятся, так как содержит опережающие ссылки. Код, использующий отложенную оценку аннотаций, предложенную в PEP 563, приведен ниже:

annotations = {'x': 'int', 'y': 'MyType', 'return': 'float'}

def foo(x = 3, y = "abc"):
    ...
foo.__annotations__ = annotations
class MyType:
    ...
foo_y_type = foo.__annotations__['y']

Проблемы, связанные с такой оценкой, описаны выше. Пример оценки аннотаций новым способом приведен ниже:

class function:
    # атрибут __annotations__ функции это уже "дескриптор
    # данных", просто мы изменяем его поведение.
    @property
    def __annotations__(self):
        return self.__co_annotations__()

# ...
def foo_annotations_fn():
    return {'x': int, 'y': MyType, 'return': float}
def foo(x = 3, y = "abc"):
    ...
foo.co_annotations = foo_annotations_fn
class MyType:
   ...
foo_y_type = foo.__annotations__['y']

Важным изменением является то, что код, создающий словарь аннотаций, теперь находится в функции, называемой здесь foo_annotations_fn(). Но эта функция не вызывается, пока мы не запросим значение foo.__annotations__, и мы не делаем этого до тех пор, пока не будет определен MyType. Таким образом, этот код также выполняется успешно, и теперь foo_y_type имеет правильное значение - класс MyType - даже несмотря на то, что MyType не был определен до тех пор, пока не была определена аннотация.

Итоги

На текущий момент проблема опережающих ссылок остается до конца не решенной. Способ решения, подходящий для статического анализа кода, совершенно не подходит для использования в период выполнения. Сообщество разработчиков использующих Pydantic и FastAPI продолжает расти (FastAPI занимает третье место среди веб-фремворков Python) и разработчикам языка Python придется учитывать их мнение в вопросах развития аннотаций.

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