Подсказки типа великолепны! Но не так давно я играл в адвоката дьявола: я утверждал, что на самом деле эти подсказки способны раздражать, особенно программистов из старой школы 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. Но мы поможем освоить нужную теорию, приобрести полезный опыт и, если трудности вас не пугают, устроиться на работу в сфере информационных технологий:




Комментарии (9)


  1. sci_nov
    00.00.0000 00:00

    Я бы на complex и остановился. Decimal им не работает... ишь! А дальше так это вообще гОниво какое-то...


  1. lea
    00.00.0000 00:00
    +1

    Python придерживается философии «должен существовать один — и, желательно, только один — очевидный способ сделать это».

    Но что-то пошло не так...


  1. outlingo
    00.00.0000 00:00
    +14

    А всё потому, что кое-кому захотелось реализовать всё в одной функции.

    Не принимая в расчет того, что сложение целых чисел, вещественных, комплексных и вообще не чисел это не "операция сложения" а "разные операции сложения".

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


    1. funca
      00.00.0000 00:00
      +2

      Пример некоммутативного сложения в python: "a" + "b" == "ab", "b" + "a" == "ba"


      1. artemev
        00.00.0000 00:00
        +3

        Это не сложение, а конкатенация строк


        1. funca
          00.00.0000 00:00
          +2

          Вообще вы правы - в математике сложение коммутативно по определению. Но напрограммировать плюс можно как угодно. У @Stefanio есть целая статья про это https://habr.com/ru/post/655059/.


    1. 0xd34df00d
      00.00.0000 00:00
      +2

      А все потому, что тяжело прикручивать типизацию пост-фактум.


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


      1. funca
        00.00.0000 00:00

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


  1. fishHook
    00.00.0000 00:00

    Подсказки типов могут раздражать, сейчас я это докажу

    и... не доказал. Автору нужна была была функция которая применяет + к двум любым аргументам, а если операция не допустима, то вываливается исключение. Собственно, как в старые добрые времена. Ну вот вам такая функция

    def any_add(a: Any, b: Any) -> Any:
    return a + b


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