Decimal числа. Отличия от float

После рассказа про float меня просили рассказать про Decimal. Узнаем же, что это за зверь, как он устроен внутри и как с ним работать. Итак, Decimal – это класс из стандартного модуля decimal. Он представляет собой число с плавающей точкой, как и float. Да, именно с плавающей, потому что некоторые, я слышал, думают, что это число с фиксированной точкой.

Однако, Decimal имеет ряд существенных отличий от float.

Цель

Тип Decimal создан, чтобы операции над рациональными числами в компьютере выполнялись также, как они выполняются людьми, как их преподают в школе. Иными словами, чтобы все-таки 0.1 + 0.1 + 0.1 == 0.3. Из-за ошибок представления, float приводит к утере точности, и такие простые на первый взгляд равенства не выполняются. А это может быть критично в высокоточных научных вычислениях, и главное в сфере бизнеса и финансов!

Внутреннее устройство

float – реализован по стандарту IEEE-754 как число с плавающей запятой двойной точности (64 бита) на основании 2. Реализация таких чисел заложена прямо в железо почти любого современного процессора. Поэтому float в Python работает примерно также, как и double в С, С++, Java и прочих языках. И имеет такие же ограничения и «странности». Так как поддержка float имеет аппаратный характер, то его быстродействие сравнительно велико.

Decimal – число с плавающей точкой с основанием экспоненты – 10, отсюда и название (decima лат. – десятая часть, десятина).

Он реализован по стандарту IBM: General Decimal Arithmetic Specification Version 1.70 – 7 Apr 2009, который в свою очередь основан на стандартах IEEE. По поводу реализации, в исходниках CPython я нашел два варианта: на чистом Python и с помощью Си-библиотеки libmpdec. Обе реализации есть в кодовой базе, хотя в последних версиях Python 3 используется именно Си-версия, очевидно, она в разы быстрее! Видите букву Си?

Python 3.7.5 (default, Nov 13 2019, 14:05:23)
[Clang 11.0.0 (clang-1100.0.33.12)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> import decimal
>>> help(decimal)

Help on module decimal:

NAME
    decimal - C decimal arithmetic module
...

Поэтому первый важный вывод:

Хоть Decimal и написан на Си, он в разы медленнее, чем float, так как реализован программно, а float – аппаратно.

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

Теперь самое главное – основание 10. Оно позволяет записывать десятичные дроби точно, без ошибок представления.

Decimal_Число = ±мантисса * 10 экcпонента

Мантисса и экспоненты – целые числа.

Помните, что мы не могли представить 0.1 в float с основанием 2? С основанием 10 – это элементарно:

0.1 = 1 * 10-1, и таким образом, 0.3 = 3 * 10-1 = (1 + 1 + 1) * 10-1 = 0.1 + 0.1 + 0.1

Как мы в школе учили десятичные дроби и знаем, как оперировать ими, так и здесь. Все точно и привычно. Ну почти все. Если мы разделим единицу на тройку, то получим бесконечную периодическую дробь 0.33333333…, либо по другому ее пишут 0.(3) – три в периоде. Естественно, что бесконечных чисел, записанных цифрами в памяти компьютера быть не может, иначе бы потребовалась бесконечная память. Поэтому количество троек в записи может быть большим, но обязано быть конечным.

Decimal оперирует с числами с произвольной (задаваемой пользователем), но конечной точностью.

По умолчанию точность – 28 десятичных знаков.

Еще одно следствие того, что Decimal реализовано программно – то, что его можно на ходу настраивать, как угодно пользователю. Для этого есть контекст – объект, содержащий настройки для выполнения операций и флаги. Операции выполняемые в этом контексте следуют правилам, заданным в нем. В отличии от float, где все правила фиксированы на аппаратном или низшим программным уровнях. Настроить можно:

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

Флаги в контексте устанавливаются со стороны модуля decimal, если при последнем вычислении случился какой-то из сигналов. (Это отдельная тема, о ней потом.)

Сам же объект Decimal содержит знак, мантиссу (коэффициент перед экспонентой) и саму экспоненту (степень). Лишние нули в мантиссе на обрезаются, чтобы сохранять значимость числа (1.20 * 2.40 = 2.8800).

Decimal – иммутабельный (неизменяемый) тип. Операции над ним приводят к созданию новых объектов, а старые не меняются.

Поработаем с Decimal

Начинаем с импорта и посмотрим, каков контекст по умолчанию:

>>> from decimal import *
>>> getcontext()
Context(prec=28, rounding=ROUND_HALF_EVEN, Emin=-999999, Emax=999999, capitals=1, clamp=0, flags=[], traps=[InvalidOperation, DivisionByZero, Overflow])

Мы видим здесь, что точность 28 знаков, округление к ближайшему четному, пределы по экспоненте ±999999, capitals – это про заглавную Е при печати, clamp не будем трогать пока что, флаги все сброшены, а включенные ловушки – неправильная операция, деление на ноль, переполнение. Если ловушка включена, это значит, что при возникновении соответствующего сигнала будет брошено исключение. Если нет ловушки, то при сигнале будет только втихую установлен флаг. Я оставлю тему ловушек на следующую статью.

Создание Decimal

Создать Decimal можно из обычного целого числа, из float, из строки или кортежа. С обычным числом все просто – int представлены и так точно:

>>> Decimal(1)
Decimal('1')
>>> Decimal(-1)
Decimal('-1')
>>> Decimal(10002332)
Decimal('10002332')

Из float – надо быть очень аккуратным. Потому что, float округляется внутри до ближайшего возможного, а Decimal не знает о ваших первоначальных намерениях, поэтому копирует содержимое float. К примеру, числа 0.1 в представлении float просто не существует. Python считывает 0.1 из кода как строку, потому ищет наиболее близкий к нему возможный float, а из него уже копируется содержимое в Decimal, как есть – уже с ошибкой:

>>> Decimal(0.1)
Decimal('0.1000000000000000055511151231257827021181583404541015625')

Не рекомендуется создавать Decimal из float. В Decimal попадет уже неправильно округленное число. Создавайте Decimal из целых чисел, либо из строк!

Логически правильно создавать Decimal сразу из строки, избегая фазу с превращением его в float! Что есть в строке – попадет в Decimal. Может показаться, что это немного криво – хранить числах в строках, но теперь вы знаете о представлении двоичного float, и строки обретают реальный смысл.

>>> Decimal('0.1')
Decimal('0.1')
>>> Decimal('3.14')
Decimal('3.14')
>>> Decimal('1.2e+10')
Decimal('1.2E+10')
>>> Decimal('10_000_000_000')  # c версии Python 3.6 можно подчеркивания
Decimal('10000000000')

Можно строкой еще задавать бесконечности и NaN (не число). Примеры:

>>> Decimal('Inf')
Decimal('Infinity')
>>> Decimal('-Inf')
Decimal('-Infinity')
>>> Decimal('nan')
Decimal('NaN')

Если использовать кортеж для конструирования Decimal, то он должен содержать три элемента:

  1. Знак, как число: 0 – это плюс, 1 – это минус.
  2. Кортеж из значащих цифр мантиссы
  3. Число – показатель экспоненты

Вообще кортеж для Decimal использует редко. Но вот вам пример:

>>> Decimal((0, (1, 2, 3, 4, 5), -1))
Decimal('1234.5')
>>> Decimal((1, (7, 7, 7), 3))
Decimal('-7.77E+5')

Если число слишком большое, то будет сигнал – неправильная операция. А так как на этом сигнале ловушка по умолчание – то будет исключение:

>>> Decimal("1e9999999999999999999")
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
decimal.InvalidOperation: [<class 'decimal.InvalidOperation'>]

Точность представление Decimal задается исключительно длиной задающего числа (или длиной строки). Настройки точности и режимов округления из контекста в ступают в игру только во время совершения математических операций.

>>> с = Context(prec=3)  # точность 3
>>> Decimal('5.643434231', c)  # но число целиком сохраняется
Decimal('5.643434231')

>>> Decimal('5.643434231', c) * 2  # после операции уже применяется округление до нужной точности
Decimal('11.287')

>>> +Decimal('5.643434231', c)  # трюк: унарный плюс применит контекст
Decimal('5.6434')

Decimal отлично интегрирован в среду Python. Что касается математики, то с Decimal работают все привычные операции: сложение, вычитание, умножение, деление, возведение в степень и так далее.

Работайте с Decimal как с обычными числами: складывайте, вычитайте, умножайте, делите и прочее. Можете, миксовать их с целыми числами. Но не рекомендуется миксовать их с float.

>>> x = Decimal('1.2')
>>> y = Decimal('2.3')
>>> x + y
Decimal('3.5')
>>> x - y
Decimal('-1.1')
>>> x * y
Decimal('2.76')
>>> x / y
Decimal('0.52174')

>>> y // x  # деление нацело
Decimal('1')
>>> y % x  # остаток
Decimal('1.1')

>>> Decimal('2.2') * 2
Decimal('4.4')
>>> Decimal('2.2') - 1
Decimal('1.2')

Дополнительно еще доступны некоторые математические функции:

>>> getcontext().prec = 10  # просто точность задали

>>> Decimal(2).sqrt()  # корень квадратный
Decimal('1.414213562')

>>> Decimal(2).ln()  # логарифм натуральный
Decimal('0.6931471806')

>>> Decimal(100).log10()  # логарифм десятичный
Decimal('2')

А вот чисел π и e из коробки не завезли, потому что не ясно, какая точность вам нужна. Их можно взять из модуля math в виде float или задать вручную до нужной точности или на худой конец вычислить. Аналогично для тригонометрии и специальных функций: либо берите неточные значения из math, либо вычисляйте сами до нужной точности рядами Тейлора или другими методами с помощью примитивных операций. В документации есть примеры вычисления констант и функций.

Кстати, Decimal можно передавать как аргументы функций, ожидающих float. Тогда они будут преобразованы во float:

>>> math.sin(Decimal(1))
0.8414709848078965

Метод quantize округляет число до фиксированной экспоненты, полезно для финансовых операций, когда нужно округлить копейки (центы). Первый аргумент – Decimal – что-то вроде шаблона округления. Смотрите примеры:

>>> Decimal('10.4266').quantize(Decimal('.01'), rounding=ROUND_DOWN)
Decimal('10.42')
>>> Decimal('10.4266').quantize(Decimal('.01'), rounding=ROUND_UP)
Decimal('10.43')
>>> Decimal('10.4266').quantize(Decimal('1.'), rounding=ROUND_UP)
Decimal('11')

Кроме того Decimal можно сравнивать между собой, как обычные числа. Причем допускается сравнивать даже на точное равенство:

>>> x = Decimal('0.1')
>>> x + x + x == Decimal('0.3')
True

Можно сортировать списки Decimal, искать минимум и максимум. А также преобразовывать Decimal обратно в обычные типы int, float, str. Пример из документации:

>>> data = list(map(Decimal, '1.34 1.87 3.45 2.35 1.00 0.03 9.25'.split()))
>>> max(data)
Decimal('9.25')
>>> min(data)
Decimal('0.03')
>>> sorted(data)
[Decimal('0.03'), Decimal('1.00'), Decimal('1.34'), Decimal('1.87'),
 Decimal('2.35'), Decimal('3.45'), Decimal('9.25')]
>>> sum(data)
Decimal('19.29')
>>> a, b, c = data[:3]
>>> str(a)
'1.34'
>>> float(a)
1.34
>>> round(a, 1)
Decimal('1.3')
>>> int(a)
1

Но! Не все сторонние библиотеки поддерживают Decimal. Например, не получится использовать его для numpy!

Не все операции над Decimal абсолютно точные, если результат неточен, то возникает сигнал Inexact.

>>> c = getcontext()
>>> c.clear_flags()

>>> Decimal(1) / Decimal(3)
Decimal('0.3333333333')

>>> c.flags[Inexact]
True

Выводы

Выбор между Decimal и float – это поиск компромисса с учетом условий конкретной задачи.

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

Сравнительная таблица

Примечание: а для целочисленных вычислений может сгодится и простой int, он умеет из коробки длинную математику!

Еще

В этой статье я не осветил полностью вопросы:

  • Сигналы, флаги и ловушки
  • Обзор режимов округления
  • Управление контекстами
  • Контексты и многопоточность

Если сообществу будет интересно, то я продолжу тему. Голосование будет на канале!

Специально для канала @pyway. Подписывайтесь на мой канал в Телеграм @pyway 👈 

Оставить комментарий

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