Зачем нужны аннотации типов
Перейти к содержимому

Зачем нужны аннотации типов

Введение в аннотации типов Python


Автор иллюстрации — Magdalena Tomczyk

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

Сохраняя идею динамической утиной типизации в современных версиях Python (3.6+) поддерживает аннотации типов переменных, полей класса, аргументов и возвращаемых значений функций:

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

Меня зовут Тихонов Андрей и я занимаюсь backend-разработкой в Lamoda.

В этой статье я хочу объяснить основы использования аннотаций типов и рассмотреть типичные примеры, реализуемые аннотациями из пакета typing .

Инструменты, поддерживающие аннотации

Аннотации типов поддерживаются многими IDE для Python, которые выделяют некорректный код или выдают подсказки в процессе набора текста.

Например, так это выглядит в Pycharm:

Так же аннотации типов обрабатываются и консольными линтерами.

Вот вывод pylint:

А вот для того же файла что нашел mypy:

Поведение разных анализаторов может отличаться. Например, mypy и pycharm по разному обрабатывают смену типа переменной. Далее в примерах я буду ориентироваться на вывод mypy.

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

Основы

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

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

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

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

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

Кстати, при использовании dataclass типы полей необходимо указывать именно в классе. Подробнее про dataclass

Встроенные типы

Хоть вы и можете использовать стандартные типы в качестве аннотаций, много полезного сокрыто в модуле typing .

Optional

Если вы пометите переменную типом int и попытаетесь присвоить ей None , будет ошибка:

Incompatible types in assignment (expression has type «None», variable has type «int»)

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

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

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

Union

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

Кстати, аннотация Optional[T] эквивалентна Union[T, None] , хотя такая запись и не рекомендуется.

Коллекции

Механизм аннотаций типов поддерживает механизм дженериков (Generics, подробнее во второй части статьи), которые позволяют специфицировать для контейнеров типы элементов, хранящихся в них.

Списки

Для того, чтобы указать, что переменная содержит список можно использовать тип list в качестве аннотации. Однако если хочется конкретизировать, какие элементы содержит список, он такая аннотация уже не подойдёт. Для этого есть typing.List . Аналогично тому, как мы указывали тип опциональной переменной, мы указываем тип элементов списка в квадратных скобках.

Предполагается, что список содержит неопределенное количество однотипных элементов. Но при этом нет ограничений на аннотацию элемента: можно использовать Any , Optional , List и другие. Если тип элемента не указан, предполагается, что это Any .

Кроме списка аналогичные аннотации есть для множеств: typing.Set и typing.FrozenSet .

Кортежи

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

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

Аннотация Tuple без указания типов элементов работает аналогично Tuple[Any, . ]

Словари

Для словарей используется typing.Dict . Отдельно аннотируется тип ключа и тип значений:

Аналогично используются typing.DefaultDict и typing.OrderedDict

Результат выполнения функции

Для указания типа результата функции можно использовать любую аннотацию. Но есть несколько особенных случаев.

Если функция ничего не возвращает (например, как print ), её результат всегда равен None . Для аннотации так же используем None .

Корректными вариантами завершения такой функции будут: явный возврат None , возврат без указания значения и завершение без вызова return .

Если же функция никогда не возвращает управление (например, как sys.exit ), следует использовать аннотацию NoReturn :

Если это генераторная функция, то есть её тело содержит оператор yield , для возвращаемого можно воспользоватьтся аннотацией Iterable[T] , либо Generator[YT, ST, RT] :

Вместо заключения

Для многих ситуаций в модуле typing есть подходящие типы, однако я не буду рассматривать все, так как поведение аналогично рассмотренным.
Например, есть Iterator как generic-версия для collections.abc.Iterator , typing.SupportsInt для того, чтобы указать что объект поддерживает метод __int__ , или Callable для функций и объектов, поддерживающих метод __call__

Так же стандарт определяет формат аннотаций в виде комментариев и stub-файлы, которые содержат информацию только для статических анализаторов.

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

Аннотации типов в Python

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

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

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

По данным исследований StackOverflow за 2020, 2019, 2018, 2017, 2016, 2015 (там же можно посмотреть результаты за 2014 и 2013) годы, Python с каждым годом растёт в популярности.

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

Кроме того, даже если код без информации о типах может быть и вполне понятен человеку, например, благодаря удачно выбранным именам, то для автоматики — это в любом случае абсолютно непроницаемый черный непрозрачный ящик. В такой ситуации очень сложно, не выполняя код (мы же говорим про статический анализ), понять как он будет вести себя в ран-тайме. Аннотации типов позволяют IDE, линтерам и тайп-чекерам лучше понимать код программы, что дает возможность рано отлавливать достаточно хитрые ошибки. В конечном итоге это делает написанные программы надежнее.

По этим соображениям, в Python 3.5 появился специальный синтаксис для объявления типов параметров функций и их возвращаемых значений (PEP 484). В Python 3.6 эта возможность была расширена — стало можно объявлять типы переменных вообще в любом месте программы (PEP 526). С каждой новой версией языка эта функциональность улучшается, и писать аннотации типов становится всё проще, удобнее и естественнее, а экосистема вокруг типизированного Python развивается семимильными шагами.

Нужно отметить, что тайп-аннотации — это именно возможность, а не обязанность. У программиста есть выбор — добавлять информацию о типах или нет. Таким образом Python пытается усидеть на двух стульях — остаться языком с динамической типизацией и дать возможность для статического анализа написанных программ. Привнести в хаос немного порядка, так сказать. И, по-моему, у Python это неплохо получается.

Как это работает?

Программист при написании кода расставляет информацию о типах переменных, параметров и возвращаемых значений функций. Это никак не влияет на выполнение программы. Python сам по себе никак не использует эту информацию в ран-тайме, он лишь перекладывает её в специальные атрибуты функций или переменных, делая доступной для сторонних утилит. То есть, если указано, что функция принимает строки, то это никак не помешает вызвать её с целыми числами или списками — в зависимости от тела функции, она может отработать, а может завершиться ошибкой, но сама возможность вызова с любыми типами аргументов никак не ограничивается.

Зачем же тогда писать тайп-аннотации?

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

Первый пункт достаточно очевидный, а про второй мы поговорим чуть позже в разделе про тайп-чекеры.

Простые типы

Вот так, например, можно тайп-аннотировать простую функцию:

Типы параметров, принимаемых функцией, записываются после имени параметра через знак двоеточия, но перед значением по умолчанию, если оно присутствует. Возвращаемое значение функции записывается после знака “стрелки”.

Теперь читатель просто взглянув на сигнатуру функции может понять, что функция принимает строку и возвращает строку. Наверное, если передать в неё другой тип, то она не сможет корректно отработать.

Точно так же можно использовать для тайп-аннотаций и любые другие базовые (примитивные, не-контейнерные) типы в Python: int , float , bool , str , bytes , None и вообще практически что угодно. Чуть позже посмотрим, как типизировать контейнерные типы данных, такие как списки, кортежи, словари и множества.

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

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

Вот так можно аннотировать любые переменные в любом месте кода (Python 3.6+):

Если мы создадим свой класс, то его тоже можно использовать для аннотаций:

Контейнерные типы и дженерики

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

Это не слишком информативно, потому что кроме самого типа контейнера было бы ещё полезно знать, какие данные он в себе содержит. Что такое person_info ? Кортеж чего?

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

Начиная с Python 3.9 можно использовать стандартные классы в точно таких же целях, ничего ниоткуда не импортируя:

Согласитесь, так намного понятнее. Сразу видно, какой тип данных лежит внутри контейнера. Такие типы называются обобщёнными (generic types).

Кстати, в типизации можно яснее увидеть разницу между тем как должны использоваться списки и кортежи ( list vs. tuple ).

  • Списки содержат однородные данные — они все должны быть одного типа, иначе с таким списком будет тяжеловато работать.
  • Кортеж, напротив, может содержать разнородные данные, которые в зависимости от позиции могут иметь тот или иной тип.
  • Список нужно использовать, когда длина заранее неизвестна либо она переменна, например, список пользователей.
  • Кортеж нужно использовать, когда длина данных известна заранее и строго фиксирована, например, как в записи из таблицы в СУБД.

Получается, кортеж — это не просто неизменяемый брат-близнец списка.

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

Составные типы

Часто случаются ситуации, когда нужно объединить несколько типов, например, для того, чтобы указать, что функция может принимать и строки, и числа. Этого можно достичь при помощи дженерик-типа Union из модуля typing :

Также очень часто возникает ситуация, когда возможно либо значение определенного типа, либо None . Это настолько частая ситуация, что для этого даже сделали отдельный дженерик-тип Optional :

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

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

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

Проверка типов

Допустим, что тайп-аннотации написаны. Как начать получать от этого пользу?

В экосистеме Python есть несколько конкурирующих между собой тайп-чекеров, например, mypy , pyre , pytype , pyright . Самым популярным среди них является mypy , наверное, потому что одним из ключевых его разработчиков является сам Гвидо ван Россум. Давайте на mypy и остановимся.

Установим mypy в проект. Внутри виртуального окружения проекта нужно выполнить:

Для pipenv и poetry соответственно вот так:

Давайте напишем самый тривиальный пример программы с ошибкой:

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

Давайте посмотрим, сможет ли тайп-чекер обнаружить эту проблему:

Отлично! Не исполняя программу, mypy смог понять, что в ней присутствует ошибка. Давайте запрячем эту же самую ошибку чуть глубже, используя функцию:

Проверим типы в этой программе:

Тайп-чекер пропустил правильный вызов функции, но обнаружил вызов функции с ошибкой.

Заключение

Тайп-аннотации — это настолько круто и удобно, что, честно говоря, я уже плохо представляю, как раньше (до Python 3.5) без этого люди вообще программировали. Для меня это самый веский аргумент в пользу Python 3 и против Python 2. Это незаменимый инструмент при разработке насколько-нибудь крупной программы.

Обязательно нужно интегрировать тайп-чекинг в свой редактор/IDE, чтобы ошибки подсвечивались ещё на этапе написания кода. Можно интегрировать тайп-чекинг в Git-хуки и CI.

На странице «Awesome Python Typing» можно найти ещё много полезных инструментов, которые пользуются тайп-аннотациями.

Если понравилась статья, то подпишитесь на уведомления о новых постах в блоге, чтобы ничего не пропустить!

Python. Урок 18. Аннотация типов в Python

Этот урок посвящен аннотациям типов в Python. Рассмотрен вопрос контроля типов переменных и функций с использованием комментариев и аннотаций. Приведено описание PEP‘ов, регламентирующих работу с аннотациями, и представлены примеры работы с инструментом mypy для анализа Python кода.

Зачем нужны аннотации?

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

Согласно PEP 3107 могут быть следующие варианты использования аннотаций:

  • проверка типов;
  • расширение функционала IDE в части предоставления информации об ожидаемых типах аргументов и типе возвращаемого значения у функций;
  • перегрузка функций и работа с дженериками;
  • взаимодействие с другими языками;
  • использование в предикатных логических функциях;
  • маппинг запросов в базах данных;
  • маршалинг параметров в RPC (удаленный вызов процедур).

Контроль типов в Python

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

Выглядит это так:

Мы создали переменную с именем name и предполагаем, что ее тип – str . Естественно, для самого интерпретатора Python это не имеет значения, мы, без труда можем продолжить нашу мини программу таким образом.

И это будет корректно с точки зрения Python . Но если необходимо проконтролировать, что переменной name будут присваиваться значения только строкового типа, мы должны: во-первых указать в комментарии о нашем намерение – это мы сделали, во-вторых использовать специальный инструмент, который выполнит соответствующую проверку. Таким инструментом является mypy .

Установить его можно с помощью pip :

Если мы сохраним приведенный выше код в файле type_tester.py и выполним следующую команду:

Получим такое сообщение:

test_type.py:3: error: Incompatible types in assignment (expression has type “int”, variable has type “str”)

Оно говорит о том, что обнаружено несоответствие типов в операции присваивание: переменная имеет тип “ str “, а ей присвоено значение типа “ int “.

Обзор PEP’ов регламентирующий работу с аннотациями

Начнем наше введение в тему аннотаций в Python с краткого обзора четырех ключевых документов:

Первый из них PEP 3107 — Function Annotations , является исторически первым из перечисленных выше документов. В нем описывается синтаксис использования аннотаций в функциях Python . Важным является то, что аннотации не имеют никакого семантического значения для интерпретатора Python и предназначены только для анализа сторонними приложениями. Аннотировать можно аргументы функции и возвращаемое ей значение.

Следующий документ – PEP 484 — Type Hints . В нем представлены рекомендации по использованию аннотаций типов. Аннотация типов упрощает статический анализ кода, рефакторинг, контроль типов в рантайме и кодогенерацию, использующую информацию о типах. В рамках данного документа, определены следующие варианты работы с аннотациями: использование аннотаций в функциях согласно PEP 3107, аннотация типов переменных через комментарии в формате # type: type_name и использование stub -файлов (см. разделы Использование аннотаций в функциях и Аннотация переменных).

В PEP 526 — Syntax for Variable Annotations приводится описание синтаксиса для аннотации типов переменных (базируется на PEP 484), использующего языковые конструкции, встроенные в Python (см. раздел Аннотация переменных).

PEP 563 — Postponed Evaluation of Annotations . Данный PEP вступил в силу с выходом Python 3.7. У подхода работы с аннотация до этого PEP’а был ряд проблем связанных с тем, что определение типов переменных (в функциях, классах и т.п.) происходит во время импорта модуля, и может сложится такая ситуация, что тип переменной объявлен, но информации об этом типе ещё нет, в таком случае тип указывают в виде строки – в кавычках. В PEP 563 предлагается использовать отложенную обработку аннотаций, это позволяет определять переменные до получения информации об их типах и ускоряет выполнение программы, т.к. при загрузке модулей не будет тратится время на проверку типов – это будет сделано перед работой с переменными.

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

Использование аннотаций в функциях

Указание типов аргументов и возвращаемого значения

В функциях мы можем аннотировать аргументы и возвращаемое значение. Выглядеть это может так.

Аннотация для аргумента определяется через двоеточие после его имени.

Аннотация, определяющая тип возвращаемого функцией значения, указывается после ее имени с использованием символов ->

Для лямбд аннотации не поддерживаются.

Доступ к аннотациям функции

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

Содержимое repeater. __annotations__

Аннотация переменных

Создание аннотированных переменных

Можно использовать один из трех способов создания аннотированных переменных.

Рассмотрим это на примере работы со строковой переменной с именем name.

Приведем еще несколько примеров.

Контроль типов с использованием аннотаций

Для проверки можно использовать уже знакомый нам инструмент mypy . Напишем вот такой код.

Сохраним его в файле с именем work.py и запустим mypy для анализа.

Если не указывать дополнительные ключи, то окно консоли будет чистым, т.к. mypy не найдет никаких ошибок в вашем коде.

Но если заменить первую строку

и вновь запустить mypy , то увидим вот такое сообщение:

work.py:7: error: Argument 1 to “sq_sum” has incompatible type “float”; expected “int”

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

Отложенная проверка аннотаций

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

То возникнет ошибка NameError: name ‘Color’ is not defined. Она связана с тем, что переменная color имеет тип Color, который пока ещё не объявлен.

В таком случае, мы можем указать тип Color в кавычках, но это не очень удобно.

Эту проблему можно решить воспользовавшись отложенной обработкой аннотаций из Python 3.7.

Можете проверить, что без строки from __future__ import annotations эта программа выполняться не будет.

P.S.

Если вам интересна тема анализа данных, то мы рекомендуем ознакомиться с библиотекой Pandas. На нашем сайте вы можете найти вводные уроки по этой теме. Все уроки по библиотеке Pandas собраны в книге “Pandas. Работа с данными”.
Книга: Pandas. Работа с данными

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *