Почему мы используем проверку типов?
Помощь типов внесла существенные изменения в систему нашей разработки платформы 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:
- Внесите минимум изменений, необходимых для выполнения
mypy
без ошибок. - Добавьте шаг
mypy
в сборку CI и хуки pre-commit. С этого момента появления новых проблем с типами уже можно не бояться. Теперь всё будет только улучшаться. - Добавляйте типизацию в весь новый код. Обучите вашу команду работе с типами и
mypy
. - Постепенно добавляйте типы ко всему остальному коду.
Подводные камни типизации
Несмотря на все свои преимущества, проверка типов в 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
, либо его подклассом.
Наследование здесь выглядит так:
object
⤑ BaseModel
⤑ Product
Параметр 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. Он ведёт технический блог этой компании и пишет в свободное время фантастику.
Читайте также:
- 7 ошибок Python, от которых стоит немедленно избавиться
- 15 Python пакетов, которые нужно попробовать
- Избегайте этих нелепых ошибок при работе с Python
Перевод статьи Òscar Vilaplana: Type Checking in Python.