Почему мы используем проверку типов?

Помощь типов внесла существенные изменения в систему нашей разработки платформы Tiqets. Среди очевидных преимуществ: 

  • Понижение когнитивной нагрузки при работе с кодом. Типы параметров и возвращаемых значений ясно указываются и проверяются. Никакого угадывания и сюрпризов.
  • Раннее обнаружение ошибок. Если мы возвращаем неверный тип, забываем, что переменная может быть None или случайно объявляем какую-либо переменную повторно, модуль проверки типов сообщает об этом.
  • Проверка данных. Мы объявляем классы, используя attrs, что позволяет определять типы атрибутов, равно как и проверять их при выполнении. Мы также можем переключиться на Pydantic, который предлагает улучшенный синтаксис.
  • Отсутствие тривиальных модульных тестов. Проверка типов позволяет избежать написания и поддержки банальных модульных тестов.

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

Как это выглядит?

from typing import List

import attr
from attr.validators import instance_of
from pydantic import BaseModel

def sum_positives(numbers: List[int]) -> int:
    return sum(num for num in numbers if num > 0)

class Product:  # mypy это проверит
    id: int
    title: str

class Venue(BaseModel):  # Pydantic выполнит проверку при выполнении
    id: int
    name: str

@attr.s
class Ticket:
    # attr осуществит проверку при выполнении
    id = attr.ib(type=int, validator=instance_of(int))

Какие типы можно использовать?

Помимо простых типов (int, str, и т.д.) вы можете использовать следующие, которые определены в модуле typing (добавленном в Python 3.5):

  • Коллекции: Tuple, Dict, MutableMapping, List, NamedTuple, etc.
  • Компоновщики: Union, Optional
  • Callable
  • Обобщённые: TypeVar, Generic.

Как начать использовать типизацию в Python?

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

Вот пошаговое руководство, которому мы следовали в Tiqets:

  1. Внесите минимум изменений, необходимых для выполнения mypy без ошибок.
  2. Добавьте шаг mypy в сборку CI и хуки pre-commit. С этого момента появления новых проблем с типами уже можно не бояться. Теперь всё будет только улучшаться.
  3. Добавляйте типизацию в весь новый код. Обучите вашу команду работе с типами и mypy.
  4. Постепенно добавляйте типы ко всему остальному коду.
Наш переход к типизированному Python.

Подводные камни типизации

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

Ложное чувство безопасности. Модули проверки типов не будут перехватывать абсолютно все ошибки. Кроме того, типы не будут проверятся при выполнении, пока вы не начнёте использовать библиотеку вроде attrs или Pydantic. Рассматривайте проверку типов как дополнительный шаг безопасности, а не как её полное замещение.

Отсутствие оптимизации. Python не будет использовать знание типов для оптимизации вашего кода. 

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

Определения типов могут становиться пугающими. Например, Dict[str, Union[str, Union[int, bool, Venue]]]. В данном случае мы советуем следующее: если у вас сложный тип, то, возможно, вы в нём ошиблись и нужно внести корректировки.

За гранью основ

Определяйте типы

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

from typing import Sequence, TypeVar

T = TypeVar('T')      # Объявление переменной типа

def first(l: Sequence[T]) -> T:   # Обобщённая функция
    return l[0]

Использование типов в конструкторах classmethod

TypeVar также полезна при объявлении типов конструктора classmethod. 

В примере ниже BaseModel.from_dict возвращает BaseModel, а в подклассе Product(BaseModel), Product.from_dict возвращает Product. Тип T должен быть либо BaseModel, либо его подклассом.

Наследование здесь выглядит так:

objectBaseModelProduct

Параметр bound='BaseModel' устанавливает BaseModel как верхнюю границу типа: он может быть BaseModel или его подклассом, но не может быть меньше, чем BaseModel (т.е. object).

from typing import TypeVar, Dict, Any, Type

T = TypeVar('T', bound='BaseModel')
class BaseModel:
    def __init__(self, id: int):
        self.id = id

@classmethod
    def from_dict(cls: Type[T], values: Dict[str, Any]) -> T:
        return cls(**values)

class Product(BaseModel):
    def __init__(self, id: int, title: str):
        super().__init__(id=id)
        self.title = title

product = Product.from_dict({"id": 1, "title": "Great Product"})

Почему bound='BaseModel' вместо bound=BaseModel? Потому что, когда мы создавали TypeVar, BaseModel ещё не был определён. Не любите так делать? Мы тоже — поэтому можно рассмотреть включение отложенного вычисления аннотаций (см. PEP 563).

Пример ниже пройдёт проверку типов, но провалится при выполнении, т.к. при вызове TypeVar BaseModel должен быть уже определён. Это пример случая, в котором проверка типов не уловит проблему.

from __future__ import annotations
from typing import TypeVar, Dict, Any, Type

T = TypeVar('T', bound=BaseModel)

...

Обнаружение небезопасного ввода

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

from typing import TypeVar

UserProvidedStr = TypeVar('UserProvidedStr')

def do_something(value: str) -> None:
    pass

def sanitize(value: UserProvidedStr) -> str:
    return 'Sanitized value'

def handle_request(unsafe: UserProvidedStr, safe: str):
    do_something(unsafe)  # ошибка: аргумент 1 для "do_something" имеет несовместимый тип "UserProvidedStr"; ожидается "str"
    do_something(safe)    # Работает
    do_something(sanitize(unsafe))  # Работает

Не просто типы: литералы

Типизация в Python относится не только к типам. Возьмём, к примеру open:

  • В режиме "r" он будет считывать текст.
  • В режиме "rb" он будет считывать байты.

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

from typing import Literal

@overload
def open(fn: str, mode: Literal['r', 'w']) -> IO[Text]: ...

@overload
def open(fn: str, mode: Literal['rb', 'rw') -> IO[bytes]: ...

def open(fn: str, mode: str):
	# Здесь помещается реализация

Типизированные словари

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

from typing import TypedDict

class ProductDict(TypedDict):
    id: int
    name: str

product: ProductDict = {'id': 1, 'name': 'Best Product'}

# Эта часть проверку не пройдёт
broken_product: ProductDict = {'id': '1', 'name': 'Best Product'}

Финальные классы, методы, атрибуты и переменные

В Python 3.8 вы можете определять классы, методы и переменные как финальные.

  • У финального класса не может быть подкласса.
  • Финальный метод не может быть перегружен.
  • Финальная переменная не может быть переназначена.
from typing import final, Final

@final
class Base:
    pass

class Derived(Base):  # ошибка: нельзя наследовать от финального класса "Base"
    pass

ID: Final = 3

ID = 4  # ошибка: нельзя присваивать к финальному имени "ID"

Sphynx

Используйте sphynx-autodoc-typehints, чтобы позволить Sphynx применять определённые вами типы при генерации им документации.

Более ухищрённые определения типов

Как насчёт утиной типизации?

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

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

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

from typing import Protocol, TypeVar

class Connection:
    def close(self) -> None:
        pass

class Bug:
    def close(self, user_id: int) -> None:
        pass

# Connection и Bug не знают о протоколе ниже.

class Closeable(Protocol):
    def close(self) -> None: ...

def do_close(c: Closeable) -> None:
    c.close()

# Это в порядке
do_close(Connection())

# Это провалит проверку mypy
do_close(Bug())

# ошибка: аргумент 1 для "do_close" имеет несовмесимый тип "Bug";
#        ожидается "Closeable"
# обратите внимание: следующий член(ы) "Bug" конфликтуют:
# обратите внимание:     Ожидается:
# обратите внимание:         def close(self) -> None
# обратите внимание:     Получено:
# обратите внимание:         def close(self, id: int) -> None

Обобщённые типы

Контейнерные классы также можно проверить на типы. Чтобы это сделать, мы должны определить новый тип, который будет представлять тип, содержащийся в классе. Ниже мы определяем для нашего контейнера тип T. Когда он будет содержать число, например Container(123), T будет int; когда же в нём будет строка, T будет str.

from typing import TypeVar, Dict, Any, Type, Generic

T = TypeVar('T')

class Container(Generic[T]):
    def __init__(self, value: T):
        self._value = value

def get(self) -> T:
        return self._value

def read_int_container(c: Container[int]) -> int:
    return c.get()

def read_str_container(c: Container[str]) -> str:
    return c.get()

# Это работает:
read_int_container(Container(123))
read_str_container(Container("hello"))

# Это не пройдёт проверку mypy
# ошибка: аргумент 1 для "Container" имеет несовместимый тип "str"; ожидается "int"
read_int_container(Container("hello"))

Разные возвращаемые типы

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

from typing import Union

def double(value: Union[int, str]) -> Union[int, str]:
    return value * 2

reveal_type(double("a"))  # Тип Union[int, str]
reveal_type(double(1))    # Тип Union[int, str]

Несмотря на верность того, что double возвращает int или str, это не является исчерпывающей правдой: эта функция возвращает int, когда её вводный параметр int, и str, когда параметр str.

Правильный же способ определения типа double будет несколько многословен.

from typing import Union, overload

def double(value: Union[int, str]) -> Union[int, str]:
    return value * 2

@overload
def better_double(value: int) -> int:
    pass

@overload
def better_double(value: str) -> str:
    pass

def better_double(value):
    return value * 2

reveal_type(better_double("a"))  # Тип str
reveal_type(better_double(1))    # Тип int

Решение сложностей при поиске типов

Модуль проверки типов будет искать тип в ближайшем пространстве имён. В последующем примере mypy подумает, что метод возвращает значения типа A.float, в то время, как на самом деле подразумевается возвращение им встроенного float.

class A:
    def float(self) -> float:
        return 1.0

Вам потребуется явно указать, что вы имеете в виду builtins.float.

import builtins

class A:
    def float(self) -> builtins.float:
        return 1.0

Приведение типов

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

from typing import cast, List

value = [130]

value_float = cast(List[float], value)

reveal_type(value_float)  # Тип выведен как List[float]
value_float == [130]      # Но это выражение по-прежнему верно

Используйте cast с осторожностью: очевидно, что так можно с лёгкостью внести сложноуловимые баги. 

Последний выход: игнорирование ошибок

Если больше ничто не работает, можете добавить к строке комментарий # type: ignore, который отключит для неё проверку типов.

Перспективы типизации в Python

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

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

Меньше верблюжьего регистра и импортов: использование list[] вместо typing.List[int].

Более сжатый синтаксис:

  • int | float вместо Union[int, float]
  • ?str = "" вместо Optional[str]

Какой модуль проверки мы используем в Tiqets?

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

Существует также несколько интересных альтернативных реализаций:

  • pyright (Microsoft) быстрее, но выполняется в node.js, а не в Python.
  • pytype (Google) может выводить типы из неаннотированного кода. Если вам интересны подробности, ознакомьтесь с этим сравнением между mypy и pytype.
  • pyre (Facebook) может выполнять инкрементную проверку.

Где следует добавлять типы?

Мы добавляем типы во всём коде.

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

Рассмотрите добавление типов везде, где бы вы добавляли модульные тесты.

Об авторе

Оскар Вилаплана является инженером ПО в Tiqets. Он ведёт технический блог этой компании и пишет в свободное время фантастику.

Читайте также:


Перевод статьи Òscar Vilaplana: Type Checking in Python.

Предыдущая статьяСинхронизация в Java. Часть 2
Следующая статьяПочему сниппеты кода со StackOverflow могут повредить ваш проект