Руководство: Подсказки типов в Python

Оглавление

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

Введение

С выходом версии 3.5 в Python появились подсказки типов: аннотации к коду, которые с помощью дополнительных инструментов позволяют проверить, правильно ли вы используете код.

Давние пользователи Python могут содрогнуться при мысли о том, что новому коду для корректной работы потребуется подсказка типов, но нам не стоит беспокоиться: сам Гвидо в PEP 484 писал: "Во время выполнения программы проверка типов не производится."

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

Для науки о данных - и для специалиста по науке о данных - подсказки типа неоценимы по нескольким причинам:

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

Конечно, как и всегда в случае с документированием и тестированием, это инвестиция: она стоит больше времени на начальном этапе, но значительно экономит ваше время (и время вашего коллеги) в долгосрочной перспективе.

Примечание: Подсказки типов были перенесены и на Python 2.7 (он же Legacy Python). Однако для работы этой функциональности требуются комментарии. Кроме того, никто не должен использовать Legacy Python в 2019 году: он менее красив и имеет всего пару месяцев обновлений до прекращения какой-либо поддержки.

Начало работы с типами

Здравствуй мир подсказок типов

# hello_world.py
def hello_world(name: str = 'Joe') -> str:
    return f'Hello {name}'

Здесь мы добавили два элемента подсказки типа. Первый - : str после имени, второй - -> str в конце сигнатуры.

<<<Синтаксис работает так, как и следовало ожидать: мы обозначаем имя как тип str и указываем, что функция hello_world должна выводить str. Если мы используем нашу функцию, то она делает то, о чем говорит:

> hello_world(name='Mark')
'Hello Mark'

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

> hello_world(name=2)
'Hello 2'

Что происходит? Ну, как я уже писал во введении, никакой проверки типов во время выполнения не происходит.

Пока код не вызовет исключение, все будет работать нормально.

Что же тогда делать с этими определениями типов? Ну, вам нужна программа проверки типов или IDE, которая читает и проверяет типы в вашем коде (например, PyCharm).

Проверка типа вашей программы

Существует, по крайней мере, четыре основные реализации проверки типов: Mypy, Pyright, pyre и pytype:

  • Mypy активно развивается, в частности, Гвидо ван Россумом, создателем Python.
  • Pyright разработан компанией Microsoft и очень хорошо интегрируется с ее замечательной Visual Studio Code;
  • Pyre был разработан Facebook с целью быть быстрым (хотя mypy недавно стал намного быстрее);
  • Pytype разработан компанией Google и, помимо проверки типов, как это делают остальные, может выполнять проверку типов (и добавлять аннотации) на неаннотированном коде.

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

$ pip install mypy
$ mypy hello_world.py 

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

Более продвинутые типы

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

# tree.py
from typing import Tuple, Iterable, Dict, List, DefaultDict
from collections import defaultdict

def create_tree(tuples: Iterable[Tuple[int, int]]) -> DefaultDict[int, List[int]]:
    """
    Return a tree given tuples of (child, father)

    The tree structure is as follows:

        tree = {node_1: [node_2, node_3], 
                node_2: [node_4, node_5, node_6],
                node_6: [node_7, node_8]}
    """
    tree = defaultdict(list) 
    for child, father in tuples:
        if father:
            tree[father].append(child)
    return tree

print(create_tree([(2.0,1.0), (3.0,1.0), (4.0,3.0), (1.0,6.0)]))
# will print
# defaultdict( 'list'="">, {1.0: [2.0, 3.0], 3.0: [4.0], 6.0: [1.0]}

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

  • Прежде всего, тип Iterable для переменной tuples. Этот тип указывает на то, что объект должен соответствовать спецификации collections.abc.Iterable (т.е. реализовывать __iter__). Это необходимо, так как в цикле for мы выполняем итерацию над tuples;
  • Мы задаем типы внутри наших контейнерных объектов: Iterable содержит Tuple, Tuples состоит из пар int и так далее.

Ок, попробуем проверить его!

$ mypy tree.py
tree.py:14: error: Need type annotation for 'tree'

О-о-о, что происходит? В основном Mypy жалуется на эту строку:

tree = defaultdict(list)

Хотя мы знаем, что возвращаемый тип должен быть DefaultDict[int, List[int]], Mypy не может сделать вывод о том, что дерево действительно имеет такой тип. Мы должны помочь ему, указав тип дерева. Это можно сделать аналогично тому, как мы делаем это в сигнатуре:

tree: DefaultDict[int, List[int]] = defaultdict(list)

Если теперь снова запустить Mypy, то все будет в порядке:

$ mypy tree.py
$

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

Иногда в нашем коде одни и те же составные типы используются снова и снова. В приведенном выше примере таким случаем может быть Tuple[int, int]. Чтобы сделать наши намерения более понятными (и сократить код), мы можем использовать псевдонимы типов. Использовать псевдонимы типов очень просто: достаточно присвоить тип переменной и использовать эту переменную в качестве нового типа:

Relation = Tuple[int, int]

def create_tree(tuples: Iterable[Relation]) -> DefaultDict[int, List[int]]:
    """
    Return a tree given tuples of (child, father)

    The tree structure is as follow:

        tree = {node_1: [node_2, node_3], 
                node_2: [node_4, node_5, node_6],
                node_6: [node_7, node_8]}
    """
    # convert to dict
    tree: DefaultDict[int, List[int]] = defaultdict(list) 
    for child, father in tuples:
        if father:
            tree[father].append(child)

    return tree

Дженерики

Опытные программисты статически типизированных языков могли заметить, что определение Relation как кортежа целых чисел является несколько ограничивающим. Разве create_tree не может работать с float, или строкой, или только что созданным специальным классом?

В принципе, ничто не мешает нам использовать его таким образом:

# tree.py
from typing import Tuple, Iterable, Dict, List, DefaultDict
from collections import defaultdict

Relation = Tuple[int, int]

def create_tree(tuples: Iterable[Relation]) -> DefaultDict[int, List[int]]:
    ...

print(create_tree([(2.0,1.0), (3.0,1.0), (4.0,3.0), (1.0,6.0)]))
# will print
# defaultdict( 'list'="">, {1.0: [2.0, 3.0], 3.0: [4.0], 6.0: [1.0]})

Однако если мы спросим мнение Mypy о коде, то получим ошибку:

$ mypy tree.py
tree.py:24: error: List item 0 has incompatible type 'Tuple[float, float]'; expected 'Tuple[int, int]'
...

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

from typing import TypeVar

T = TypeVar('T')

Relation = Tuple[T, T]
def create_tree(tuples: Iterable[Relation]) -> DefaultDict[T, List[T]]:
    ...
    tree: DefaultDict[T, List[T]] = defaultdict(list)
    ...

print(create_tree([(2.0,1.0), (3.0,1.0), (4.0,3.0), (1.0,6.0)]))

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

Обратите внимание, что важно, чтобы ‘T’ внутри TypeVar был равен имени переменной T.

Общие классы: Нужно ли было использовать TypeVar?

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

Это важно, так как поиск ключа в Python работает путем вычисления хэша ключа. Если ключ не является хэшируемым, то поиск будет прерван.

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

Некоторые примеры:

  • typing.Iterable укажет, что мы ожидаем, что объект будет итерабельным;
  • typing.Iterator укажет, что мы ожидаем, что объект будет итератором;
  • typing.Reversible будет означать, что мы ожидаем, что объект будет обратимым;
  • typing.Hashable укажет, что мы ожидаем от объекта реализации __hash__;
  • typing.Sized будет указывать на то, что мы ожидаем от объекта реализации __len__;
  • typing.Sequence укажет, что мы ожидаем, что объект будет Sized, Iterable, Reversible и реализует count, index.

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

from typing import Iterable, TypeVar

T = TypeVar('T')

def return_values() -> Iterable[float]:
    yield 4.0
    yield 5.0
    yield 6.0

def chain(*args: Iterable[T]) -> Iterable[T]:
    for arg in args:
        yield from arg

print(list(chain([1, 2, 3], return_values(), 'string')))
[1, 2, 3, 4.0, 5.0, 6.0, 's', 't', 'r', 'i', 'n', 'g']

Функция return_values немного надуманная, но она иллюстрирует суть: функции chain все равно, кто мы такие, лишь бы были итерируемы!

Any, Union и Optional

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

  • Any делает то, что вы думаете, отмечая, что объект не имеет никакого определенного типа
  • Union может использоваться как Union[A, B] для обозначения того, что объект может иметь тип A или B
  • Optional используется как Optional[A] для указания на то, что объект имеет тип A или None. В отличие от настоящих функциональных языков, мы не можем рассчитывать на безопасность при передаче Optionals, поэтому будьте осторожны. Он эффективно работает как Union[A, None]. Любители функционального программирования узнают свои любимые Option (если вы пришли из Scala) или Maybe (если вы пришли из Haskell).

Callables

Python поддерживает передачу функций в качестве аргументов другим функциям, но как их аннотировать?

Решением является использование Callable[[arg1, arg2], return_type]. Если аргументов много, то их можно сократить с помощью многоточия Callable[..., return_type].

В качестве примера предположим, что мы хотим написать собственную функцию map/reduce (отличную от MapReduce в Hadoop!). Мы можем сделать это с помощью аннотаций типов, например, так:

# mr.py
from functools import reduce
from typing import Callable, Iterable, TypeVar, Union, Optional

T = TypeVar('T')
S = TypeVar('S')
Number = Union[int, float]

def map_reduce(
    it: Iterable[T],
    mapper: Callable[[T], S],
    reducer: Callable[[S, S], S],
    filterer: Optional[Callable[[S], bool]]
) -> S:
    mapped = map(mapper, it)
    filtered = filter(filterer, mapped)
    reduced = reduce(reducer, filtered)
    return reduced


def mapper(x: Number) -> Number:
    return x ** 2


def filterer(x: Number) -> bool:
    return x % 2 == 0


def reducer(x: Number, y: Number) -> Number:
    return x + y


results = map_reduce(
    range(10),
    mapper=mapper,
    reducer=reducer,
    filterer=filterer
)
print(results)

Просто взглянув на сигнатуру map_reduce, можно понять, как данные проходят через функцию: mapper получает T и выдает S, filterer, если не None, фильтрует S, а reducers объединяет S в конечную S.

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

Внешние модули

Аннотирование нашего кода - это хорошо, но как быть с другими модулями, которые мы можем использовать? Специалисты по исследованию данных часто импортируют данные, скажем, из NumPy или pandas. Можно ли аннотировать функции, принимающие на вход массивы NumPy?

Что ж, есть только один способ узнать это:

# rescale.py
import numpy as np

def rescale_from_to(array1d: np.ndarray, 
                    from_: float=0.0, to: float=5.0) -> np.ndarray:
    min_ = np.min(array1d)
    max_ = np.max(array1d)
    rescaled = (array1d - min_) * (to - from_) / (max_ - min_) + from_
    return rescaled

my_array: np.array = np.array([1, 2, 3, 4])

rescaled_array = rescale_from_to(my_array)

Теперь мы можем проверить его:

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

Уже на первой строке происходит сбой! Дело в том, что в numpy нет аннотаций типов, поэтому Mypy не может знать, как выполнить проверку (из сообщения об ошибке следует, что вся стандартная библиотека имеет аннотации типов благодаря проекту typeshed.)

Существует несколько способов решения этой проблемы:

  • Использовать mypy --ignore-missing-import rescale.py в командной строке. Недостатком этого способа является то, что он будет игнорировать и ошибки (например, неправильное написание имени пакета)
  • Добавить # type: ignore после имени модуля
    import numpy as np  # type: ignore
  • Мы можем создать .mypy.ini файл в нашей домашней папке (или mypy.ini в папке, где находится наш проект) со следующим содержанием
# mypy.ini
[mypy]
[mypy-numpy]
ignore_missing_imports = True

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

Заключение

Мы узнали, как создавать функции и модули с подсказками типов, а также о различных возможностях сложных типов, дженериков и TypeVar. Кроме того, мы рассмотрели, как программа проверки типов, такая как Mypy, может помочь нам выявить ранние ошибки в нашем коде.

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

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

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

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