Если вы когда-нибудь чувствовали, что вы погрязли в совещаниях и обсуждениях, которые всё длятся и длятся, а решения проблемы всё нет, знайте: в mypy есть 5-летний issue, о том что целое число не является числом.

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

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

В python есть следующие встроенные типы для чисел: целые, вещественные с плавающей и фиксированной точкой, рациональные дроби и даже комплексные числа. Данные типы реализуют определённые интерфейсы (ABCs), организованные в numeric tower (Number, Complex, Real, Rational и Integral). И вот здесь, начинаются проблемы. Некоторые решения выглядят понятными и допустимыми, например, в функцию, принимающую float всегда можно передать int, а в функцию, принимающую complex всегда можно передать и float, и int.

def sin(a: float):
    print(isinstance(a, float))


def cos(a: complex):
    print(isinstance(a, complex))


sin(1)    # выведет False
cos(4.5)  # тоже выведет False

Проверка mypy выведет Success: no issues found in 1 source file. И с математической точки зрения в этом есть смысл, любое вещественное число является комплексным, а целое — вещественным. Но есть пуристы, которые такой код не пропустят, например, в rust, нельзя передавать целые числа в функции, ожидающее вещественное число.

fn f(a: f32) {
}

fn main() {
    f(4)
}
   Compiling playground v0.0.1 (/playground)
error[E0308]: mismatched types
 --> src/main.rs:6:7
  |
6 |     f(4)
  |     - ^
  |     | |
  |     | expected `f32`, found integer
  |     | help: use a float literal: `4.0`
  |     arguments to this function are incorrect

Да и в python красивая математическая абстракция начинает течь, как можно заметить проверки типов проходят в mypy, но проверки isinstance возвращают False. То есть тип в сигнатуре и в isinstance не одно и то же, впрочем, это даже понятно, учитывая __subclasshook__ и динамизм языка.

Так же в python decimal и float, не являются взаимозаменяемыми и совместимыми, хотя с точки зрения математики, и то, и другое — вещественные числа, но создатели языка решили, что смешивать в одной операции два типа не стоит, так как это может привести к потере точности.

>>> Decimal(1) + 2.5
TypeError: unsupported operand type(s) for +: 'decimal.Decimal' and 'float'

mypy об этом знает и такие операции не разрешает:

ws.py:15: error: Unsupported operand types for + ("Decimal" and "float")

Так же, Decimal нельзя передать в функцию, ожидающую комплексную переменную, а целое число нельзя передать в функцию, ожидающую Decimal. Хотя математическая абстракция, по идее, не должна переставать работать от того, то мы поменяли вещественные числа с плавающей точкой на числа с фиксированной, они всё равно остаются вещественными и математические операции, допустимые над Decimal должны выполнять и над int. Но такой код mypy не пропустит.

from decimal import Decimal
from numbers import Number


def sin(a: Decimal):
    ...


def cos(a: complex):
    ...


sin(1)
cos(Decimal(1))
ws.py:13: error: Argument 1 to "sin" has incompatible type "int"; expected "Decimal"
ws.py:14: error: Argument 1 to "cos" has incompatible type "Decimal"; expected "complex"

Отдельный забавный момент состоит в том, что в python bool наследуется от int.

>>> int.__subclasses__()
[bool, ...
from decimal import Decimal


def sin(x: float):
    pass

sin(Decimal(1))  # ws.py:7: error: Argument 1 to "sin" has incompatible type "Decimal"; expected "float"
sin(1==0)        # А эти строчки
sin(True)        # проверку проходят

То есть вычислить "синус" от 1 нельзя, а от истины — возможно.

Теперь вернёмся к КДПВ. int, Decimal и float являются Number.

isinstance(1, Number)             # True
isinstance(2.5, Number)           # True
isinstance(Decimal(2.5), Number)  # True

Но, mypy так не считает.

from decimal import Decimal
from numbers import Number


def sin(x: Number):
    pass

sin(Decimal(1))
sin(1)
sin(2.5)
sin(True)
ws.py:8: error: Argument 1 to "sin" has incompatible type "Decimal"; expected "Number"
ws.py:9: error: Argument 1 to "sin" has incompatible type "int"; expected "Number"
ws.py:10: error: Argument 1 to "sin" has incompatible type "float"; expected "Number"
ws.py:11: error: Argument 1 to "sin" has incompatible type "bool"; expected "Number"

sin(Number(1)) тоже не пройдёт, так как Number — абстрактный класс.

Python достаточно приятный в использовании язык, недаром он стал одним из самых популярных (а по некоторым подсчётам - самым популярным). И многие архитектурные решения, принятые при реализации языка и интерпретатора достойны изучения. Но, иногда даже такие простые сущности как числа приводят к необходимости принимать сложные и неоднозначные решения, допустив в процессе несколько багов и неочевидностей. Так что, если вдруг ваш начальник будет недоволен вашей архитектурой, можете попытаться отмазаться тем, что не только у вас не получается создать идеальную иерархию типов, даже у Гвидо не всегда получается.

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


  1. vadimr
    12.08.2022 19:20
    +1

    Как придумать самим себе проблему, типизировав формальный параметр, и затем её героически решать.


    1. LinearLeopard Автор
      12.08.2022 19:47
      +1

      Не совсем понял, о каком формальном параметре идёт речь. Если вы о функции sin, то реальная умеет работать со всеми типами:

      >>> sin(True)
      0.8414709848078965
      >>> sin(Decimal(1))
      0.8414709848078965
      >>> sin(Fraction(1, 1))
      0.8414709848078965
      


      и не типизирована docs.python.org/3/library/math.html#math.sin

      Параметр только позиционный

      >>> sin(x=True)
      Traceback (most recent call last):
        File "<stdin>", line 1, in <module>
      TypeError: math.sin() takes no keyword arguments
      


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


      1. vadimr
        12.08.2022 19:51

        def sin(x: Number):

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

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


        1. LinearLeopard Автор
          12.08.2022 19:54

          Зачем говорить, что ф-ция принимает число?


          1. vadimr
            12.08.2022 19:57
            -3

            Я не понимаю, что такое “число”. Это вредная генерализация понятия.

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


            1. LinearLeopard Автор
              12.08.2022 21:56
              +2

              А, кажется, понял, вы имеете ввиду, что предпочли бы видеть

              def f(x: Union[int, float, Decimal, Fraction, Complex])?


              1. vadimr
                12.08.2022 22:31

                Я бы предпочёл видеть f(x). А если уж хочется расписать типы, то тогда надо для начала разбираться, int там должно быть по существу задачи или float.

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


                1. LinearLeopard Автор
                  13.08.2022 00:14

                  Судя по тому, что баг за 5 лет так и не пофиксили, не вы не одиноки с данным мнением)


  1. Wesha
    13.08.2022 05:54

    А решение-то — единственный кусок кода (псевдокод, ибо питонам не обучен):

    def sin(int x)
    sin(float::x)
    end

    Раз требуется, чтобы параметр был определённого типа — пишете обёртку, которая берёт "любой" тип, и приводит его к "определённому".


    1. san-smith
      13.08.2022 10:00

      Честно говоря, не назвал бы это «решение» решением.
      В общем случае это не работает — представьте, что «определённым» типом является int, а «любым» — float или complex. Уверены, что есть разумные правила приведения?
      Кстати, что насчёт ситуации, когда приведение типа завершилось неудачно?


    1. LinearLeopard Автор
      13.08.2022 10:29

      Спасибо за интерес.

      В python приведение типа работает только для анализатора docs.python.org/3/library/typing.html#typing.cast

      Кроме того, оно приводит любой тип к любому

      from typing import cast
      
      
      def s(x: str):
          pass
      
      
      s(cast(str, 1))
      # Success: no issues found in 1 source file
      


      Что равнозначно отсутствию проверки типов совсем.

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

      def f(x: Union[float, Decimal])?
      


      Или объявить протокол:

      from decimal import Decimal
      from typing import cast, Protocol
      
      
      class MyNumber(Protocol):
          def __add__(self, other):
              pass
      
          def __mul__(self, other):
              pass
      
          def __pow__(self, power, modulo=None):
              pass
      
      
      def s(x: MyNumber):
          pass
      
      
      s(cast(str, 1))
      s(Decimal(1))
      


      Строка не пройдёт:
      ws.py:21: error: Argument 1 to "s" has incompatible type "str"; expected "MyNumber"
      ws.py:21: note: 'str' is missing following 'MyNumber' protocol member:
      ws.py:21: note:     __pow__
      Found 1 error in 1 file (checked 1 source file)
      


      Правда, не пройдёт только из-за того, что у неё не определёна операция возведения в степень.


  1. evtn
    15.08.2022 12:17
    -1

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

    Для начала стоило бы в статье оставить ссылку на само обсуждение.

    Так называемый number (или numeric) tower изначально определён как модуль numbers в PEP 3141 от далёкого 2007 года. То есть за год до релиза Python 3 и за семь лет до появления тайпхинтов в 2014. Тот же самый PEP 484, прямо в том же абзаце, который вы привели в пример, предлагает использовать реальные классы для реализации numeric tower, а вы это предложение игнорируете и продолжаете использовать старые абстрактные классы.

    Более того, давайте посмотрим в тот самый PEP 3141 и узнаем, как должен быть определён Number:

    class Number(metaclass=ABCMeta): pass

    То есть, в общем-то, никаких методов нам этот класс не даёт (потому что это протокол для абстрактного числа, а число может иметь совершенно разные свойства и разные операции — к примеру, если вы принимаете два числа, вы не можете их сравнивать, потому что сравнение не определено для комплексных чисел). Что вы имеете в виду, когда пишете x: Number?

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


    1. LinearLeopard Автор
      15.08.2022 12:36

      Спасибо за интерес к статье.

      Статья написана в пятницу и представляет собой обычную не очень серьёзную пятничную хабра статью. Не стоит относиться к ней как к статье по теории типов в Oxford University Press или IEEE.

      P.S. Ссылку забыл добавить, сейчас пофикшу, спасибо.


      1. evtn
        15.08.2022 13:49

        Да я не говорю, что статья должна тянуть на серьёзное исследование, просто она какая-то пустая, что ли. Я за 5 минут пролистал обсуждение и пробежался по PEP 484/3141 достаточно, чтобы расписать суть лучше.

        Хотелось бы, чтобы авторы статей на Хабре тоже разбирались в предмете статьи, хотя бы в виде 10-минутного прочтения литературы об этом.