Как стать автором
Обновить
63.55
Skillfactory
Учим работать в IT на курсах и в магистратурах

Сказ о поддержке подсказок типов для функции сложения в Python, или Вот как непросто бывает идти в IT…

Уровень сложностиСредний
Время на прочтение8 мин
Количество просмотров5.5K
Автор оригинала: zurtex

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


Думаю, многие отнеслись к этому скептически, а потому посмотрим на одну полностью выдуманную ситуацию. Если явно не указано иное, всё в ней вымышлено. Редактируя текст, я понял, что в попытках 4–6 ошибок даже больше, чем предполагалось, но переписывать снова не буду.


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


def slow_add(a, b):
    time.sleep(0.1)
    return a + b

Типы


Вы всегда работали с традиционной утиной типизацией: если a и b не складываются, то функция выдаёт исключение. Но только что вы отказались от поддержки Python 2, так что пользователи требуют подсказок типов.


Попытка №1


Вы добавляете простые подсказки:


def slow_add(a: int, b: int) -> int:
    time.sleep(0.1)
    return a + b

Все тесты пройдены, кодовая база соответствует требованиям mypy, и в примечаниях к релизу вы пишете: «Добавлена поддержка подсказок типов!».


Попытка №2


Пользователи сразу заваливают GitHub Issues жалобами! MyPy не работает, потому что в slow_add передаются числа с плавающей точкой. Так сборка нарушается, а из-за внутренних политик организаций, по которым всегда необходимо увеличивать покрытие подсказок типа, откатиться до старой версии нельзя. Выходные ваших пользователей испорчены.


Вы исследуете проблему, и оказывается, что для цепочки типов ints -> float -> complex MyPy поддерживает совместимость утиной типизации. Круто!


Вот новый релиз:


def slow_add(a: complex, b: complex) -> complex:
    time.sleep(0.1)
    return a + b

Забавно, что это нотка MyPy, а не стандарт PEP…


Попытка №3


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


В Python 3 появились числовые абстрактные базовые классы, поэтому идеальный вариант — просто подсказывать типы как numbers.Number.


Но MyPy не считает числами ни целые числа, ни числа с плавающей запятой, ни десятичные дроби. Почитав о typing, вы догадываетесь: дело в Decimals и Union:


def slow_add(
    a: Union[complex, Decimal], b: Union[complex, Decimal]
) -> Union[complex, Decimal]:
    time.sleep(0.1)
    return a + b

Оx, нет! Теперь MyPy жалуется, что в Decimal нельзя добавить другие типы чисел. Ну, в любом случае, вы хотели не этого… Ещё немного чтения — и вы попробуете перегрузку:


@overload
def slow_add(a: Decimal, b: Decimal) -> Decimal:
    ...

@overload
def slow_add(a: complex, b: complex) -> complex:
    ...

def slow_add(a, b):
    time.sleep(0.1)
    return a + b

А теперь строгий MyPy жалуется на пропущенную в slow_add аннотацию типа. Прочитав об этой проблеме вы понимаете, что @overload полезна только пользователям, а тело функции больше не проверяется в mypy. Хорошо, что в обсуждении проблемы нашёлся пример реализации решения проблемы:


T = TypeVar("T", Decimal, complex)

def slow_add(a: T, b: T) -> T:
    time.sleep(0.1)
    return a + b

Попытка №4


Вы выкатываете новый релиз. Через несколько дней начинают жаловаться всё больше пользователей. Очень увлечённый пользователь объясняет особо критичный случай применения с кортежами: slow_add((1, ), (2, )). А добавлять каждый тип раз за разом не хочется, должен же быть способ лучше!


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


T = TypeVar("T")

class Addable(Protocol):
    def __add__(self: T, other: T, /) -> T:
        ...

def slow_add(a: Addable, b: Addable) -> Addable:
    time.sleep(0.1)
    return a + b

Отвлечёмся слегка


Вы вновь выкатываете новый релиз, отмечая в Release Notes, что «теперь поддерживается любой добавляемый тип».


Пользователь кортежа снова сразу же жалуется и говорит, что подсказки не работают для кортежей длиннее, вот таких: slow_add((1, 2), (3, 4)). Это странно, ведь вы проверили несколько длин кортежей.


После дебага пользовательской среды пробежками «туда-обратно» по GitHub вы увидели, что pyright выдаёт код выше как ошибку, а MyPy — нет, даже в строгом режиме. Предположительно MyPy ведёт себя правильно, поэтому вы продолжаете блаженствовать, не обращая внимания на фундаментальную ошибку.


Если MyPy работает неверно, то Pyright явно должен выдавать ошибку. Об этом я сообщил в оба проекта, и подробности о решении, если вам интересно, объяснил мейнтейнер. К сожалению, эти подробности не учитывались до «Попытки №7».

Попытка №5


Неделю спустя пользователь сообщает о проблеме: в последнем релизе говорится, что «теперь поддерживается любой добавляемый тип», но у них есть куча классов, реализовать которые можно только с помощью __radd__, а новая версия выдаёт ошибки typing.


Вы пробуете несколько подходов. Лучше всего решает проблему этот:


T = TypeVar("T")

class Addable(Protocol):
    def __add__(self: T, other: T, /) -> T:
        ...

class RAddable(Protocol):
    def __radd__(self: T, other: Any, /) -> T:
        ...

@overload
def slow_add(a: Addable, b: Addable) -> Addable:
    ...

@overload
def slow_add(a: Any, b: RAddable) -> RAddable:
    ...

def slow_add(a: Any, b: Any) -> Any:
    time.sleep(0.1)
    return a + b

Досадно, что теперь у MyPy нет согласованного подхода к телу функции. А ещё не вышло полностью выразить условие, что когда b — RAddable, a не должно быть того же типа, потому что аннотации типов Python ещё не поддерживают исключения типов.


Попытка №6


Пару дней спустя новый пользователь жалуется, что получает ошибки подсказки типа, пытаясь возвести вывод нашей основной функции в степень: pow(slow_add(1, 1), slow_add(1, 1)). Это не так уж плохо, вы быстро понимаете, что проблема заключается в аннотации протоколов. На самом деле аннотировать нужно не протоколы, а переменные типа:


T = TypeVar("T")

class Addable(Protocol):
    def __add__(self: T, other: T, /) -> T:
        ...

A = TypeVar("A", bound=Addable)

class RAddable(Protocol):
    def __radd__(self: T, other: Any, /) -> T:
        ...

R = TypeVar("R", bound=RAddable)

@overload
def slow_add(a: A, b: A) -> A:
    ...

@overload
def slow_add(a: Any, b: R) -> R:
    ...

def slow_add(a: Any, b: Any) -> Any:
    time.sleep(0.1)
    return a + b

Попытка №7


Пользователь кортежа снова с нами! Теперь он говорит, что MyPy в строгом режиме жалуется на выражение slow_add((1,), (2,)) == (1, 2):


Non-overlapping equality check (left operand type: "Tuple[int]", right operand type: "Tuple[int, int]")

Вы понимаете, что ничего не можете гарантировать в отношении типа возвращаемого из произвольного __add__ или __radd__ значения, а потому начинаете щедро разбрасываться Any:


class Addable(Protocol):
    def __add__(self: "Addable", other: Any, /) -> Any:
        ...

class RAddable(Protocol):
    def __radd__(self: "RAddable", other: Any, /) -> Any:
        ...

@overload
def slow_add(a: Addable, b: Any) -> Any:
    ...

@overload
def slow_add(a: Any, b: RAddable) -> Any:
    ...

def slow_add(a: Any, b: Any) -> Any:
    time.sleep(0.1)
    return a + b

Попытка №8


Пользователи просто сошли с ума! Хорошие автоматические предположения о типе, которые в прошлом релизе их IDE были, теперь исчезли! Ну, вы не можете подсказывать тип всего, но могли бы включить подсказки встроенных типов и, может быть, некоторых типов стандартной библиотеки, к примеру Decimal:


Вы решаете, что можно положиться на некоторые утиные типы MyPy, но проверяете этот код:


@overload
def slow_add(a: complex, b: complex) -> complex:
    ...

И понимаете, что MyPy выдаёт ошибку на что-то вроде slow_add(1, 1.0).as_integer_ratio(). Итак, в конечном счёте вы реализуете:


class Addable(Protocol):
    def __add__(self: "Addable", other: Any, /) -> Any:
        ...

class RAddable(Protocol):
    def __radd__(self: "RAddable", other: Any, /) -> Any:
        ...

@overload
def slow_add(a: int, b: int) -> int:
    ...

@overload
def slow_add(a: float, b: float) -> float:
    ...

@overload
def slow_add(a: complex, b: complex) -> complex:
    ...

@overload
def slow_add(a: str, b: str) -> str:
    ...

@overload
def slow_add(a: tuple[Any, ...], b: tuple[Any, ...]) -> tuple[Any, ...]:
    ...

@overload
def slow_add(a: list[Any], b: list[Any]) -> list[Any]:
    ...

@overload
def slow_add(a: Decimal, b: Decimal) -> Decimal:
    ...

@overload
def slow_add(a: Fraction, b: Fraction) -> Fraction:
    ...

@overload
def slow_add(a: Addable, b: Any) -> Any:
    ...

@overload
def slow_add(a: Any, b: RAddable) -> Any:
    ...

def slow_add(a: Any, b: Any) -> Any:
    time.sleep(0.1)
    return a + b

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


Попытка №9


Несколько месяцев спустя пользователь говорит, что использует встраиваемую версию Python, и в ней нет Decimal. Зачем же ваш пакет вообще его импортирует? Итак, теперь код выглядит так:


from __future__ import annotations

import time
from typing import TYPE_CHECKING, Any, Protocol, TypeVar, overload

if TYPE_CHECKING:
    from decimal import Decimal
    from fractions import Fraction

class Addable(Protocol):
    def __add__(self: "Addable", other: Any, /) -> Any:
        ...

class RAddable(Protocol):
    def __radd__(self: "RAddable", other: Any, /) -> Any:
        ...

@overload
def slow_add(a: int, b: int) -> int:
    ...

@overload
def slow_add(a: float, b: float) -> float:
    ...

@overload
def slow_add(a: complex, b: complex) -> complex:
    ...

@overload
def slow_add(a: str, b: str) -> str:
    ...

@overload
def slow_add(a: tuple[Any, ...], b: tuple[Any, ...]) -> tuple[Any, ...]:
    ...

@overload
def slow_add(a: list[Any], b: list[Any]) -> list[Any]:
    ...

@overload
def slow_add(a: Decimal, b: Decimal) -> Decimal:
    ...

@overload
def slow_add(a: Fraction, b: Fraction) -> Fraction:
    ...

@overload
def slow_add(a: Addable, b: Any) -> Any:
    ...

@overload
def slow_add(a: Any, b: RAddable) -> Any:
    ...

def slow_add(a: Any, b: Any) -> Any:
    time.sleep(0.1)
    return a + b

TL;DR


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


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


Ещё поправка: я сдался перед исправлениями поздней ночью, но умные люди заметили ошибки! У меня есть «десятая попытка» исправить их. Но pyright жалуется, ведь мои перегрузки перекрываются. Но я не думаю, что есть способ выразить желаемое в аннотациях без перекрытия.


Mypy жалуется, что иногда ранее размещённый код пользователей выдаёт ошибку comparison-overlap, что интересно. Но, похоже, отсутствие перекрытий пользовательского кода здесь может увидеть pyright.


Я опишу проблемы pyright и mypy на Github, хотя в основном они могут быть обусловлены архитектурой, то есть оказаться ограничением текущего состояния подсказок типов Python в принципе:


T = TypeVar("T")

class Addable(Protocol):
    def __add__(self: "Addable", other: Any, /) -> Any:
        ...

class SameAddable(Protocol):
    def __add__(self: T, other: T, /) -> T:
        ...

class RAddable(Protocol):
    def __radd__(self: "RAddable", other: Any, /) -> Any:
        ...

class SameRAddable(Protocol):
    def __radd__(self: T, other: Any, /) -> T:
        ...

SA = TypeVar("SA", bound=SameAddable)
RA = TypeVar("RA", bound=SameRAddable)

@overload
def slow_add(a: SA, b: SA) -> SA:
    ...

@overload
def slow_add(a: Addable, b: Any) -> Any:
    ...

@overload
def slow_add(a: Any, b: RA) -> RA:
    ...

@overload
def slow_add(a: Any, b: RAddable) -> Any:
    ...

def slow_add(a: Any, b: Any) -> Any:
    time.sleep(0.1)
    return a + b

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




Теги:
Хабы:
Всего голосов 10: ↑8 и ↓2+9
Комментарии9

Публикации

Информация

Сайт
www.skillfactory.ru
Дата регистрации
Дата основания
Численность
501–1 000 человек
Местоположение
Россия
Представитель
Skillfactory School