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

Оглавление

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

В этом уроке вы узнаете о следующем:

  • Аннотации типов и подсказки типов
  • Добавление статических типов в код, как свой, так и чужой
  • Запуск программы проверки статических типов
  • Усиление типов во время выполнения

Это исчерпывающее руководство, которое охватит много вопросов. Если вы хотите просто получить краткое представление о том, как работают подсказки типов в Python, и понять, стоит ли включать проверку типов в свой код, вам не нужно читать его целиком. Два раздела Hello Types и Pros and Cons дадут вам представление о том, как работает проверка типов, и рекомендации о том, когда она может быть полезна.

Типовые системы

Все языки программирования включают в себя некую систему типов <2>>, которая формализует, с какими категориями объектов можно работать и как эти категории обрабатываются. Например, система типов может определять числовой тип, причем 42 является одним из примеров объекта числового типа.

Динамическая типизация

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

>>> if False:
...     1 + "two"  # This line never runs, so no TypeError is raised
... else:
...     1 + 2
...
3

>>> 1 + "two"  # Now this is type checked, and a TypeError is raised
TypeError: unsupported operand type(s) for +: 'int' and 'str'

В первом примере ветвь 1 + "two" никогда не выполняется, поэтому она никогда не проверяется на тип. Второй пример показывает, что при вычислении 1 + "two" возникает ошибка TypeError, поскольку в Python нельзя сложить целое число и строку.

Далее посмотрим, могут ли переменные менять тип:

>>> thing = "Hello"
>>> type(thing)
<class 'str'>

>>> thing = 28.1
>>> type(thing)
<class 'float'>

type() возвращает тип объекта. Эти примеры подтверждают, что тип thing может изменяться, и Python правильно определяет тип по мере его изменения.

Статическая типизация

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

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

Давайте рассмотрим небольшой пример из статически типизированного языка. Рассмотрим следующий фрагмент Java:

String thing;
thing = "Hello";

Первая строка объявляет, что имя переменной thing привязано к типу String во время компиляции. Это имя никогда не может быть перепривязано к другому типу. Во второй строке переменной thing присваивается значение. Ему никогда не может быть присвоено значение, не являющееся объектом String. Например, если бы вы позже сказали thing = 28.1f, компилятор выдал бы ошибку из-за несовместимости типов.

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

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

Утиная типизация

Еще один термин, который часто используется при разговоре о Python, - утиная типизация. Это прозвище происходит от фразы "если она ходит как утка и крякает как утка, то это, должно быть, утка" (или любая из ее вариаций).

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

Например, вы можете вызвать len() на любом объекте Python, который определяет .__len__() метод:

>>> class TheHobbit:
...     def __len__(self):
...         return 95022
...
>>> the_hobbit = TheHobbit()
>>> len(the_hobbit)
95022

Обратите внимание, что вызов len() дает возвращаемое значение метода .__len__(). На самом деле, реализация len() по сути эквивалентна следующей:

def len(obj):
    return obj.__len__()

Для вызова len(obj) единственным реальным ограничением на obj является то, что он должен определять метод .__len__(). В противном случае объект может иметь такие разные типы, как str, list, dict или TheHobbit.

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

Привет Типы

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

def headline(text, align=True):
    if align:
        return f"{text.title()}\n{'-' * len(text)}"
    else:
        return f" {text.title()} ".center(50, "o")

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

>>> print(headline("python type checking"))
Python Type Checking
--------------------

>>> print(headline("python type checking", align=False))
oooooooooooooo Python Type Checking oooooooooooooo

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

def headline(text: str, align: bool = True) -> str:
    ...

В синтаксисе text: str указано, что аргумент text должен иметь тип str. Аналогично, необязательный аргумент align должен иметь тип bool со значением по умолчанию True. Наконец, обозначение -> str указывает, что headline() будет возвращать строку.

Что касается стиля, PEP 8 рекомендует следующее:

  • Используйте обычные правила для двоеточий, то есть отсутствие пробела до и один пробел после двоеточия: text: str.
  • Используйте пробелы вокруг знака = при объединении аннотации аргумента со значением по умолчанию: align: bool = True.
  • Используйте пробелы вокруг стрелки ->: def headline(...) -> str.

Добавление таких подсказок типа не имеет никакого эффекта во время выполнения: это всего лишь подсказки, и они не выполняются сами по себе. Например, если мы используем неправильный тип для (по общему признанию, неудачно названного) аргумента align, код все равно будет работать без каких-либо проблем или предупреждений:

>>> print(headline("python type checking", align="left"))
Python Type Checking
--------------------

Примечание: Причина, по которой это работает, заключается в том, что строка "left" сравнивается как истинная. Использование align="center" не даст желаемого эффекта, так как "center" также является истиной.

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

Возможно, в вашем редакторе уже есть подобная программа проверки типов. Например, PyCharm сразу же выдаст вам предупреждение:

PyCharm flagging a type error

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

Если в вашей системе еще нет Mypy, вы можете установить его, используя pip:

$ pip install mypy

Поместите следующий код в файл с именем headlines.py:

 1 # headlines.py
 2
 3 def headline(text: str, align: bool = True) -> str:
 4     if align:
 5         return f"{text.title()}\n{'-' * len(text)}"
 6     else:
 7         return f" {text.title()} ".center(50, "o")
 8
 9 print(headline("python type checking"))
10 print(headline("use mypy", align="center"))

Это, по сути, тот же код, который вы видели ранее: определение headline() и два примера, которые его используют.

Теперь запустите Mypy на этом коде:

$ mypy headlines.py
headlines.py:10: error: Argument "align" to "headline" has incompatible
                        type "str"; expected "bool"

Основываясь на подсказках типов, Mypy может сказать нам, что мы используем неправильный тип в строке 10.

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

 1 # headlines.py
 2
 3 def headline(text: str, centered: bool = False) -> str:
 4     if not centered:
 5         return f"{text.title()}\n{'-' * len(text)}"
 6     else:
 7         return f" {text.title()} ".center(50, "o")
 8
 9 print(headline("python type checking"))
10 print(headline("use mypy", centered=True))

Здесь вы заменили align на centered, и правильно использовали булево значение для centered при вызове headline(). Теперь код передает Mypy:

$ mypy headlines.py
Success: no issues found in 1 source file

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

$ python headlines.py
Python Type Checking
--------------------
oooooooooooooooooooo Use Mypy oooooooooooooooooooo

Первый заголовок выровнен по левому краю, а второй - по центру.

Плюсы и минусы

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

  • Подсказки типов помогают документировать ваш код. Традиционно для документирования ожидаемых типов аргументов функции использовались docstrings. Это работает, но поскольку стандарта на docstrings не существует (несмотря на PEP 257), их нельзя легко использовать для автоматических проверок.

  • Подсказки типов улучшают IDE и линтеры. С их помощью гораздо проще статически рассуждать о коде. Это, в свою очередь, позволяет IDE предлагать лучшее завершение кода и другие подобные возможности. Благодаря аннотации типа PyCharm знает, что text - это строка, и может давать конкретные предложения, основанные на этом:

    Code completion in PyCharm on a typed variable

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

Конечно, статическая проверка типов - это не все персики и сливки. Есть и некоторые недостатки, которые следует учитывать:

  • Подсказки типа требуют времени и усилий разработчика для добавления . Хотя это, вероятно, окупается тем, что вы тратите меньше времени на отладку, вы будете тратить больше времени на ввод кода.

  • Подсказки типов лучше всего работают в современных Pythons. Аннотации появились в Python 3.0, а в Python 2.7 можно использовать типовые комментарии. Тем не менее, такие улучшения, как аннотации переменных и отложенная оценка подсказок типов означают, что вам будет удобнее выполнять проверку типов, используя Python 3.6 или даже Python 3.7.

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

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

Чтобы получить некоторое представление об этом, создайте два файла: empty_file.py должен быть пустым файлом, а import_typing.py должен содержать следующую строку:

import typing

В Linux довольно легко проверить, сколько времени занимает typing импорт с помощью утилиты perf, которую Python 3.12 поддерживает:

$ perf stat -r 1000 python3.6 import_typing.py

 Performance counter stats for 'python3.6 import_typing.py' (1000 runs):

 [ ... extra information hidden for brevity ... ]

       0.045161650 seconds time elapsed    ( +-  0.77% )

Таким образом, запуск скрипта import typing.py занимает около 45 миллисекунд. Конечно, это не все время, потраченное на импорт typing. Часть этого времени уходит на запуск интерпретатора Python, поэтому давайте сравним с запуском Python на пустом файле:

$ perf stat -r 1000 python3.6 empty_file.py

 Performance counter stats for 'python3.6 empty_file.py' (1000 runs):

 [ ... extra information hidden for brevity ... ]

       0.028077845 seconds time elapsed    ( +-  0.49% )

По результатам этого теста импорт модуля typing занимает около 17 миллисекунд на Python 3.6.

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

$ perf stat -r 1000 python3.7 import_typing.py
 [...]
       0.025979806 seconds time elapsed    ( +-  0.31% )

$ perf stat -r 1000 python3.7 empty_file.py
 [...]
       0.020002505 seconds time elapsed    ( +-  0.30% )

Действительно, общее время запуска уменьшилось примерно на 8 миллисекунд, а время импорта typing сократилось с 17 до примерно 6 миллисекунд - почти в 3 раза быстрее.

Использование timeit

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

$ python3.6 -m timeit "import typing"
10000000 loops, best of 3: 0.134 usec per loop

Хотя вы получите результат, вы должны отнестись к нему с подозрением: 0,1 микросекунды - это более чем в 100000 раз быстрее, чем измерял perf! На самом деле timeit выполнил оператор import typing 30 миллионов раз, при этом Python фактически импортировал typing только один раз.

Чтобы получить приемлемые результаты, можно указать timeit, чтобы он выполнялся только один раз:

$ python3.6 -m timeit -n 1 -r 1 "import typing"
1 loops, best of 1: 9.77 msec per loop
$ python3.7 -m timeit -n 1 -r 1 "import typing"
1 loop, best of 1: 1.97 msec per loop

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

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

Новая importtime опция

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

$ python3.7 -X importtime import_typing.py
import time: self [us] | cumulative | imported package
[ ... some information hidden for brevity ... ]
import time:       358 |        358 | zipimport
import time:      2107 |      14610 | site
import time:       272 |        272 |   collections.abc
import time:       664 |       3058 |   re
import time:      3044 |       6373 | typing

Здесь показан аналогичный результат. Импорт typing занимает около 6 миллисекунд. Если вы внимательно прочитаете отчет, то заметите, что примерно половина этого времени уходит на импорт модулей collections.abc и re, от которых зависит typing.

Итак, стоит ли использовать статическую проверку типов в своем коде? Ну, это не вопрос "все или ничего". К счастью, Python поддерживает концепцию постепенной типизации. Это означает, что вы можете постепенно вводить типы в свой код. Код без подсказок типов будет проигнорирован статической системой проверки типов. Поэтому вы можете начать добавлять типы в критически важные компоненты и продолжать до тех пор, пока это приносит вам пользу.

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

Несколько эмпирических правил о том, стоит ли добавлять типы в ваш проект:

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

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

  • В библиотеках, которые будут использоваться другими, особенно опубликованных на PyPI, подсказки типов имеют большую ценность. Другой код, использующий ваши библиотеки, нуждается в этих подсказках типов, чтобы самому пройти правильную проверку типов. Примеры проектов, использующих подсказки типов, смотрите в cursive_re, black, нашем собственном Real Python Reader и Mypy.

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

В своей замечательной статье The State of Type Hints in Python Бернат Габор рекомендует " использовать подсказки типов всякий раз, когда стоит писать модульные тесты". Действительно, подсказки типов играют ту же роль, что и тесты в вашем коде: они помогают вам как разработчику писать лучший код.

Надеюсь, теперь вы имеете представление о том, как работает проверка типов в Python и хотите ли вы использовать ее в своих проектах.

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

Аннотации

Аннотации были введены в Python 3.0, первоначально без какой-либо конкретной цели. Это был просто способ связать произвольные выражения с аргументами и возвращаемыми значениями функций.

Годы спустя PEP 484 определил, как добавлять подсказки типов в код на Python, основываясь на работе, которую Юкка Лехтосало проделал над своим докторским проектом - Mypy. Основной способ добавления подсказок типов - использование аннотаций. Поскольку проверка типов становится все более распространенной, это также означает, что аннотации должны быть в основном предназначены для подсказок типов.

В следующих разделах объясняется, как работают аннотации в контексте подсказок типов.

Аннотации функций

Для функций можно аннотировать аргументы и возвращаемое значение. Это делается следующим образом:

def func(arg: arg_type, optarg: arg_type = default) -> return_type:
    ...

Для аргументов используется синтаксис argument: annotation, а возвращаемый тип аннотируется с помощью -> annotation. Обратите внимание, что аннотация должна быть правильным выражением Python.

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

import math

def circumference(radius: float) -> float:
    return 2 * math.pi * radius

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

>>> circumference(1.23)
7.728317927830891

>>> circumference.__annotations__
{'radius': <class 'float'>, 'return': <class 'float'>}

Иногда вы можете запутаться в том, как Mypy интерпретирует ваши подсказки типов. Для таких случаев существуют специальные выражения Mypy: reveal_type() и reveal_locals(). Вы можете добавить их в свой код перед запуском Mypy, и Mypy послушно сообщит, какие типы он определил. В качестве примера сохраните следующий код в reveal.py:

 1# reveal.py
 2
 3import math
 4reveal_type(math.pi)
 5
 6radius = 1
 7circumference = 2 * math.pi * radius
 8reveal_locals()

Далее запустите этот код через Mypy:

$ mypy reveal.py
reveal.py:4: error: Revealed type is 'builtins.float'

reveal.py:8: error: Revealed local types are:
reveal.py:8: error: circumference: builtins.float
reveal.py:8: error: radius: builtins.int

Даже без аннотаций Mypy правильно определил типы встроенных math.pi, а также наших локальных переменных radius и circumference.

Примечание: Выражения раскрытия предназначены только как инструмент, помогающий добавлять типы и отлаживать подсказки типов. Если вы попытаетесь запустить файл reveal.py в качестве сценария Python, он завершится ошибкой NameError, поскольку reveal_type() не является функцией, известной интерпретатору Python.

Если Mypy говорит, что "Имя 'reveal_locals' не определено", возможно, вам нужно обновить установку Mypy. Выражение reveal_locals() доступно в Mypy версии 0.610 и более поздних.

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

В определении circumference() в предыдущем разделе вы аннотировали только аргументы и возвращаемое значение. Вы не добавили никаких аннотаций внутри тела функции. Чаще всего этого бывает достаточно.

Однако иногда программе проверки типов требуется помощь в определении типов переменных. Аннотации переменных были определены в PEP 526 и введены в Python 3.6. Синтаксис такой же, как и для аннотаций аргументов функций:

pi: float = 3.142

def circumference(radius: float) -> float:
    return 2 * pi * radius

Переменная pi была аннотирована с помощью подсказки типа float.

Примечание: Статические программы проверки типов более чем способны определить, что 3.142 - это float, поэтому в данном примере аннотация pi не нужна. По мере изучения системы типов Python вы увидите больше подходящих примеров аннотации переменных.

Аннотации переменных хранятся в словаре __annotations__ уровня модуля:

>>> circumference(1)
6.284

>>> __annotations__
{'pi': <class 'float'>}

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

>>> nothing: str
>>> nothing
NameError: name 'nothing' is not defined

>>> __annotations__
{'nothing': <class 'str'>}

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

Тип Комментарии

Как уже говорилось, аннотации появились в Python 3, и они не были перенесены в Python 2. Это означает, что если вы пишете код, который должен поддерживать legacy Python, вы не можете использовать аннотации.

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

import math

def circumference(radius):
    # type: (float) -> float
    return 2 * math.pi * radius

Комментарии типов - это просто комментарии, поэтому их можно использовать в любой версии Python.

Комментарии типов обрабатываются непосредственно программой проверки типов, поэтому эти типы недоступны в словаре __annotations__:

>>> circumference.__annotations__
{}

Комментарий типа должен начинаться с литерала type: и располагаться в той же или следующей строке, что и определение функции. Если вы хотите аннотировать функцию с несколькими аргументами, вы пишете каждый тип через запятую:

def headline(text, width=80, fill_char="-"):
    # type: (str, int, str) -> str
    return f" {text.title()} ".center(width, fill_char)

print(headline("type comments work", width=40))

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

 1# headlines.py
 2
 3def headline(
 4    text,           # type: str
 5    width=80,       # type: int
 6    fill_char="-",  # type: str
 7):                  # type: (...) -> str
 8    return f" {text.title()} ".center(width, fill_char)
 9
10print(headline("type comments work", width=40))

Запустите пример через Python и Mypy:

$  python headlines.py
---------- Type Comments Work ----------

$ mypy headlines.py
Success: no issues found in 1 source file

Если у вас есть ошибки, например, если вы случайно вызвали headline() с width="full" в строке 10, Mypy сообщит вам:

$ mypy headline.py
headline.py:10: error: Argument "width" to "headline" has incompatible
                       type "str"; expected "int"

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

pi = 3.142  # type: float

В этом примере переменная pi будет проверена на тип как переменная float.

Итак, тип Аннотации или тип Комментарии?

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

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

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

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

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

Игра с типами в Python, часть 1

До сих пор в подсказках типов вы использовали только базовые типы, такие как str, float и bool. Система типов Python довольно мощная и поддерживает множество видов более сложных типов. Это необходимо, так как она должна разумно моделировать динамическую природу утиной типизации Python.

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

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

Пример: Колода карт

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

 1 # game.py
 2
 3 import random
 4
 5 SUITS = "♠ ♡ ♢ ♣".split()
 6 RANKS = "2 3 4 5 6 7 8 9 10 J Q K A".split()
 7
 8 def create_deck(shuffle=False):
 9     """Create a new deck of 52 cards"""
10     deck = [(s, r) for r in RANKS for s in SUITS]
11     if shuffle:
12         random.shuffle(deck)
13     return deck
14
15 def deal_hands(deck):
16     """Deal the cards in the deck into four hands"""
17     return (deck[0::4], deck[1::4], deck[2::4], deck[3::4])
18
19 def play():
20     """Play a 4-player card game"""
21     deck = create_deck(shuffle=True)
22     names = "P1 P2 P3 P4".split()
23     hands = {n: h for n, h in zip(names, deal_hands(deck))}
24
25     for name, cards in hands.items():
26         card_str = " ".join(f"{s}{r}" for (s, r) in cards)
27         print(f"{name}: {card_str}")
28
29 if __name__ == "__main__":
30     play()

Каждая карта представлена в виде кортежа строк, обозначающих масть и ранг. Колода представляется в виде списка карт. create_deck() создает обычную колоду из 52 игральных карт и по желанию тасует карты. deal_hands() раздает колоду карт четырем игрокам.

Наконец, play() играет в игру. На данный момент он только готовится к карточной игре, создавая перетасованную колоду и раздавая карты каждому игроку. Ниже приведен типичный вывод:

$ python game.py
P4: ♣9 ♢9 ♡2 ♢7 ♡7 ♣A ♠6 ♡K ♡5 ♢6 ♢3 ♣3 ♣Q
P1: ♡A ♠2 ♠10 ♢J ♣10 ♣4 ♠5 ♡Q ♢5 ♣6 ♠A ♣5 ♢4
P2: ♢2 ♠7 ♡8 ♢K ♠3 ♡3 ♣K ♠J ♢A ♣7 ♡6 ♡10 ♠K
P3: ♣2 ♣8 ♠8 ♣J ♢Q ♡9 ♡J ♠4 ♢8 ♢10 ♠9 ♡4 ♠Q

По ходу дела вы увидите, как расширить этот пример до более интересной игры.

Последовательности и отображения

Добавим подсказки типов в нашу карточную игру. Другими словами, давайте аннотируем функции create_deck(), deal_hands() и play(). Первая трудность заключается в том, что вам нужно аннотировать составные типы, такие как список, используемый для представления колоды карт, и кортежи, используемые для представления самих карт.

При использовании простых типов, таких как str, float и bool, добавлять подсказки к типу так же просто, как и использовать сам тип:

>>> name: str = "Guido"
>>> pi: float = 3.142
>>> centered: bool = False

С составными типами можно делать то же самое:

>>> names: list = ["Guido", "Jukka", "Ivan"]
>>> version: tuple = (3, 7, 1)
>>> options: dict = {"centered": False, "capitalize": True}

Однако это не дает полного представления. Каковы будут типы names[2], version[0] и options["centered"]? В данном конкретном случае вы видите, что это str, int и bool соответственно. Однако сами подсказки типов не дают об этом никакой информации.

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

>>> from typing import Dict, List, Tuple

>>> names: List[str] = ["Guido", "Jukka", "Ivan"]
>>> version: Tuple[int, int, int] = (3, 7, 1)
>>> options: Dict[str, bool] = {"centered": False, "capitalize": True}

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

  • names - список строк
  • version - 3-кортеж, состоящий из трех целых чисел
  • options - словарь, отображающий строки на булевы значения

Модуль typing содержит гораздо больше составных типов, включая Counter, Deque, FrozenSet, NamedTuple и Set. Кроме того, модуль содержит другие типы, которые вы увидите в последующих разделах.

Вернемся к карточной игре. Карта представлена кортежем из двух строк. Вы можете записать это как Tuple[str, str], тогда тип колоды карт станет List[Tuple[str, str]]. Поэтому можно аннотировать create_deck() следующим образом:

 8 def create_deck(shuffle: bool = False) -> List[Tuple[str, str]]:
 9     """Create a new deck of 52 cards"""
10     deck = [(s, r) for r in RANKS for s in SUITS]
11     if shuffle:
12         random.shuffle(deck)
13     return deck

Помимо возвращаемого значения, вы также добавили тип bool к необязательному аргументу shuffle.

Примечание: Кортежи и списки аннотируются по-разному.

Кортеж - это неизменяемая последовательность, которая обычно состоит из фиксированного числа элементов, возможно, имеющих различную типизацию. Например, мы представляем карту как кортеж из масти и ранга. В общем случае для кортежа из n элементов пишется Tuple[t_1, t_2, ..., t_n].

Список - это изменяемая последовательность, которая обычно состоит из неизвестного количества элементов одного типа, например, списка карт. Независимо от количества элементов в списке в аннотации указан только один тип: List[t].

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

from typing import List, Sequence

def square(elems: Sequence[float]) -> List[float]:
    return [x**2 for x in elems]

Использование Sequence является примером использования утиной типизации. Sequence - это все, что поддерживает len() и .__getitem__(), независимо от его фактического типа.

Тип псевдонимов

Подсказки типов могут стать довольно косыми при работе с вложенными типами, такими как колода карт. Возможно, вам придется немного посмотреть на List[Tuple[str, str]], прежде чем вы поймете, что он соответствует нашему представлению колоды карт.

Теперь подумайте, как бы вы аннотировали deal_hands():

15 def deal_hands(
16     deck: List[Tuple[str, str]]
17 ) -> Tuple[
18     List[Tuple[str, str]],
19     List[Tuple[str, str]],
20     List[Tuple[str, str]],
21     List[Tuple[str, str]],
22 ]:
23     """Deal the cards in the deck into four hands"""
24     return (deck[0::4], deck[1::4], deck[2::4], deck[3::4])

Это просто ужасно!

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

from typing import List, Tuple

Card = Tuple[str, str]
Deck = List[Card]

Card теперь можно использовать в подсказках типов или при определении новых псевдонимов типов, как Deck в примере выше.

Используя эти псевдонимы, аннотации deal_hands() становятся гораздо более читабельными:

15 def deal_hands(deck: Deck) -> Tuple[Deck, Deck, Deck, Deck]:
16     """Deal the cards in the deck into four hands"""
17     return (deck[0::4], deck[1::4], deck[2::4], deck[3::4])

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

>>> from typing import List, Tuple
>>> Card = Tuple[str, str]
>>> Deck = List[Card]

>>> Deck
typing.List[typing.Tuple[str, str]]

Обратите внимание, что при печати Deck видно, что это псевдоним для списка из двух кортежей строк.

Функции без возвращаемых значений

Вы можете знать, что функции без явного возврата все равно возвращают None:

>>> def play(player_name):
...     print(f"{player_name} plays")
...

>>> ret_val = play("Jacob")
Jacob plays

>>> print(ret_val)
None

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

 1 # play.py
 2
 3 def play(player_name: str) -> None:
 4     print(f"{player_name} plays")
 5
 6 ret_val = play("Filip")

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

$ mypy play.py
play.py:6: error: "play" does not return a value

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

# play.py

def play(player_name: str):
    print(f"{player_name} plays")

ret_val = play("Henrik")

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

$ mypy play.py
Success: no issues found in 1 source file

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

from typing import NoReturn

def black_hole() -> NoReturn:
    raise Exception("There is no going back ...")

Поскольку black_hole() всегда вызывает исключение, он никогда не вернется правильно.

Пример: Играть в карты

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

 1 # game.py
 2
 3 import random
 4 from typing import List, Tuple
 5
 6 SUITS = "♠ ♡ ♢ ♣".split()
 7 RANKS = "2 3 4 5 6 7 8 9 10 J Q K A".split()
 8
 9 Card = Tuple[str, str]
10 Deck = List[Card]
11
12 def create_deck(shuffle: bool = False) -> Deck:
13     """Create a new deck of 52 cards"""
14     deck = [(s, r) for r in RANKS for s in SUITS]
15     if shuffle:
16         random.shuffle(deck)
17     return deck
18
19 def deal_hands(deck: Deck) -> Tuple[Deck, Deck, Deck, Deck]:
20     """Deal the cards in the deck into four hands"""
21     return (deck[0::4], deck[1::4], deck[2::4], deck[3::4])
22
23 def choose(items):
24     """Choose and return a random item"""
25     return random.choice(items)
26
27 def player_order(names, start=None):
28     """Rotate player order so that start goes first"""
29     if start is None:
30         start = choose(names)
31     start_idx = names.index(start)
32     return names[start_idx:] + names[:start_idx]
33
34 def play() -> None:
35     """Play a 4-player card game"""
36     deck = create_deck(shuffle=True)
37     names = "P1 P2 P3 P4".split()
38     hands = {n: h for n, h in zip(names, deal_hands(deck))}
39     start_player = choose(names)
40     turn_order = player_order(names, start=start_player)
41
42     # Randomly play cards from each player's hand until empty
43     while hands[start_player]:
44         for name in turn_order:
45             card = choose(hands[name])
46             hands[name].remove(card)
47             print(f"{name}: {card[0] + card[1]:<3}  ", end="")
48         print()
49
50 if __name__ == "__main__":
51     play()

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

$ python game.py
P3: ♢10  P4: ♣4   P1: ♡8   P2: ♡Q
P3: ♣8   P4: ♠6   P1: ♠5   P2: ♡K
P3: ♢9   P4: ♡J   P1: ♣A   P2: ♡A
P3: ♠Q   P4: ♠3   P1: ♠7   P2: ♠A
P3: ♡4   P4: ♡6   P1: ♣2   P2: ♠K
P3: ♣K   P4: ♣7   P1: ♡7   P2: ♠2
P3: ♣10  P4: ♠4   P1: ♢5   P2: ♡3
P3: ♣Q   P4: ♢K   P1: ♣J   P2: ♡9
P3: ♢2   P4: ♢4   P1: ♠9   P2: ♠10
P3: ♢A   P4: ♡5   P1: ♠J   P2: ♢Q
P3: ♠8   P4: ♢7   P1: ♢3   P2: ♢J
P3: ♣3   P4: ♡10  P1: ♣9   P2: ♡2
P3: ♢6   P4: ♣6   P1: ♣5   P2: ♢8

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

Тип Any

choose() работает как для списков имен, так и для списков карт (и любой другой последовательности, если на то пошло). Один из способов добавить подсказки типа для этого был бы следующим:

import random
from typing import Any, Sequence

def choose(items: Sequence[Any]) -> Any:
    return random.choice(items)

Это означает примерно то же, что и написано: items - это последовательность, которая может содержать элементы любого типа, а choose() вернет один такой элемент любого типа. К сожалению, это не так уж полезно. Рассмотрим следующий пример:

 1 # choose.py
 2
 3 import random
 4 from typing import Any, Sequence
 5
 6 def choose(items: Sequence[Any]) -> Any:
 7     return random.choice(items)
 8
 9 names = ["Guido", "Jukka", "Ivan"]
10 reveal_type(names)
11
12 name = choose(names)
13 reveal_type(name)

Хотя Mypy правильно заключит, что names - это список строк, эта информация теряется после вызова choose() из-за использования типа Any:

$ mypy choose.py
choose.py:10: error: Revealed type is 'builtins.list[builtins.str*]'
choose.py:13: error: Revealed type is 'Any'

Вскоре вы увидите лучший способ. Однако сначала давайте более теоретически рассмотрим систему типов Python и ту особую роль, которую в ней играет Any.

Теория типов

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

Подтипы

Одним из важных понятий является понятие подтипов . Формально мы говорим, что тип T является подтипом U, если выполняются следующие два условия:

  • Каждое значение из T также находится в множестве значений типа U.
  • Каждая функция из типа U также находится во множестве функций типа T.

Эти два условия гарантируют, что даже если тип T отличается от U, переменные типа T всегда могут претендовать на U.

Для конкретного примера рассмотрим T = bool и U = int. Тип bool принимает только два значения. Обычно их обозначают True и False, но эти имена - всего лишь псевдонимы для целых значений 1 и 0 соответственно:

>>> int(False)
0

>>> int(True)
1

>>> True + True
2

>>> issubclass(bool, int)
True

<<<Поскольку 0 и 1 - оба целые числа, первое условие выполняется. Выше вы можете видеть, что булевы числа можно складывать, но они также могут делать все, что могут целые числа. Это и есть второе условие. Другими словами, bool является подтипом int.

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

def double(number: int) -> int:
    return number * 2

print(double(True))  # Passing in bool instead of int

Подтипы в некоторой степени связаны с подклассами. Фактически все подклассы соответствуют подтипам, и bool является подтипом int, потому что bool является подклассом int. Однако существуют также подтипы, которые не соответствуют подклассам. Например, int является подтипом float, но int не является подклассом float.

Ковариант, контравариант и инвариант

Что происходит, когда вы используете подтипы внутри составных типов? Например, является ли Tuple[bool] подтипом Tuple[int]? Ответ зависит от составного типа, а также от того, является ли этот тип ковариантным, контравариантным или инвариантным. Это быстро становится техническим, поэтому приведем лишь несколько примеров:

  • Tuple является ковариантным. Это означает, что он сохраняет иерархию типов своих элементов: Tuple[bool] является подтипом Tuple[int], потому что bool является подтипом int.

  • List является инвариантным. Инвариантные типы не дают никаких гарантий относительно подтипов. Хотя все значения List[bool] являются значениями List[int], вы можете добавить int к List[int] и не добавлять к List[bool]. Другими словами, второе условие для подтипов не выполняется, и List[bool] не является подтипом List[int].

  • Callable контравариантен по своим аргументам. Это означает, что она меняет иерархию типов на противоположную. Как работает Callable, вы увидите позже, а пока думайте о Callable[[T], ...] как о функции, единственный аргумент которой имеет тип T. Примером Callable[[int], ...] является функция double(), определенная выше. Контравариантность означает, что если ожидается функция, работающая на bool, то функция, работающая на int, будет приемлемой.

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

Градуальная типизация и последовательные типы

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

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

Тип T согласован с типом U, если T является подтипом U или либо T, либо U является Any.

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

Это означает, что вы можете использовать Any для явного перехода к динамической типизации, описания типов, которые слишком сложны для описания в системе типов Python, или описания элементов в составных типах. Например, словарь со строковыми ключами, который может принимать любой тип в качестве значений, может быть аннотирован Dict[str, Any].

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

Игра с типами в Python, часть 2

Вернемся к нашим практическим примерам. Вспомните, что вы пытались аннотировать общую функцию choose():

import random
from typing import Any, Sequence

def choose(items: Sequence[Any]) -> Any:
    return random.choice(items)

Проблема с использованием Any заключается в том, что вы без нужды теряете информацию о типе. Вы знаете, что если передать список строк в choose(), то он вернет строку. Ниже вы увидите, как выразить это с помощью переменных типов, а также как работать с:

Переменные типа

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

Давайте создадим переменную типа, которая будет эффективно инкапсулировать поведение choose():

 1 # choose.py
 2
 3 import random
 4 from typing import Sequence, TypeVar
 5
 6 Choosable = TypeVar("Choosable")
 7
 8 def choose(items: Sequence[Choosable]) -> Choosable:
 9     return random.choice(items)
10
11 names = ["Guido", "Jukka", "Ivan"]
12 reveal_type(names)
13
14 name = choose(names)
15 reveal_type(name)

Переменная типа должна быть определена с помощью TypeVar из модуля typing. При использовании переменная типа охватывает все возможные типы и принимает наиболее конкретный тип из возможных. В примере name теперь является str:

$ mypy choose.py
choose.py:12: error: Revealed type is 'builtins.list[builtins.str*]'
choose.py:15: error: Revealed type is 'builtins.str*'

Рассмотрим несколько других примеров:

 1 # choose_examples.py
 2
 3 from choose import choose
 4
 5 reveal_type(choose(["Guido", "Jukka", "Ivan"]))
 6 reveal_type(choose([1, 2, 3]))
 7 reveal_type(choose([True, 42, 3.14]))
 8 reveal_type(choose(["Python", 3, 7]))

Первые два примера должны иметь тип str и int, но как быть с последними двумя? Отдельные элементы списка имеют разные типы, и в этом случае переменная типа Choosable делает все возможное, чтобы приспособиться:

$ mypy choose_examples.py
choose_examples.py:5: error: Revealed type is 'builtins.str*'
choose_examples.py:6: error: Revealed type is 'builtins.int*'
choose_examples.py:7: error: Revealed type is 'builtins.float*'
choose_examples.py:8: error: Revealed type is 'builtins.object*'

Как вы уже видели, bool является подтипом int, который опять же является подтипом float. Поэтому в третьем примере возвращаемое значение choose() гарантированно будет чем-то, что можно представить как float. В последнем примере между str и int нет отношения подтипа, поэтому лучшее, что можно сказать о возвращаемом значении, - это то, что оно является объектом.

Обратите внимание, что ни один из этих примеров не вызвал ошибку типа. Есть ли способ сообщить программе проверки типов, что choose() должен принимать и строки, и числа, но не оба одновременно?

Вы можете ограничить тип переменных, перечислив допустимые типы:

 1 # choose.py
 2
 3 import random
 4 from typing import Sequence, TypeVar
 5
 6 Choosable = TypeVar("Choosable", str, float)
 7
 8 def choose(items: Sequence[Choosable]) -> Choosable:
 9     return random.choice(items)
10
11 reveal_type(choose(["Guido", "Jukka", "Ivan"]))
12 reveal_type(choose([1, 2, 3]))
13 reveal_type(choose([True, 42, 3.14]))
14 reveal_type(choose(["Python", 3, 7]))

Теперь Choosable может быть только str или float, и Mypy заметит, что последний пример является ошибкой:

$ mypy choose.py
choose.py:11: error: Revealed type is 'builtins.str*'
choose.py:12: error: Revealed type is 'builtins.float*'
choose.py:13: error: Revealed type is 'builtins.float*'
choose.py:14: error: Revealed type is 'builtins.object*'
choose.py:14: error: Value of type variable "Choosable" of "choose"
                     cannot be "object"

Также обратите внимание, что во втором примере тип считается float, хотя входной список содержит только int объектов. Это связано с тем, что Choosable был ограничен строками и плавающей точкой, а int является подтипом float.

В нашей карточной игре мы хотим ограничить использование choose() для str и Card:

Choosable = TypeVar("Choosable", str, Card)

def choose(items: Sequence[Choosable]) -> Choosable:
    ...

Мы вкратце упомянули, что Sequence представляет как списки, так и кортежи. Как мы уже отмечали, Sequence можно рассматривать как тип "утка", поскольку это может быть любой объект, в котором реализованы .__len__() и .__getitem__().

Типы и протоколы уток

Вспомните следующий пример из введения:

def len(obj):
    return obj.__len__()

len() может вернуть длину любого объекта, реализовавшего метод .__len__(). Как добавить подсказки о типе в len(), в частности в аргумент obj?

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

  • В номинальной системе сравнения между типами основаны на именах и объявлениях. Система типов Python в основном номинальная, где int может быть использован вместо float из-за их отношения подтипов.

  • В структурной системе сравнения между типами основаны на структуре. Можно определить структурный тип Sized, который включает все экземпляры, определяющие .__len__(), независимо от их номинального типа.

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

Протокол определяет один или несколько методов, которые должны быть реализованы. Например, все классы, определяющие .__len__(), выполняют протокол typing.Sized. Поэтому мы можем аннотировать len() следующим образом:

from typing import Sized

def len(obj: Sized) -> int:
    return obj.__len__()

Другие примеры протоколов, определенных в модуле typing, включают Container, Iterable, Awaitable и ContextManager.

Вы также можете определять свои собственные протоколы. Это делается путем наследования от Protocol и определения сигнатур функций (с пустыми телами функций), которые ожидает протокол. Следующий пример показывает, как могли бы быть реализованы протоколы len() и Sized:

from typing_extensions import Protocol

class Sized(Protocol):
    def __len__(self) -> int: ...

def len(obj: Sized) -> int:
    return obj.__len__()

На момент написания статьи поддержка самоопределяемых протоколов все еще является экспериментальной и доступна только через модуль typing_extensions. Этот модуль должен быть явно установлен из PyPI, выполнив команду pip install typing-extensions.

Тип Optional

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

В примере с картой функция player_order() использует None в качестве дозорного значения для start, говоря, что если стартовый игрок не указан, то он должен быть выбран случайным образом:

27 def player_order(names, start=None):
28     """Rotate player order so that start goes first"""
29     if start is None:
30         start = choose(names)
31     start_idx = names.index(start)
32     return names[start_idx:] + names[:start_idx]

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

Для аннотирования таких аргументов вы можете использовать тип Optional:

from typing import Sequence, Optional

def player_order(
    names: Sequence[str], start: Optional[str] = None
) -> Sequence[str]:
    ...

Тип Optional просто говорит, что переменная либо имеет указанный тип, либо является None. Эквивалентным способом указать то же самое было бы использование типа Union: Union[None, str]

Обратите внимание, что при использовании Optional или Union необходимо следить за тем, чтобы переменная имела правильный тип, когда вы оперируете с ней. В примере это делается путем проверки того, имеет ли переменная тип start is None. Невыполнение этого требования приведет как к статическим ошибкам типа, так и к возможным ошибкам времени выполнения:

 1 # player_order.py
 2
 3 from typing import Sequence, Optional
 4
 5 def player_order(
 6     names: Sequence[str], start: Optional[str] = None
 7 ) -> Sequence[str]:
 8     start_idx = names.index(start)
 9     return names[start_idx:] + names[:start_idx]

Mypy сообщает вам, что вы не позаботились о случае, когда start является None:

$ mypy player_order.py
player_order.py:8: error: Argument 1 to "index" of "list" has incompatible
                          type "Optional[str]"; expected "str"

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

def player_order(names: Sequence[str], start: str = None) -> Sequence[str]:
    ...

Если вы не хотите, чтобы Mypy делал это предположение, вы можете отключить его с помощью опции командной строки --no-implicit-optional.

Пример: Объект(ив) игры

Давайте перепишем карточную игру, сделав ее более объектно-ориентированной. Это позволит нам обсудить, как правильно аннотировать классы и методы.

Более или менее прямой перевод нашей карточной игры в код, использующий классы для Card, Deck, Player и Game, выглядит примерно так:

 1 # game.py
 2
 3 import random
 4 import sys
 5
 6 class Card:
 7     SUITS = "♠ ♡ ♢ ♣".split()
 8     RANKS = "2 3 4 5 6 7 8 9 10 J Q K A".split()
 9
10     def __init__(self, suit, rank):
11         self.suit = suit
12         self.rank = rank
13
14     def __repr__(self):
15         return f"{self.suit}{self.rank}"
16
17 class Deck:
18     def __init__(self, cards):
19         self.cards = cards
20
21     @classmethod
22     def create(cls, shuffle=False):
23         """Create a new deck of 52 cards"""
24         cards = [Card(s, r) for r in Card.RANKS for s in Card.SUITS]
25         if shuffle:
26             random.shuffle(cards)
27         return cls(cards)
28
29     def deal(self, num_hands):
30         """Deal the cards in the deck into a number of hands"""
31         cls = self.__class__
32         return tuple(cls(self.cards[i::num_hands]) for i in range(num_hands))
33
34 class Player:
35     def __init__(self, name, hand):
36         self.name = name
37         self.hand = hand
38
39     def play_card(self):
40         """Play a card from the player's hand"""
41         card = random.choice(self.hand.cards)
42         self.hand.cards.remove(card)
43         print(f"{self.name}: {card!r:<3}  ", end="")
44         return card
45
46 class Game:
47     def __init__(self, *names):
48         """Set up the deck and deal cards to 4 players"""
49         deck = Deck.create(shuffle=True)
50         self.names = (list(names) + "P1 P2 P3 P4".split())[:4]
51         self.hands = {
52             n: Player(n, h) for n, h in zip(self.names, deck.deal(4))
53         }
54
55     def play(self):
56         """Play a card game"""
57         start_player = random.choice(self.names)
58         turn_order = self.player_order(start=start_player)
59
60         # Play cards from each player's hand until empty
61         while self.hands[start_player].hand.cards:
62             for name in turn_order:
63                 self.hands[name].play_card()
64             print()
65
66     def player_order(self, start=None):
67         """Rotate player order so that start goes first"""
68         if start is None:
69             start = random.choice(self.names)
70         start_idx = self.names.index(start)
71         return self.names[start_idx:] + self.names[:start_idx]
72
73 if __name__ == "__main__":
74     # Read player names from command line
75     player_names = sys.argv[1:]
76     game = Game(*player_names)
77     game.play()

Теперь давайте добавим типы в этот код.

Подсказки типов для методов

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

 6 class Card:
 7     SUITS = "♠ ♡ ♢ ♣".split()
 8     RANKS = "2 3 4 5 6 7 8 9 10 J Q K A".split()
 9
10     def __init__(self, suit: str, rank: str) -> None:
11         self.suit = suit
12         self.rank = rank
13
14     def __repr__(self) -> str:
15         return f"{self.suit}{self.rank}"

Обратите внимание, что метод .__init__() всегда должен иметь None в качестве возвращаемого типа.

Классы как типы

Между классами и типами существует соответствие. Например, все экземпляры класса Card вместе образуют тип Card. Чтобы использовать классы в качестве типов, достаточно использовать имя класса.

Например, Deck по сути состоит из списка Card объектов. Вы можете аннотировать его следующим образом:

17 class Deck:
18     def __init__(self, cards: List[Card]) -> None:
19         self.cards = cards

Mypy может связать ваше использование Card в аннотации с определением класса Card.

Однако это работает не так чисто, если вам нужно обратиться к классу, который определен в данный момент. Например, метод Deck.create() class возвращает объект с типом Deck. Однако вы не можете просто добавить -> Deck, поскольку класс Deck еще не полностью определен.

Вместо этого в аннотациях разрешено использовать строковые литералы. Эти строки будут оцениваться программой проверки типов только впоследствии, поэтому они могут содержать ссылки self и forward. Метод .create() должен использовать такие строковые литералы для своих типов:

20 class Deck:
21     @classmethod
22     def create(cls, shuffle: bool = False) -> "Deck":
23         """Create a new deck of 52 cards"""
24         cards = [Card(s, r) for r in Card.RANKS for s in Card.SUITS]
25         if shuffle:
26             random.shuffle(cards)
27         return cls(cards)

Обратите внимание, что класс Player также будет ссылаться на класс Deck. Однако это не проблема, так как Deck определен до Player:

34 class Player:
35     def __init__(self, name: str, hand: Deck) -> None:
36         self.name = name
37         self.hand = hand

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

Такую функциональность планируется сделать стандартной во все еще мифическом Python 4.0. Однако в Python 3.7 и более поздних версиях прямые ссылки доступны через __future__ импорт:

from __future__ import annotations

class Deck:
    @classmethod
    def create(cls, shuffle: bool = False) -> Deck:
        ...

Благодаря импорту __future__ вы можете использовать Deck вместо "Deck" еще до того, как будет определено Deck.

Возврат self или cls

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

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

 1 # dogs.py
 2
 3 from datetime import date
 4
 5 class Animal:
 6     def __init__(self, name: str, birthday: date) -> None:
 7         self.name = name
 8         self.birthday = birthday
 9
10     @classmethod
11     def newborn(cls, name: str) -> "Animal":
12         return cls(name, date.today())
13
14     def twin(self, name: str) -> "Animal":
15         cls = self.__class__
16         return cls(name, self.birthday)
17
18 class Dog(Animal):
19     def bark(self) -> None:
20         print(f"{self.name} says woof!")
21
22 fido = Dog.newborn("Fido")
23 pluto = fido.twin("Pluto")
24 fido.bark()
25 pluto.bark()

Пока код работает без проблем, Mypy заметит проблему:

$ mypy dogs.py
dogs.py:24: error: "Animal" has no attribute "bark"
dogs.py:25: error: "Animal" has no attribute "bark"

Проблема в том, что хотя унаследованные методы Dog.newborn() и Dog.twin() будут возвращать Dog, в аннотации сказано, что они возвращают Animal.

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

# dogs.py

from datetime import date
from typing import Type, TypeVar

TAnimal = TypeVar("TAnimal", bound="Animal")

class Animal:
    def __init__(self, name: str, birthday: date) -> None:
        self.name = name
        self.birthday = birthday

    @classmethod
    def newborn(cls: Type[TAnimal], name: str) -> TAnimal:
        return cls(name, date.today())

    def twin(self: TAnimal, name: str) -> TAnimal:
        cls = self.__class__
        return cls(name, self.birthday)

class Dog(Animal):
    def bark(self) -> None:
        print(f"{self.name} says woof!")

fido = Dog.newborn("Fido")
pluto = fido.twin("Pluto")
fido.bark()
pluto.bark()

В этом примере следует отметить несколько моментов:

  • Переменная типа TAnimal используется для обозначения того, что возвращаемые значения могут быть экземплярами подклассов Animal.

  • Мы указываем, что Animal является верхней границей для TAnimal. Указание bound означает, что TAnimal будет только Animal или один из его подклассов. Это необходимо для правильного ограничения допустимых типов.

  • Конструкция typing.Type[] является типовым эквивалентом type(). Она нужна, чтобы отметить, что метод class ожидает класс и возвращает экземпляр этого класса.

Аннотация *args и **kwargs

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

$ python game.py GeirArne Dan Joanna
Dan: ♢A   Joanna: ♡9   P1: ♣A   GeirArne: ♣2
Dan: ♡A   Joanna: ♡6   P1: ♠4   GeirArne: ♢8
Dan: ♢K   Joanna: ♢Q   P1: ♣K   GeirArne: ♠5
Dan: ♡2   Joanna: ♡J   P1: ♠7   GeirArne: ♡K
Dan: ♢10  Joanna: ♣3   P1: ♢4   GeirArne: ♠8
Dan: ♣6   Joanna: ♡Q   P1: ♣Q   GeirArne: ♢J
Dan: ♢2   Joanna: ♡4   P1: ♣8   GeirArne: ♡7
Dan: ♡10  Joanna: ♢3   P1: ♡3   GeirArne: ♠2
Dan: ♠K   Joanna: ♣5   P1: ♣7   GeirArne: ♠J
Dan: ♠6   Joanna: ♢9   P1: ♣J   GeirArne: ♣10
Dan: ♠3   Joanna: ♡5   P1: ♣9   GeirArne: ♠Q
Dan: ♠A   Joanna: ♠9   P1: ♠10  GeirArne: ♡8
Dan: ♢6   Joanna: ♢5   P1: ♢7   GeirArne: ♣4

Это реализуется путем распаковки и передачи sys.argv в Game() при его инстанцировании. Метод .__init__() использует *names для упаковки заданных имен в кортеж.

Что касается аннотаций типов: несмотря на то, что names будет представлять собой кортеж строк, следует аннотировать только тип каждого имени. Другими словами, вы должны использовать str, а не Tuple[str]:

46 class Game:
47     def __init__(self, *names: str) -> None:
48         """Set up the deck and deal cards to 4 players"""
49         deck = Deck.create(shuffle=True)
50         self.names = (list(names) + "P1 P2 P3 P4".split())[:4]
51         self.hands = {
52             n: Player(n, h) for n, h in zip(self.names, deck.deal(4))
53         }

Аналогично, если у вас есть функция или метод, принимающий **kwargs, то вам следует аннотировать только тип каждого возможного аргумента ключевого слова.

Callables

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

Функции, а также лямбды, методы и классы представляются typing.Callable. Обычно также указываются типы аргументов и возвращаемого значения. Например, Callable[[A1, A2, A3], Rt] представляет функцию с тремя аргументами с типами A1, A2 и A3 соответственно. Возвращаемый тип функции - Rt.

В следующем примере функция do_twice() дважды вызывает заданную функцию и печатает возвращаемые значения:

 1 # do_twice.py
 2
 3 from typing import Callable
 4
 5 def do_twice(func: Callable[[str], str], argument: str) -> None:
 6     print(func(argument))
 7     print(func(argument))
 8
 9 def create_greeting(name: str) -> str:
10     return f"Hello {name}"
11
12 do_twice(create_greeting, "Jekyll")

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

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

Пример: Сердца

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

  • Четыре игрока играют с рукой из 13 карт каждый.

  • Игрок, у которого на руках ♣2, начинает первый раунд и должен сыграть ♣2.

  • Игроки по очереди разыгрывают карты, по возможности следуя ведущей масти.

  • Игрок, разыгрывающий самую высокую карту в ведущей масти, выигрывает фокус и становится стартовым игроком в следующем ходу.

  • Игрок не может вести ♡ до тех пор, пока ♡ не будет сыграна в предыдущем розыгрыше.

  • После того, как все карты сыграны, игроки получают очки, если берут определенные карты:

    • 13 очков за ♠Q
    • 1 очко за каждую ♡
  • Игра длится несколько раундов, пока один из игроков не наберет 100 очков или больше. Игрок, набравший наименьшее количество очков, побеждает.

Более подробную информацию можно найти на сайте .

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

Вы можете загрузить этот код и другие примеры с GitHub:

# hearts.py

from collections import Counter
import random
import sys
from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
from typing import overload

class Card:
    SUITS = "♠ ♡ ♢ ♣".split()
    RANKS = "2 3 4 5 6 7 8 9 10 J Q K A".split()

    def __init__(self, suit: str, rank: str) -> None:
        self.suit = suit
        self.rank = rank

    @property
    def value(self) -> int:
        """The value of a card is rank as a number"""
        return self.RANKS.index(self.rank)

    @property
    def points(self) -> int:
        """Points this card is worth"""
        if self.suit == "♠" and self.rank == "Q":
            return 13
        if self.suit == "♡":
            return 1
        return 0

    def __eq__(self, other: Any) -> Any:
        return self.suit == other.suit and self.rank == other.rank

    def __lt__(self, other: Any) -> Any:
        return self.value < other.value

    def __repr__(self) -> str:
        return f"{self.suit}{self.rank}"

class Deck(Sequence[Card]):
    def __init__(self, cards: List[Card]) -> None:
        self.cards = cards

    @classmethod
    def create(cls, shuffle: bool = False) -> "Deck":
        """Create a new deck of 52 cards"""
        cards = [Card(s, r) for r in Card.RANKS for s in Card.SUITS]
        if shuffle:
            random.shuffle(cards)
        return cls(cards)

    def play(self, card: Card) -> None:
        """Play one card by removing it from the deck"""
        self.cards.remove(card)

    def deal(self, num_hands: int) -> Tuple["Deck", ...]:
        """Deal the cards in the deck into a number of hands"""
        return tuple(self[i::num_hands] for i in range(num_hands))

    def add_cards(self, cards: List[Card]) -> None:
        """Add a list of cards to the deck"""
        self.cards += cards

    def __len__(self) -> int:
        return len(self.cards)

    @overload
    def __getitem__(self, key: int) -> Card: ...

    @overload
    def __getitem__(self, key: slice) -> "Deck": ...

    def __getitem__(self, key: Union[int, slice]) -> Union[Card, "Deck"]:
        if isinstance(key, int):
            return self.cards[key]
        elif isinstance(key, slice):
            cls = self.__class__
            return cls(self.cards[key])
        else:
            raise TypeError("Indices must be integers or slices")

    def __repr__(self) -> str:
        return " ".join(repr(c) for c in self.cards)

class Player:
    def __init__(self, name: str, hand: Optional[Deck] = None) -> None:
        self.name = name
        self.hand = Deck([]) if hand is None else hand

    def playable_cards(self, played: List[Card], hearts_broken: bool) -> Deck:
        """List which cards in hand are playable this round"""
        if Card("♣", "2") in self.hand:
            return Deck([Card("♣", "2")])

        lead = played[0].suit if played else None
        playable = Deck([c for c in self.hand if c.suit == lead]) or self.hand
        if lead is None and not hearts_broken:
            playable = Deck([c for c in playable if c.suit != "♡"])
        return playable or Deck(self.hand.cards)

    def non_winning_cards(self, played: List[Card], playable: Deck) -> Deck:
        """List playable cards that are guaranteed to not win the trick"""
        if not played:
            return Deck([])

        lead = played[0].suit
        best_card = max(c for c in played if c.suit == lead)
        return Deck([c for c in playable if c < best_card or c.suit != lead])

    def play_card(self, played: List[Card], hearts_broken: bool) -> Card:
        """Play a card from a cpu player's hand"""
        playable = self.playable_cards(played, hearts_broken)
        non_winning = self.non_winning_cards(played, playable)

        # Strategy
        if non_winning:
            # Highest card not winning the trick, prefer points
            card = max(non_winning, key=lambda c: (c.points, c.value))
        elif len(played) < 3:
            # Lowest card maybe winning, avoid points
            card = min(playable, key=lambda c: (c.points, c.value))
        else:
            # Highest card guaranteed winning, avoid points
            card = max(playable, key=lambda c: (-c.points, c.value))
        self.hand.cards.remove(card)
        print(f"{self.name} -> {card}")
        return card

    def has_card(self, card: Card) -> bool:
        return card in self.hand

    def __repr__(self) -> str:
        return f"{self.__class__.__name__}({self.name!r}, {self.hand})"

class HumanPlayer(Player):
    def play_card(self, played: List[Card], hearts_broken: bool) -> Card:
        """Play a card from a human player's hand"""
        playable = sorted(self.playable_cards(played, hearts_broken))
        p_str = "  ".join(f"{n}: {c}" for n, c in enumerate(playable))
        np_str = " ".join(repr(c) for c in self.hand if c not in playable)
        print(f"  {p_str}  (Rest: {np_str})")
        while True:
            try:
                card_num = int(input(f"  {self.name}, choose card: "))
                card = playable[card_num]
            except (ValueError, IndexError):
                pass
            else:
                break
        self.hand.play(card)
        print(f"{self.name} => {card}")
        return card

class HeartsGame:
    def __init__(self, *names: str) -> None:
        self.names = (list(names) + "P1 P2 P3 P4".split())[:4]
        self.players = [Player(n) for n in self.names[1:]]
        self.players.append(HumanPlayer(self.names[0]))

    def play(self) -> None:
        """Play a game of Hearts until one player go bust"""
        score = Counter({n: 0 for n in self.names})
        while all(s < 100 for s in score.values()):
            print("\nStarting new round:")
            round_score = self.play_round()
            score.update(Counter(round_score))
            print("Scores:")
            for name, total_score in score.most_common(4):
                print(f"{name:<15} {round_score[name]:>3} {total_score:>3}")

        winners = [n for n in self.names if score[n] == min(score.values())]
        print(f"\n{' and '.join(winners)} won the game")

    def play_round(self) -> Dict[str, int]:
        """Play a round of the Hearts card game"""
        deck = Deck.create(shuffle=True)
        for player, hand in zip(self.players, deck.deal(4)):
            player.hand.add_cards(hand.cards)
        start_player = next(
            p for p in self.players if p.has_card(Card("♣", "2"))
        )
        tricks = {p.name: Deck([]) for p in self.players}
        hearts = False

        # Play cards from each player's hand until empty
        while start_player.hand:
            played: List[Card] = []
            turn_order = self.player_order(start=start_player)
            for player in turn_order:
                card = player.play_card(played, hearts_broken=hearts)
                played.append(card)
            start_player = self.trick_winner(played, turn_order)
            tricks[start_player.name].add_cards(played)
            print(f"{start_player.name} wins the trick\n")
            hearts = hearts or any(c.suit == "♡" for c in played)
        return self.count_points(tricks)

    def player_order(self, start: Optional[Player] = None) -> List[Player]:
        """Rotate player order so that start goes first"""
        if start is None:
            start = random.choice(self.players)
        start_idx = self.players.index(start)
        return self.players[start_idx:] + self.players[:start_idx]

    @staticmethod
    def trick_winner(trick: List[Card], players: List[Player]) -> Player:
        lead = trick[0].suit
        valid = [
            (c.value, p) for c, p in zip(trick, players) if c.suit == lead
        ]
        return max(valid)[1]

    @staticmethod
    def count_points(tricks: Dict[str, Deck]) -> Dict[str, int]:
        return {n: sum(c.points for c in cards) for n, cards in tricks.items()}

if __name__ == "__main__":
    # Read player names from the command line
    player_names = sys.argv[1:]
    game = HeartsGame(*player_names)
    game.play()

Вот несколько моментов, которые следует отметить в коде:

  • Для отношений типов, которые трудно выразить с помощью Union или переменных типов, можно использовать декоратор @overload. Смотрите Deck.__getitem__() для примера и документацию для получения дополнительной информации.

  • Подклассы соответствуют подтипам, так что HumanPlayer можно использовать там, где ожидается Player.

  • Когда подкласс повторно реализует метод из суперкласса, аннотации типов должны совпадать. Пример смотрите в HumanPlayer.play_card().

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

$ python hearts.py GeirArne Aldren Joanna Brad

Starting new round:
Brad -> ♣2
  0: ♣5  1: ♣Q  2: ♣K  (Rest: ♢6 ♡10 ♡6 ♠J ♡3 ♡9 ♢10 ♠7 ♠K ♠4)
  GeirArne, choose card: 2
GeirArne => ♣K
Aldren -> ♣10
Joanna -> ♣9
GeirArne wins the trick

  0: ♠4  1: ♣5  2: ♢6  3: ♠7  4: ♢10  5: ♠J  6: ♣Q  7: ♠K  (Rest: ♡10 ♡6 ♡3 ♡9)
  GeirArne, choose card: 0
GeirArne => ♠4
Aldren -> ♠5
Joanna -> ♠3
Brad -> ♠2
Aldren wins the trick

...

Joanna -> ♡J
Brad -> ♡2
  0: ♡6  1: ♡9  (Rest: )
  GeirArne, choose card: 1
GeirArne => ♡9
Aldren -> ♡A
Aldren wins the trick

Aldren -> ♣A
Joanna -> ♡Q
Brad -> ♣J
  0: ♡6  (Rest: )
  GeirArne, choose card: 0
GeirArne => ♡6
Aldren wins the trick

Scores:
Brad             14  14
Aldren           10  10
GeirArne          1   1
Joanna            1   1

Статическая проверка типов

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

Проект Mypy

Mypy был создан Юккой Лехтосало во время его обучения в аспирантуре Кембриджа примерно в 2012 году. Изначально Mypy задумывался как вариант Python с бесшовной динамической и статической типизацией. См. слайды Юкки с PyCon Finland 2012 для примеров первоначального видения Mypy.

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

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

Запуск Mypy

Перед тем как запустить Mypy в первый раз, необходимо установить программу. Это проще всего сделать, используя pip:

$ pip install mypy

Установив Mypy, вы можете запускать его как обычную программу командной строки:

$ mypy my_program.py

Запуск Mypy на вашем my_program.py Python-файле проверит его на наличие ошибок типа без фактического выполнения кода.

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

$ mypy --help
usage: mypy [-h] [-v] [-V] [more options; see below]
            [-m MODULE] [-p PACKAGE] [-c PROGRAM_TEXT] [files ...]

Mypy is a program that will type check your Python code.

[... The rest of the help hidden for brevity ...]

Кроме того, в онлайн-документации командной строки Mypy есть много информации.

Давайте рассмотрим некоторые из наиболее распространенных вариантов. Прежде всего, если вы используете сторонние пакеты без подсказок типов, вы можете захотеть заглушить предупреждения Mypy об этом. Это можно сделать с помощью опции --ignore-missing-imports.

В следующем примере используется Numpy для вычисления и печати косинуса нескольких чисел:

 1 # cosine.py
 2
 3 import numpy as np
 4
 5 def print_cosine(x: np.ndarray) -> None:
 6     with np.printoptions(precision=3, suppress=True):
 7         print(np.cos(x))
 8
 9 x = np.linspace(0, 2 * np.pi, 9)
10 print_cosine(x)

Обратите внимание, что np.printoptions() доступен только в версии 1.15 и более поздних версиях Numpy. Запуск этого примера выводит на консоль несколько чисел:

$ python cosine.py
[ 1.     0.707  0.    -0.707 -1.    -0.707 -0.     0.707  1.   ]

Фактический вывод этого примера не важен. Однако обратите внимание, что аргумент x в строке 5 аннотирован np.ndarray, поскольку мы хотим вывести косинус полного массива чисел.

Вы можете запустить Mypy на этом файле, как обычно:

$ mypy cosine.py 
cosine.py:3: error: No library stub file for module 'numpy'
cosine.py:3: note: (Stub files are from https://github.com/python/typeshed)

Возможно, эти предупреждения не будут иметь для вас особого смысла, но вы скоро узнаете о stubs и typeshed. По сути, эти предупреждения можно воспринимать как сообщение Mypy о том, что пакет Numpy не содержит подсказок типов.

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

$ mypy --ignore-missing-imports cosine.py 
Success: no issues found in 1 source file

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

Два менее навязчивых способа работы со сторонними пакетами - это использование комментариев типов или конфигурационных файлов.

В таком простом примере, как приведенный выше, вы можете заглушить предупреждение numpy, добавив комментарий типа к строке, содержащей импорт:

 3import numpy as np  # type: ignore

Литерал # type: ignore указывает Mypy игнорировать импорт Numpy.

Если у вас несколько файлов, возможно, будет проще отслеживать, какие импорты игнорировать, в конфигурационном файле. Mypy читает файл с именем mypy.ini в текущем каталоге, если он есть. Этот конфигурационный файл должен содержать секцию [mypy] и может содержать специфические для модуля секции вида [mypy-module].

Следующий конфигурационный файл будет игнорировать то, что в Numpy отсутствуют подсказки типов:

# mypy.ini

[mypy]

[mypy-numpy]
ignore_missing_imports = True

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

Добавление заглушек

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

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

$ pip install parse

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

 1 # parse_name.py
 2
 3 import parse
 4
 5 def parse_name(text: str) -> str:
 6     patterns = (
 7         "my name is {name}",
 8         "i'm {name}",
 9         "i am {name}",
10         "call me {name}",
11         "{name}",
12     )
13     for pattern in patterns:
14         result = parse.parse(pattern, text)
15         if result:
16             return result["name"]
17     return ""
18
19 answer = input("What is your name? ")
20 name = parse_name(answer)
21 print(f"Hi {name}, nice to meet you!")

Основной поток задан в последних трех строках: запрос имени, разбор ответа и печать приветствия. Пакет parse вызывается в строке 14, чтобы попытаться найти имя, основанное на одном из шаблонов, перечисленных в строках 7-11.

Программа может быть использована следующим образом:

$ python parse_name.py
What is your name? I am Geir Arne
Hi Geir Arne, nice to meet you!

Обратите внимание, что даже если я отвечаю I am Geir Arne, программа понимает, что I am не является частью моего имени.

Давайте добавим в программу небольшую ошибку и посмотрим, сможет ли Mypy помочь нам ее обнаружить. Измените строку 16 с return result["name"] на return result. Это вернет объект parse.Result вместо строки, содержащей имя.

Следующий запуск Mypy на программе:

$ mypy parse_name.py 
parse_name.py:3: error: Cannot find module named 'parse'
parse_name.py:3: note: (Perhaps setting MYPYPATH or using the
                       "--ignore-missing-imports" flag would help)

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

$ mypy parse_name.py --ignore-missing-imports
Success: no issues found in 1 source file

К сожалению, игнорирование импорта означает, что у Mypy нет возможности обнаружить ошибку в нашей программе. Лучшим решением было бы добавить подсказки типов в сам пакет Parse. Поскольку Parse имеет открытый исходный код, вы можете добавить типы в исходный код и отправить запрос на исправление.

Альтернативный вариант - добавить типы в файл-заглушку. Файл-заглушка - это текстовый файл, содержащий сигнатуры методов и функций, но не их реализации. Их основная функция - добавлять подсказки о типах в код, который вы по каким-то причинам не можете изменить. Чтобы показать, как это работает, мы добавим несколько заглушек для пакета Parse.

Прежде всего, вам следует поместить все файлы-заглушки в один общий каталог и установить переменную окружения MYPYPATH, чтобы она указывала на этот каталог. На Mac и Linux переменную MYPYPATH можно установить следующим образом:

$ export MYPYPATH=/home/gahjelle/python/stubs

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

Далее создайте файл в каталоге stubs, который назовите parse.pyi. Он должен быть назван по имени пакета, для которого вы добавляете подсказки типов, с суффиксом .pyi. Пока оставьте этот файл пустым. Затем снова запустите Mypy:

$ mypy parse_name.py
parse_name.py:14: error: Module has no attribute "parse"

Если вы все настроили правильно, вы должны увидеть новое сообщение об ошибке. Mypy использует новый файл parse.pyi, чтобы выяснить, какие функции доступны в пакете parse. Поскольку файл-заглушка пуст, Mypy предполагает, что parse.parse() не существует, и выдает ошибку, которую вы видите выше.

В следующем примере не добавляются типы для всего пакета parse. Вместо этого он показывает подсказки типов, которые нужно добавить, чтобы Mypy проверил использование parse.parse():

# parse.pyi

from typing import Any, Mapping, Optional, Sequence, Tuple, Union

class Result:
    def __init__(
        self,
        fixed: Sequence[str],
        named: Mapping[str, str],
        spans: Mapping[int, Tuple[int, int]],
    ) -> None: ...
    def __getitem__(self, item: Union[int, str]) -> str: ...
    def __repr__(self) -> str: ...

def parse(
    format: str,
    string: str,
    evaluate_result: bool = ...,
    case_sensitive: bool = ...,
) -> Optional[Result]: ...

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

Наконец-то Mypy смог обнаружить ошибку, которую мы ввели:

$ mypy parse_name.py
parse_name.py:16: error: Incompatible return value type (got
                         "Result", expected "str")

Это указывает на строку 16 и на то, что мы возвращаем объект Result, а не строку имени. Измените return result обратно на return result["name"] и снова запустите Mypy, чтобы убедиться, что он доволен.

Типизированный

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

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

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

Другие программы проверки статических типов

В этом руководстве мы в основном сосредоточились на проверке типов с помощью Mypy. Однако в экосистеме Python существуют и другие статические программы проверки типов.

Редактор PyCharm поставляется с собственной программой проверки типов. Если вы используете PyCharm для написания кода на Python, он будет автоматически проверен на типы.

Facebook разработала Pyre. Одна из его заявленных целей - быть быстрым и производительным. Несмотря на некоторые различия, Pyre функционирует в основном аналогично Mypy. Если вы хотите попробовать Pyre, обратитесь к документации.

Кроме того, Google создал Pytype. Эта программа проверки типов также работает в основном так же, как и Mypy. Помимо проверки аннотированного кода, Pytype поддерживает проверку типов в неаннотированном коде и даже автоматическое добавление аннотаций в код. Более подробную информацию можно найти в документе quickstart.

Использование типов во время выполнения

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

Однако подсказки типов доступны во время выполнения в словаре __annotations__, и вы можете использовать их для проверки типов, если хотите. Прежде чем вы побежите писать свой собственный пакет для проверки типов, вы должны знать, что уже есть несколько пакетов, делающих это за вас. Посмотрите на Enforce, Pydantic или Pytypes для некоторых примеров.

Еще одно применение подсказок типов - перевод кода Python на язык C и его компиляция для оптимизации. Популярный проект Cython использует гибридный язык C/Python для написания статически типизированного кода на Python. Однако, начиная с версии 0.27, Cython также поддерживает аннотации типов. Недавно стал доступен проект Mypyc. Хотя он еще не готов для широкого использования, он может компилировать некоторый аннотированный типами код Python в расширения C.

Заключение

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

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

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

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