После рассказа про 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, то он должен содержать три элемента:
- Знак, как число: 0 – это плюс, 1 – это минус.
- Кортеж из значащих цифр мантиссы
- Число – показатель экспоненты
Вообще кортеж для 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 👈