Python: Аннотации типов
Аннотации типов — это возможность указать типы параметров и возвращаемое значение у функции в Python. Это не является обязательным требованием языка, но может помочь программистам в дальнейшей разработке, улучшить читаемость кода и повысить его надежность.
Давайте рассмотрим простой пример функции без аннотаций типов:
def concat(first, second): return first + second
Эта функция конкатенирует две строки в одну. При этом с первого взгляда на код сложно понять, что происходит в нем: какие типы у аргументов, почему функция работает со строками, а не складывает, например, два числа.
Если в дальнейшем использовать эту функцию в коде, то может возникнуть необходимость проверять типы аргументов перед передачей их в функцию, что увеличивает объем кода и затрудняет его понимание.
Теперь давайте добавим аннотации типов к функции:
def concat(first: str, second: str) -> str: return first + second
Здесь мы указали, что аргументы first и second должны быть строкового типа ( str ). Возвращаемое значение тоже будет строковым. Когда мы будем использовать эту функцию в коде, нам будет проще понять, какие типы аргументов можно передавать и какой тип возвращаемого значения ожидается.
Аннотации типов также могут быть использованы для определения типов переменных внутри функции. Например:
def double(n: int) -> int: result: int = n * 2 return result
В этом примере мы определили тип переменной result как int , используя аннотацию типа.
Аннотации типов — это не строгая проверка типов в Python. Их использование не гарантирует, что функция будет вызвана с аргументами и возвращаемым значением указанных типов. Все таки Python остается динамически типизированным языком. В нем аннотации типов не влияют на возможность передачи аргументов различных типов или возвращения значений других типов. Тем не менее их использование упрощает чтение и понимание кода и помогает отслеживать ошибки.
Задание
Реализуйте функцию word_multiply() . Она должна принимать два параметра:
- Строку
- Число, которое обозначает, сколько раз нужно повторить строку
text = 'python' print(word_multiply(text, 2)) # => pythonpython print(word_multiply(text, 0)) # =>
Укажите аннотации типов при объявлении функции.
Упражнение не проходит проверку — что делать?
Если вы зашли в тупик, то самое время задать вопрос в «Обсуждениях». Как правильно задать вопрос:
- Обязательно приложите вывод тестов, без него практически невозможно понять что не так, даже если вы покажете свой код. Программисты плохо исполняют код в голове, но по полученной ошибке почти всегда понятно, куда смотреть.
В моей среде код работает, а здесь нет
Тесты устроены таким образом, что они проверяют решение разными способами и на разных данных. Часто решение работает с одними входными данными, но не работает с другими. Чтобы разобраться с этим моментом, изучите вкладку «Тесты» и внимательно посмотрите на вывод ошибок, в котором есть подсказки.
Мой код отличается от решения учителя
Это нормально , в программировании одну задачу можно выполнить множеством способов. Если ваш код прошел проверку, то он соответствует условиям задачи.
В редких случаях бывает, что решение подогнано под тесты, но это видно сразу.
Прочитал урок — ничего не понятно
Создавать обучающие материалы, понятные для всех без исключения, довольно сложно. Мы очень стараемся, но всегда есть что улучшать. Если вы встретили материал, который вам непонятен, опишите проблему в «Обсуждениях». Идеально, если вы сформулируете непонятные моменты в виде вопросов. Обычно нам нужно несколько дней для внесения правок.
Кстати, вы тоже можете участвовать в улучшении курсов: внизу есть ссылка на исходный код уроков, который можно править прямо из браузера.
Аннотации типов в 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 сам по себе никак не использует эту информацию в ран-тайме, он лишь перекладывает её в специальные атрибуты функций или переменных, делая доступной для сторонних утилит. То есть, если указано, что функция принимает строки, то это никак не помешает вызвать её с целыми числами или списками — в зависимости от тела функции, она может отработать, а может завершиться ошибкой, но сама возможность вызова с любыми типами аргументов никак не ограничивается.
Зачем же тогда писать тайп-аннотации?
- документация для разработчиков;
- сторонние утилиты и библиотеки могут использовать эту информацию по своему усмотрению, например, выполняя проверки типов или приводя данные к нужному типу.
Первый пункт достаточно очевидный, а про второй мы поговорим чуть позже в разделе про тайп-чекеры.
Простые типы
Вот так, например, можно тайп-аннотировать простую функцию:
def greeting(name: str = "world") -> str: return "Hello, " + name
Типы параметров, принимаемых функцией, записываются после имени параметра через знак двоеточия, но перед значением по умолчанию, если оно присутствует. Возвращаемое значение функции записывается после знака “стрелки”.
Теперь читатель просто взглянув на сигнатуру функции может понять, что функция принимает строку и возвращает строку. Наверное, если передать в неё другой тип, то она не сможет корректно отработать.
Точно так же можно использовать для тайп-аннотаций и любые другие базовые (примитивные, не-контейнерные) типы в Python: int , float , bool , str , bytes , None и вообще практически что угодно. Чуть позже посмотрим, как типизировать контейнерные типы данных, такие как списки, кортежи, словари и множества.
Вот так можно зааннотировать функцию, которая принимает два числа с плавающей точкой и возвращает число с плавающей точкой:
def body_mass_index(weight: float, height: float) -> float: return weight / height ** 2
А вот так функцию, которая принимает строку и булевый аргумент, но ничего не возвращает:
def print_hello(name: str, upper: bool = False) -> None: if upper: name = name.upper() print("Hello,", name)
Вот так можно аннотировать любые переменные в любом месте кода (Python 3.6+):
name: str = "Andrey" age: int = 25 is_sick_with_covid19: bool = False # надеюсь pi: float = 3.1415 # можно даже аннотировать переменные, не назначая им значения foo: str
Если мы создадим свой класс, то его тоже можно использовать для аннотаций:
class Example: pass # довольно бессмысленно, но для примера пойдет example_instance: Example = Example()
Контейнерные типы и дженерики
Перейдем к более сложным типам, таким как списки, кортежи, словари и множества. Можно аннотировать в лоб, используя сами имена классов:
primes: list person_info: tuple stock_prices: dict valid_answers: set
Это не слишком информативно, потому что кроме самого типа контейнера было бы ещё полезно знать, какие данные он в себе содержит. Что такое person_info ? Кортеж чего?
В Python до версии 3.9 для этого придётся использовать отдельные классы из модуля typing , потому что стандартные классы не представляют такой функциональности. Делается это при помощи квадратных скобок, как будто мы извлекаем что-то по индексу:
from typing import List, Tuple, Dict, Set # тип всех элементов списка primes: List[int] # тип каждого элемента кортежа person_info: Tuple[str, int, float, float] # тип ключей, тип значений stock_prices: Dict[str, float] # тип всех элементов множества valid_answers: Set[str]
Начиная с Python 3.9 можно использовать стандартные классы в точно таких же целях, ничего ниоткуда не импортируя:
# будет работать только начиная с Python 3.9! # тип всех элементов списка primes: list[int] # тип каждого элемента кортежа person_info: tuple[str, int, float, float] # тип ключей, тип значений stock_prices: dict[str, float] # тип всех элементов множества valid_answers: set[str]
Согласитесь, так намного понятнее. Сразу видно, какой тип данных лежит внутри контейнера. Такие типы называются обобщёнными (generic types).
Кстати, в типизации можно яснее увидеть разницу между тем как должны использоваться списки и кортежи ( list vs. tuple ).
- Списки содержат однородные данные — они все должны быть одного типа, иначе с таким списком будет тяжеловато работать.
- Кортеж, напротив, может содержать разнородные данные, которые в зависимости от позиции могут иметь тот или иной тип.
- Список нужно использовать, когда длина заранее неизвестна либо она переменна, например, список пользователей.
- Кортеж нужно использовать, когда длина данных известна заранее и строго фиксирована, например, как в записи из таблицы в СУБД.
Получается, кортеж — это не просто неизменяемый брат-близнец списка.
Если сильно хочется использовать кортеж как просто неизменяемую последовательность однородных данных, то можно зааннотировать его вот так, используя . (это специальный объект Ellipsis , записывается как многоточие, при чтении исходников вслух в этом месте нужно делать драматическую паузу):
# кортеж из строк, длина неизвестна months: Tuple[str, . ]
Составные типы
Часто случаются ситуации, когда нужно объединить несколько типов, например, для того, чтобы указать, что функция может принимать и строки, и числа. Этого можно достичь при помощи дженерик-типа Union из модуля typing :
from typing import Union def add_or_concatenate(a: Union[str, int], b: Union[str, int]): return a + b
Также очень часто возникает ситуация, когда возможно либо значение определенного типа, либо None . Это настолько частая ситуация, что для этого даже сделали отдельный дженерик-тип Optional :
from typing import Optional, Union # по сути это одно и то же, но первый вариант проще читается phone: Optional[str] phone: Union[str, None]
Также может возникнуть ситуация, когда не получается указать какой-либо конкретный тип, потому что, например, функция может принимать на вход абсолютно что угодно. Для этих случаев тоже есть специальный объект typing.Any :
from typing import Any def func(arg: Any) -> Any: return arg
Можно считать, что Any неявно подставляется везде, где не указан более конкретный тип. Очень соблазнительно везде вставлять этот тип, но настоятельно рекомендую использовать его только в крайних случаях, потому что чрезмерное его использование сводит пользу от типизации на нет.
Вообще советую заглянуть в документацию модуля typing , там есть много интересных классов на все случаи жизни.
Проверка типов
Допустим, что тайп-аннотации написаны. Как начать получать от этого пользу?
В экосистеме Python есть несколько конкурирующих между собой тайп-чекеров, например, mypy , pyre , pytype , pyright . Самым популярным среди них является mypy , наверное, потому что одним из ключевых его разработчиков является сам Гвидо ван Россум. Давайте на mypy и остановимся.
Установим mypy в проект. Внутри виртуального окружения проекта нужно выполнить:
$ pip install mypy
Для pipenv и poetry соответственно вот так:
$ pipenv install --dev mypy $ poetry add --dev mypy
Давайте напишем самый тривиальный пример программы с ошибкой:
print("qwerty" + 1)
При выполнении, очевидно, программа завершится ошибкой:
$ python example.py Traceback (most recent call last): File "example.py", line 1, in print("qwerty" + 1) TypeError: can only concatenate str (not "int") to str
Давайте посмотрим, сможет ли тайп-чекер обнаружить эту проблему:
$ mypy example.py example.py:1: error: Unsupported operand types for + ("str" and "int") Found 1 error in 1 file (checked 1 source file)
Отлично! Не исполняя программу, mypy смог понять, что в ней присутствует ошибка. Давайте запрячем эту же самую ошибку чуть глубже, используя функцию:
def greet(name: str) -> None: print("Hello, " + name) # правильный вызов greet("world!") # а вот тут будет ошибка greet(5)
Проверим типы в этой программе:
$ mypy example2.py example2.py:9: error: Argument 1 to "greet" has incompatible type "int"; expected "str" Found 1 error in 1 file (checked 1 source file)
Тайп-чекер пропустил правильный вызов функции, но обнаружил вызов функции с ошибкой.
Заключение
Тайп-аннотации — это настолько круто и удобно, что, честно говоря, я уже плохо представляю, как раньше (до Python 3.5) без этого люди вообще программировали. Для меня это самый веский аргумент в пользу Python 3 и против Python 2. Это незаменимый инструмент при разработке насколько-нибудь крупной программы.
Обязательно нужно интегрировать тайп-чекинг в свой редактор/IDE, чтобы ошибки подсвечивались ещё на этапе написания кода. Можно интегрировать тайп-чекинг в Git-хуки и CI.
На странице «Awesome Python Typing» можно найти ещё много полезных инструментов, которые пользуются тайп-аннотациями.
Если понравилась статья, то подпишитесь на уведомления о новых постах в блоге, чтобы ничего не пропустить!
Дополнительное чтение:
- введение в аннотации типов часть 1 и часть 2 на Хабре;
- документация к модулю typing ;
- документация mypy ;
- репозиторий mypy на GitHub;
- сайт mypy ;
- сайт pyre ;
- репозиторий pytype на GitHub;
- репозиторий pyright на GitHub;
- Awesome Python Typing;
- ликбез по типизации в языках программирования на Хабре.
Введение в аннотации типов Python

Автор иллюстрации — Magdalena Tomczyk
Python — язык с динамической типизацией и позволяет нам довольно вольно оперировать переменными разных типов. Однако при написании кода мы так или иначе предполагаем переменные каких типов будут использоваться (это может быть вызвано ограничением алгоритма или бизнес логики). И для корректной работы программы нам важно как можно раньше найти ошибки, связанные с передачей данных неверного типа.
Сохраняя идею динамической утиной типизации в современных версиях Python (3.6+) поддерживает аннотации типов переменных, полей класса, аргументов и возвращаемых значений функций:
- PEP 3107 — Function Annotations
- PEP 484 — Type Hints
- PEP 526 — Syntax for Variable Annotations
- Пакет typing
Аннотации типов просто считываются интерпретатором Python и никак более не обрабатываются, но доступны для использования из стороннего кода и в первую очередь рассчитаны для использования статическими анализаторами.
Меня зовут Тихонов Андрей и я занимаюсь backend-разработкой в Lamoda.
В этой статье я хочу объяснить основы использования аннотаций типов и рассмотреть типичные примеры, реализуемые аннотациями из пакета typing .
Инструменты, поддерживающие аннотации
Аннотации типов поддерживаются многими IDE для Python, которые выделяют некорректный код или выдают подсказки в процессе набора текста.
Например, так это выглядит в Pycharm:


Так же аннотации типов обрабатываются и консольными линтерами.
Вот вывод pylint:
$ pylint example.py ************* Module example example.py:7:6: E1101: Instance of 'int' has no 'startswith' member (no-member)
А вот для того же файла что нашел mypy:
$ mypy example.py example.py:7: error: "int" has no attribute "startswith" example.py:10: error: Unsupported operand types for // ("str" and "int")
Поведение разных анализаторов может отличаться. Например, mypy и pycharm по разному обрабатывают смену типа переменной. Далее в примерах я буду ориентироваться на вывод mypy.
В некоторых примерах код при запуске может работать без исключений, но может содержать логические ошибки из-за использования переменных не того типа. А в некоторых примерах он может даже не выполняться.
Основы
В отличие от старых версий Python, аннотации типов пишутся не в комментариях или docstring, а непосредственно в коде. С одной стороны, это ломает обратную совместимость, с другой — явно означает что это часть кода и может обрабатываться соответственно
В простейшем случае аннотация содержит непосредственно ожидаемый тип. Более сложные кейсы будут рассмотрены ниже. Если в качестве аннотации указан базовый класс, допустимо передача экземпляров его наследников в качестве значений. Однако использовать можно только те возможности, что реализованы в базовом классе.
Аннотации для переменных пишут через двоеточие после идентификатора. После этого может идти инициализация значения. Например,
price: int = 5 title: str
Параметры функции аннотируются так же как переменные, а возвращаемое значение указывается после стрелки -> и до завершающего двоеточия. Например,
def indent_right(s: str, width: int) -> str: return " " * (max(0, width - len(s))) + s
Для полей класса аннотации должны быть указаны явно при определении класса. Однако анализаторы могут выводить автоматически их на основе __init__ метода, но в этом случае они не будут доступны во время выполнения программы. Подробнее про работу с аннотациями в рантайме во второй части статьи
class Book: title: str author: str def __init__(self, title: str, author: str) -> None: self.title = title self.author = author b: Book = Book(title='Fahrenheit 451', author='Bradbury')
Кстати, при использовании dataclass типы полей необходимо указывать именно в классе. Подробнее про dataclass
Встроенные типы
Хоть вы и можете использовать стандартные типы в качестве аннотаций, много полезного сокрыто в модуле typing .
Optional
Если вы пометите переменную типом int и попытаетесь присвоить ей None , будет ошибка:
Incompatible types in assignment (expression has type «None», variable has type «int»)
Для таких случаев предусмотрена в модуле typing аннотация Optional с указанием конкретного типа. Обратите внимание, тип опциональной переменной указывается в квадратных скобках
from typing import Optional amount: int amount = None # Incompatible types in assignment (expression has type "None", variable has type "int") price: Optional[int] price = None
Any
Иногда вы не хотите ограничивать возможные типы переменной. Например, если это действительно не важно, или если вы планируете сделать обработку разных типов самостоятельно. В этом случае, можно использовать аннотацию Any . На следующий код mypy не будет ругаться:
unknown_item: Any = 1 print(unknown_item) print(unknown_item.startswith("hello")) print(unknown_item // 0)
Может возникнуть вопрос, почему не использовать object ? Однако в этом случае предполагается, что хоть передан может быть любой объект, обращаться с ним можно только как с экземпляром object .
unknown_object: object print(unknown_object) print(unknown_object.startswith("hello")) # error: "object" has no attribute "startswith" print(unknown_object // 0) # error: Unsupported operand types for // ("object" and "int")
Union
Для случаев, когда необходимо допустить использование не любых типов, а только некоторых, можно использовать аннотацию typing.Union с указанием списка типов в квадратных скобках.
def hundreds(x: Union[int, float]) -> int: return (int(x) // 100) % 10 hundreds(100.0) hundreds(100) hundreds("100") # Argument 1 to "hundreds" has incompatible type "str"; expected "Union[int, float]"
Кстати, аннотация Optional[T] эквивалентна Union[T, None] , хотя такая запись и не рекомендуется.
Коллекции
Механизм аннотаций типов поддерживает механизм дженериков (Generics, подробнее во второй части статьи), которые позволяют специфицировать для контейнеров типы элементов, хранящихся в них.
Списки
Для того, чтобы указать, что переменная содержит список можно использовать тип list в качестве аннотации. Однако если хочется конкретизировать, какие элементы содержит список, он такая аннотация уже не подойдёт. Для этого есть typing.List . Аналогично тому, как мы указывали тип опциональной переменной, мы указываем тип элементов списка в квадратных скобках.
titles: List[str] = ["hello", "world"] titles.append(100500) # Argument 1 to "append" of "list" has incompatible type "int"; expected "str" titles = ["hello", 1] # List item 1 has incompatible type "int"; expected "str" items: List = ["hello", 1]
Предполагается, что список содержит неопределенное количество однотипных элементов. Но при этом нет ограничений на аннотацию элемента: можно использовать Any , Optional , List и другие. Если тип элемента не указан, предполагается, что это Any .
Кроме списка аналогичные аннотации есть для множеств: typing.Set и typing.FrozenSet .
Кортежи
Кортежи в отличие от списков часто используются для разнотипных элементов. Синтаксис похож с одним отличием: в квадратных скобках указывается тип каждого элемента кортежа по отдельности.
Если же планируется использовать кортеж аналогично списку: хранить неизвестное количество однотипных элементов, можно воспользоваться многоточием ( . ).
Аннотация Tuple без указания типов элементов работает аналогично Tuple[Any, . ]
price_container: Tuple[int] = (1,) price_container = ("hello") # Incompatible types in assignment (expression has type "str", variable has type "Tuple[int]") price_container = (1, 2) # Incompatible types in assignment (expression has type "Tuple[int, int]", variable has type "Tuple[int]") price_with_title: Tuple[int, str] = (1, "hello") prices: Tuple[int, . ] = (1, 2) prices = (1, ) prices = (1, "str") # Incompatible types in assignment (expression has type "Tuple[int, str]", variable has type "Tuple[int, . ]") something: Tuple = (1, 2, "hello")
Словари
Для словарей используется typing.Dict . Отдельно аннотируется тип ключа и тип значений:
book_authors: Dict[str, str] = book_authors["1984"] = 0 # Incompatible types in assignment (expression has type "int", target has type "str") book_authors[1984] = "Orwell" # Invalid index type "int" for "Dict[str, str]"; expected type "str"
Аналогично используются typing.DefaultDict и typing.OrderedDict
Результат выполнения функции
Для указания типа результата функции можно использовать любую аннотацию. Но есть несколько особенных случаев.
Если функция ничего не возвращает (например, как print ), её результат всегда равен None . Для аннотации так же используем None .
Корректными вариантами завершения такой функции будут: явный возврат None , возврат без указания значения и завершение без вызова return .
def nothing(a: int) -> None: if a == 1: return elif a == 2: return None elif a == 3: return "" # No return value expected else: pass
Если же функция никогда не возвращает управление (например, как sys.exit ), следует использовать аннотацию NoReturn :
def forever() -> NoReturn: while True: pass
Если это генераторная функция, то есть её тело содержит оператор yield , для возвращаемого можно воспользоватьтся аннотацией Iterable[T] , либо Generator[YT, ST, RT] :
def generate_two() -> Iterable[int]: yield 1 yield "2" # Incompatible types in "yield" (actual type "str", expected type "int")
Вместо заключения
Для многих ситуаций в модуле typing есть подходящие типы, однако я не буду рассматривать все, так как поведение аналогично рассмотренным.
Например, есть Iterator как generic-версия для collections.abc.Iterator , typing.SupportsInt для того, чтобы указать что объект поддерживает метод __int__ , или Callable для функций и объектов, поддерживающих метод __call__
Так же стандарт определяет формат аннотаций в виде комментариев и stub-файлы, которые содержат информацию только для статических анализаторов.
В следующей статье я бы хотел остановиться на механизме работы дженериков и обработке аннотаций в рантайме.
Почему я начал использовать аннотации типов в Python – и вам тоже советую

С появлением подсказок типов (type hints) в Python 3.5+ добавилась опциональная статическая типизация – поэтому эти подсказки так мне нравятся. Теперь я аннотирую ими все мои проекты.

Когда еще в 2016 году вышел Python 3.6, меня восхитили некоторые новые возможности, которые в нем предоставлялись. Среди них меня особенно впечатлили f-строки (если вы ими пока не пользовались, начните, не откладывая).
Спустя некоторое время после того, как я апгрейдил мой стек для работы с Python 3.6, я услышал об аннотациях типов из видео Дэна Бейдера. Он объяснил, что это такое, и для чего они ему пригодились.
Хотя, аннотации типов были введены в Python 3.5 (еще в 2015 году), о них по-прежнему интересно поговорить более подробно.
Думаю, как раз настало время поделиться с вами опытом!
Статическая типизация в Python?! Нет, это не для меня
Впервые услышав об аннотациях типов, я ими не впечатлился. Думал, что аннотации типов – это какой-то костыль на уровне языка Python.
Идея указывать типы в языке с динамической типизацией показалась мне, мягко говоря, странной, учитывая, что динамическая природа Python годами меня устраивала.
На тот момент я считал, что вполне нормально написать такой код:
def set_pos(self, pos): self.x = pos[0] self.y = pos[1]
Что должно содержаться в pos ? Это же очевидно – просто смотрим в код и видим, что там должен находиться кортеж с двумя числами (а какими именно числами? Целыми? С плавающей точкой?).
Я также узнал, что в среде выполнения Python аннотации типов вообще не использовались. Ранее они полностью игнорировались. Поэтому я подумал: зачем их использовать, если они никак не влияют на выполнение кода, который я пишу?
В самом деле, мне было непонятно, откуда весь этот растущий сыр-бор по поводу введения статической типизации в Python.
Пока вы не начали сотрудничать с другими разработчикам
Один из первых фрагментов кода, который я прочитал, был написан моим наставником. Он – из тех, у кого есть большой практический опыт работы с Java, но для выполнения задач ему также потребовалось освоить Python. Поскольку наставник исходно работал со статически типизируемыми языками, он настойчиво выступал за аннотирование типов в Python и в своем коде использовал их повсюду. Когда я спросил, зачем, он просто мне ответил:
Так всем понятнее. Аннотации типов поясняют читателю твоего кода, каков в этом коде ввод и вывод – даже спрашивать об этом не приходится.
Меня поразило, что в этом тезисе такой акцент делается на удобстве других людей.
В принципе, он сказал, что человек использует аннотации типов, чтобы другим было проще понимать его код.
Удобочитаемость важна
Задумайтесь об этом. Когда вы работаете над проектом, тот код, который вы пишете сейчас, может казаться вам вполне осмысленным. Вы не ощущаете нужды подробно его документировать.
Но другие люди (в том числе, вы через полгода) в будущем должны будут читать ваш код и понимать, что он означает. Как я полагаю, при этом читателю кода приходится ответить как минимум на три основных вопроса:
- Что этот фрагмент принимает в качестве ввода?
- Как он обрабатывает ввод?
- Что этот код выдает в качестве вывода?
По мере того, как я все больше читал код коллег – в том числе, сложный унаследованный код — я осознал, что аннотации типов на самом деле крайне полезны. Аннотации типов позволили мне ответить на вопросы 1 и 3 мгновенно. (на вопрос 2 можно ответить не менее просто, если в коде правильно выбраны имена функций.)
Давайте сыграем в игру
Ниже я написал функцию, ее тело скрыто. Можете мне рассказать, что она делает?
def concat(a, b): .
Вот моя версия — судя по имени функции, я бы сказал, что concat() принимает два списка (или кортежа?) и сцепляет их, возвращая в результате единый список, в котором содержатся элементы a и b .
Очевидно, правда? Не вполне.
На самом деле, здесь есть две возможности. Что, если на самом деле concat() просто сцепляет две строки, например?
Вот в чем дело — мы, в принципе, не понимаем, что делает concat() , поскольку мы не можем ответить на все три вопроса, приведенных выше. Можно примерно ответить только на вопрос 2: «делается какое-то сцепление».
А теперь давайте добавим аннотации типов для concat() :
def concat(a: int, b: int) -> str: .
Ага! Значит, мы в обоих вопросах ошиблись. По-видимому, concat() принимает два целых числа и на выходе выдает строку.
Так что, теперь я скажу: она принимает на ввод два целых числа, преобразует их в строковые представления, сцепляет их и возвращает результат.
А вот что именно она делает:
def concat(a: int, b: int) -> str: return str(a) + str(b)
Этот пример показывает, что знание ввода и вывода критически важно, чтобы понять этот фрагмент кода. А аннотации типов позволяют решить эту задачу почти мгновенно.
Здесь обычно был обходной маневр
Возвращаясь к моему опыту, скажу, что уже знал об этом – задолго до того, как начал использовать аннотации типов — и, вероятно, вы тоже знали.
Мне всегда нравился чистый код, который я при этом документировал, насколько мне удавалось. Думаю, это знак дисциплинированности – добавлять строку docstring во все ваши функции и классы, чтобы объяснить, что они делают (функционал) и почему они вообще существуют (цель).
Вот конкретный фрагмент кода из моего личного проекта, над которым я работал несколько лет назад:
def randrange(a, b, size=1): """Return random numbers between a and b. Parameters ---------- a : float Lower bound. b : float Upper bound. size : int, optional Number of numbers to return. Defaults to 1. Returns ------- ns : list of float """ .
Посмотрим-ка… в docstring описываются параметры, а также их типы и выводимое значение к каждому типу…
В каком-то смысле я уже пользовался аннотациями типов — через docstring.
Не поймите меня неправильно: документировать ваш код при помощи docstrings хорошо и полезно, когда в компоненте заложено много логики. Есть стандартные форматы (выше я пользовался форматом документов NumPy) и они полезны тем, что помогают поддерживать соглашения по документации, а также могут интерпретироваться некоторыми IDE.
Однако при работе с простыми функциями использование полноценной строки docstring просто для описания аргументов и возвращения значений иногда кажется обходным маневром — поскольку (как я полагал) в Python вообще не предлагается каких-либо подсказок типов.
Иногда аннотации типов могут полностью заменить docstring, поскольку они — по моему мнению — очень чисто и просто документируют как ввод, так и вывод. В итоге ваш код становится более удобочитаемым как для вас самих, так и для ваших коллег.
Но подождите! Это еще не все.
Аннотации типов были добавлены в Python 3.5 вместе с модулем типизации.
Этот модуль позволяет аннотировать всевозможные типы (например, списки, словари, функции или генераторы) и даже поддерживает вложения, дженерики, а также возможность определять собственные специальные типы.
Не буду сейчас в деталях разбирать модуль typing , но просто поделюсь некоторыми моими недавними наблюдениями: аннотации типов могут использоваться для генерации кода.
Возьмем, к примеру, namedtuple . Это структура данных из модуля collections — точно как ChainMap, рассмотренный в статье A practical usage of ChainMap in Python.
Что делает namedtuple : он генерирует класс, чьи экземпляры действуют как кортежи (они неизменяемые) но допускают доступ к атрибутам через точечное представление.
Как правило, namedtuple используется следующим образом:
from collections import namedtuple Point = namedtuple("Point", "x y") point = Point(x=1, y=5) print(point.x) # 1
Ранее, говоря о важности документирования ввода и вывода, мы сталкивались с подобным случаем: здесь нам чего-то не хватает. Мы не знаем, каковы типы x и y .
Оказывается, в модуле typing есть эквивалент namedtuple , называемый NamedTuple , он позволяет использовать аннотации типов.
Давайте заново определим класс Point с применением NamedTuple :
from typing import NamedTuple class Point(NamedTuple): x: int y: int point = Point(x=4, y=0) print(point.x) # 4
Мне нравится. Вот таким красивым, чистым и выразительным может быть код Python.
Обратите внимание: Point используется точно так, как и ранее, с той оговоркой, что теперь работать удобнее, поскольку код гораздо легче читается – и наши IDE и редакторы помогут нам обнаруживать потенциальные ошибки при написании типов. Также это делается благодаря статическим инструментам проверки, например, MyPy (и различным вариантам их интеграции).
При помощи аннотаций типов можно делать еще множество классных вещей, особенно теперь, когда они входят в ядро языка Python.
Например, в Python 3.7 были введены классы данных, потрясающий новый вариант генерируемых классов для простого, но при этом эффективного хранения данных. Однако, о них стоило бы написать отдельную статью.
А что с философской точки зрения?
Python проектировался как динамический язык программирования, а мы теперь вводим в него статическую типизацию. Здесь уже пора задуматься о следующем:
Как это вписывается в философию языка?
Может быть, разработчики ядра Python наконец осознали, что динамическая типизация была ошибочным выбором?
Не вполне. Попробуйте погуглить python philosophy – и найдете следующий документ:
«Дзен Python» — это документ, направляющий всю философию языка Python.
По-моему, аннотации типов на 100% вписываются в философию Python. Вот некоторые истины, воплощенные в них.
Явное лучше неявного.
В принципе, именно ради этого и изобретались аннотации типов. Просто сравните:
def process(data): do_stuff(data)
from typing import List, Tuple def process(data: List[Tuple[int, str]]): do_stuff(data)
Простое лучше сложного.
В простых случаях подсказки типов гораздо удобнее полноразмерных строк docstring.
Удобочитаемость важна
Об этом мы уже поговорили.
Должен быть один — и желательно всего один — способ это сделать.
Этот момент реализуется при помощи строгого (но при этом простого) синтаксиса аннотаций типов. Они – верное средство, если вы хотите документировать и поддерживать статические типы в Python!
Лучше поздно, чем никогда
По-моему, аннотации типов полностью поменяли правила игры:
- Благодаря им мой код стал лучше.
- Они предоставляют стандартный способ документирования ввода и вывода, помогая вам и другим людям гораздо лучше понимать код и увереннее о нем рассуждать.
- Также они открывают новые способы писать код более чисто и лаконично.
Если вы пока еще не пользуетесь аннотациями типов – попробуйте! О них есть много отличной информации — изучайте для начала.
Если вы уже пользуетесь аннотациями типов — и, надеюсь, хорошо закопались в них, помогите с их популяризацией!
- Блог компании Издательский дом «Питер»
- Python
- Программирование
- Совершенный код
- ООП