Класс Decimal для точного представления вещественных чисел

Курс по стандартной библиотеке: https://stepik.org/a/259466?utm_source=proproprogs

Смотреть материал на YouTube | RuTube

На данный момент мы с вами знакомы с тремя основными числовыми типами языка Python:

  • int – для целых чисел;
  • float – для вещественных чисел;
  • complex – для комплексных чисел.

Причем, комплексные числа состоят из данных вещественного типа float. Поэтому можно выделить два основных числовых типа: int и float.

Целочисленные значения относительно просто кодируются на уровне бит без искажений. Например:

192 = 1100 0000
87 = 0101 0111
5 = 0000 0101

И так далее. А вот с вещественными числами дела обстоят куда сложнее. Они кодируются в виде конечных двоичных дробей. Однако не каждое дробное число можно точно представить такими двоичными дробями. Классический пример – число 0.1, которое не может быть точно описано на уровне бит стандартной схемой. В итоге получаем следующий известный арифметический казус в программировании:

0.1+0.1+0.1 = 0.30000000000000004

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

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

d = float(input())
if d == 0.78:  # так делать не надо!
    print("Wow!!!")

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

delta = 1e-6
if abs(d-0.78) < delta:
    print("Wow!!!")

Однако иногда, все же, возникают задачи, где нужна математическая точность представления вещественных чисел и операций с ними. Классический пример – финансовые программные продукты. Здесь каждая доля копейки имеет значение. И связано это, отнюдь, не столько из-за чрезмерной жадности финансовых воротил, сколько из-за уязвимостей, которые несут в себе погрешности вычислений. Чтобы у вас не осталось в этом никаких сомнений приведу один реальный пример. Группа программистов, разрабатывая программный модуль по проведению финансовых транзакций, воодушевилась внезапной гениальной мыслью: а что если каждую крупную транзакцию округлять в свою пользу так, чтобы получать с нее доли копейки. Этого же даже никто и не заметит? Сказано, сделано! Прошли годы. Схема успешно приносила горе-дельцам ощутимую прибыль. Но аудит не дремал. Была выявлена большая недостача. Тщательное расследование в конце-концов вышло на эту злополучную схему, виновные были наказаны, справедливость восторжествовала. Мораль. Даже незначительные искажения данных могут приводить к значимым финансовым потерям.

Создание объектов класса Decimal

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

С помощью этого класса можно создавать новое представление вещественных чисел, отличное от стандартного float. Делается это следующим образом:

a = Decimal('0.1')
b = Decimal(7)
c = Decimal('-0.3')
d = Decimal(-13)
e = Decimal('1e-8')

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

f = Decimal(0.1)

или с помощью метода from_float():

g = Decimal.from_float(0.3)

но делать так крайне не рекомендуется, т.к. сначала 0.1 и 0.3 будут закодированы, как тип float, т.е. неточно, и только потом искаженные значения разместятся в объектах класса Decimal. Мы в этом легко можем убедиться:

for x in a, b, c, d, e, f, g:
    print(f"{x:.20f}")

В консоли увидим:

0.10000000000000000000
7.00000000000000000000
-0.30000000000000000000
-13.00000000000000000000
0.00000001000000000000
0.10000000000000000555
0.29999999999999998890

Как видите, последние значения вместо 0.1 и 0.3 имеют некоторый шум представления. Тогда как аналогичное число 0.1, записанное в виде строки, такой погрешности не имеет.

Обратное конвертирование объектов класса Decimal в числа

Объекты класса Decimal всегда можно конвертировать в стандартные числовые типы данных int или float. Например, пусть имеется объект:

res = Decimal('1.57843')

Чтобы его преобразовать в тип float достаточно прописать:

res_f = float(res)  # 1.57843

Или же его можно привести к целочисленному типу int:

res_i = int(res)  # 1

В этом случае будет отброшена дробная часть.

Арифметические операции с объектами класса Decimal

С объектами класса Decimal можно выполнять все стандартные арифметические операции. Например, пусть заданы числа:

a = Decimal('5.3')
b = Decimal('3.1415')
d = Decimal('7')
e = Decimal('3')

Тогда с ними можно выполнять следующие вычисления:

a + b  # Decimal('8.4415')
b - a  # Decimal('-2.1585')
a * e  # Decimal('15.9')
b / a  # Decimal('0.5927358490566037735849056604')
a // b  # Decimal('1')
d % e  # Decimal('1')

И с комбинацией обычных целых чисел языка Python:

e ** 2  # Decimal('9')
a + 7  # Decimal('12.3')
8 / a  # Decimal('1.509433962264150943396226415')
d % 4  # Decimal('3')

И так далее. А вот применять обычные вещественные числа недопустимо, возникнет ошибка TypeError:

a + 0.1  # TypeError

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

a + Decimal('0.1')  # Decimal('5.4')

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

Также следует иметь в виду, что операция деления на ноль:

d / 0  # decimal.DivisionByZero

приводит к ожидаемому исключению типа decimal.DivisionByZero.

Также с объектами класса Decimal допустимо выполнять операции вида:

a += b  # Decimal('8.4415')
d *= 2  # Decimal('14')
b /= e  # Decimal('1.047166666666666666666666667')

И так далее.

Операции сравнений с объектами класса Decimal

Помимо базовых арифметических операций с объектами класса Decimal можно использовать все стандартные операции сравнения. Например:

a == b  # False
a == 5.3  # False
a == Decimal('5.3')  # True
b < a  # True
a >= b  # True
d == 7  # True
d == Decimal('7')  # True
d != e  # True

Смотрите, сравнение a == 5.3  возвращает False, тогда как a == Decimal('5.3') – значение True. Ранее мы уже говорили, что вещественные числа типа float не следует сравнивать между собой обычным равенством. Но числа Decimal можно, т.к. они имеют точное представление в памяти устройства. При сравнении с целыми числами таких проблем не возникает, поэтому обе команды:

d == 7  # True
d == Decimal('7')  # True

возвращают True.

Способ кодирования объектов класса Decimal

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

(знак числа, (кортеж из цифр), значение экспоненты)

Например, для числа:

a = Decimal('5.3')

путем вызова метода as_tuple() получим следующий кортеж:

a.as_tuple()  # DecimalTuple(sign=0, digits=(5, 3), exponent=-1)

Здесь sign=0 – означает положительное число (1 – для отрицательных); digits=(5, 3) – набор цифр числа; exponent=-1 означает 10^-1 степени, т.е. число из цифр 53 нужно разделить на 10 для получения итогового значения 5.3.

Давайте то же самое проделаем с числом:

b = Decimal('-85.12306946')

получим:

DecimalTuple(sign=1, digits=(8, 5, 1, 2, 3, 0, 6, 9, 4, 6), exponent=-8)

Теперь знак отрицательный (sign=1), а набор чисел нужно разделить на 10^8 степени, т.е. последние 8 цифр расположить в дробной части.

Мало того, мы можем формировать новые числа Decimal на основе такого представления. Например:

c = Decimal((0, (3, 1, 4, 1, 5), -4))  # Decimal('3.1415')
d = Decimal((1, (8, 7), 0))  # Decimal('-87')
e = Decimal((0, (1, 2), 3))  # Decimal('1.2E+4')

Это удобный способ точного описания вещественных чисел при их экспорте и импорте.

Специальные значения чисел класса Decimal

Помимо обычных числовых значений объекты класса Decimal могут принимать еще два специальных:

  • 'NaN' (либо '-NaN') – Not a Number, не число;
  • 'Infinity' (либо '-Infinity') – бесконечность.

Эти значения можно задать напрямую следующим образом:

d_nan1 = Decimal('NaN')
d_nan2 = Decimal('-NaN')

и

d_inf1 = Decimal('Infinity')
d_inf2 = Decimal('-Infinity')

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

d_inf1 + a  # Decimal('Infinity')
d_nan1 - 5  # Decimal('NaN')

На следующем занятии мы продолжим эту тему и рассмотрим различные методы и математические функции для работы с числами класса Decimal.

Курс по стандартной библиотеке: https://stepik.org/a/259466?utm_source=proproprogs

Видео по теме