python

Приключения чисел в python и mypy или the numeric tower

  • суббота, 13 августа 2022 г. в 00:37:34
https://habr.com/ru/post/682272/
  • Python
  • Программирование


Если вы когда-нибудь чувствовали, что вы погрязли в совещаниях и обсуждениях, которые всё длятся и длятся, а решения проблемы всё нет, знайте: в 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 достаточно приятный в использовании язык, недаром он стал одним из самых популярных (а по некоторым подсчётам - самым популярным). И многие архитектурные решения, принятые при реализации языка и интерпретатора достойны изучения. Но, иногда даже такие простые сущности как числа приводят к необходимости принимать сложные и неоднозначные решения, допустив в процессе несколько багов и неочевидностей. Так что, если вдруг ваш начальник будет недоволен вашей архитектурой, можете попытаться отмазаться тем, что не только у вас не получается создать идеальную иерархию типов, даже у Гвидо не всегда получается.