$ sudo teach IT
Модуль 7 · Инструменты Python · Урок 7.3

Точные числа: Decimal и Fraction

Почему 0.1 + 0.2 не равно 0.3, и что делать, когда обычных дробных чисел недостаточно для денег и точных расчётов

Теория~25 минутНовичокfloat и IEEE 754DecimalFractioncomplex

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

Почему 0.1 + 0.2 не равно 0.3

Представьте, что вы пытаетесь записать одну треть в виде десятичной дроби: 0.3333... — цифры после запятой никогда не заканчиваются, сколько бы вы ни писали. На бумаге вы просто округляете и пишете 0.33, зная, что это не совсем точно.

Компьютер оказывается в похожей ситуации, только с числом 0.1. Внутри памяти все числа хранятся не в привычной нам десятичной системе, а в двоичной — там есть только 0 и 1. А в двоичной системе число 0.1 — это как раз такая же бесконечная, никогда не заканчивающаяся дробь, как 1/3 в десятичной. Компьютеру приходится обрезать её до фиксированного количества разрядов, и в этом месте появляется крошечная погрешность.

Официально это поведение описывает стандарт IEEE 754 — международное соглашение о том, как именно дробные числа (в Python это тип float) хранятся в памяти любого компьютера. Стандарт используют не только в Python, но и в JavaScript, Java, C и почти во всех современных языках — так что это не недоработка конкретно Python, а общее свойство двоичного хранения дробей.

print(0.1 + 0.2)
print(0.1 + 0.2 == 0.3)

Разберём, что здесь происходит:

  • print(0.1 + 0.2) — складываем два числа, которые кажутся простыми. Выведется не 0.3, а 0.30000000000000004: за настоящей суммой прячется небольшой "хвост" из-за округления в двоичной системе.
  • print(0.1 + 0.2 == 0.3) — сравниваем результат сложения с числом 0.3 через ==. Выведется False, хотя на первый взгляд числа должны совпасть: слева на самом деле хранится 0.30000000000000004, а справа — чуть другое приближение числа 0.3.

Как с этим жить. Правило простое: никогда не сравнивайте два float напрямую через ==, если они получены вычислениями. Вместо этого проверяют, что числа отличаются меньше, чем на допустимую погрешность, например так: abs(a - b) < 0.0001. Здесь abs() — уже знакомая вам функция модуля числа, а условие проверяет, что разница между a и b меньше выбранной маленькой величины.

Decimal: точная арифметика для денег

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

Официально это называется модулем decimal, а числа в нём — объектами класса Decimal. В отличие от float, который всегда хранит число в двоичном виде, Decimal хранит его как десятичную дробь — ровно так, как оно записано на бумаге.

from decimal import Decimal

a = Decimal("0.1")
b = Decimal("0.2")
print(a + b)
print(a + b == Decimal("0.3"))

Разберём построчно:

  • from decimal import Decimal — из модуля decimal забираем один инструмент, класс Decimal. Дальше в коде его можно использовать под этим именем.
  • a = Decimal("0.1") — создаём точное десятичное число. Обратите внимание: число передано в виде строки "0.1", а не как обычное число 0.1. Почему это важно — в следующем разделе.
  • b = Decimal("0.2") — аналогично создаём второе число.
  • print(a + b) — складываем два объекта Decimal тем же оператором +, что и с обычными числами. Выведется ровно 0.3, без лишних цифр.
  • print(a + b == Decimal("0.3")) — на этот раз сравнение через == сработает как ожидается и выведет True, потому что обе стороны хранят точные десятичные значения, а не двоичные приближения.

Пример из жизни: чек с налогом

from decimal import Decimal

price = Decimal("19.99")
tax = Decimal("0.18")

total = price * (Decimal("1") + tax)
print(total)
print(round(total, 2))

Здесь price и tax — точная цена и точная ставка налога 18%. Строка total = price * (Decimal("1") + tax) считает цену с налогом: сначала складывает единицу и ставку налога (получая множитель 1.18), затем умножает на него цену. Выведется 23.5882 — ровно столько знаков после запятой, сколько получилось математически, без случайного "мусора" в конце. Знакомая функция round(total, 2) округлит эту сумму до привычных двух знаков: 23.59.

Официальное правило: создавайте Decimal только из строки. Так, Decimal("0.1") — правильно, а Decimal(0.1) — нет.

bad = Decimal(0.1)
print(bad)

good = Decimal("0.1")
print(good)

Если создать Decimal из уже готового числа 0.1, Python сначала честно превращает его в float — со всей его двоичной погрешностью — и только потом "упаковывает" в Decimal. Строка bad = Decimal(0.1) выведет длинное число вроде 0.1000000000000000055511151231257827021181583404541015625: Decimal лишь точно скопировал ту погрешность, что уже была в float. А строка good = Decimal("0.1") создаёт число напрямую из текста, минуя float, и печатает ровно 0.1. Отсюда правило: если число заранее известно как строка или литерал в коде — пишите его в кавычках.

Fraction: числа как в учебнике математики

Вспомните, как в школе складывали дроби: 1/3 + 1/6 приводили к общему знаменателю и получали ровно 1/2, без единого десятичного знака. Decimal для такой задачи не подходит: он умеет точно хранить только конечные десятичные дроби, а 1/3 в десятичном виде — это снова бесконечное 0.333... Здесь нужен другой инструмент, который умеет работать именно с числителем и знаменателем.

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

from fractions import Fraction

a = Fraction(1, 3)
b = Fraction(1, 6)

print(a + b)
print(a * b)

Разберём построчно:

  • from fractions import Fraction — забираем класс Fraction из модуля fractions, как раньше забирали Decimal из decimal.
  • a = Fraction(1, 3) — создаём дробь 1/3, передавая числитель и знаменатель двумя отдельными аргументами.
  • b = Fraction(1, 6) — аналогично создаём дробь 1/6.
  • print(a + b) — складываем две дроби оператором +. Python сам находит общий знаменатель и сокращает результат: выведется 1/2, а не что-то вроде 6/18.
  • print(a * b) — умножаем дроби. Выведется 1/18: числители и знаменатели перемножились, как и положено при умножении дробей.

Другие способы создать Fraction

f1 = Fraction(6, 4)
print(f1)

f2 = Fraction("0.25")
print(f2)

Строка f1 = Fraction(6, 4) создаёт дробь из двух целых чисел, которые не обязаны быть уже сокращены — Fraction сокращает их сам, поделив оба числа на их наибольший общий делитель. Выведется 3/2, а не 6/4. Строка f2 = Fraction("0.25") показывает ещё один способ — создать дробь прямо из строки с десятичной записью: Python сам превращает 0.25 в дробь и сокращает её до 1/4.

Когда выбирать что. Decimal подходит для денег и любых расчётов, где число изначально задано в десятичном виде: цены, налоги, проценты. Fraction подходит там, где точнее удобнее думать дробями: пропорции, вероятности, математические задачи, где 1/3 должна остаться ровно 1/3, а не превратиться в бесконечную десятичную запись.

Коротко: комплексные числа встроены в язык

В школьной математике квадратный корень из отрицательного числа считается "не существующим". Но в некоторых областях — например, при обработке звука или в электротехнике — такие числа всё же нужны, и математики договорились считать, что корень из −1 существует, обозначив его особым числом. В Python этот тип данных встроен в язык на уровне синтаксиса, без всякого импорта:

z = 3 + 4j
print(z.real)
print(z.imag)
print(abs(z))

Здесь z = 3 + 4j создаёт комплексное число: 3 — обычная (действительная) часть, а 4j — мнимая часть, где буква j прямо в числе означает, что это особая единица. Атрибуты z.real и z.imag возвращают эти две части по отдельности (3.0 и 4.0), а уже знакомая функция abs(z) для комплексного числа считает не модуль по знаку, а длину: 5.0, по теореме Пифагора для 3 и 4. Для более сложной математики с такими числами (корень из отрицательного, тригонометрия) в стандартной библиотеке есть отдельный модуль cmath — для большинства задач этого курса он не понадобится, но полезно знать, что он существует, если вы когда-нибудь встретите обработку сигналов или похожую область.

Сводная таблица: какой тип выбрать

Тип Когда использовать
float Обычные вычисления, где небольшая погрешность не критична: координаты, статистика, физические величины
Decimal Деньги, налоги, проценты — всё, где важна точность до копейки, а числа заданы в десятичном виде
Fraction Точные дроби и пропорции, где важно сохранить именно рациональное число, а не десятичное приближение
complex Узкие задачи вроде обработки сигналов и электротехники, встречаются редко

Частые ошибки

Сравнение float через ==. Код вида if price_total == 23.59: может неожиданно оказаться False, даже если на глаз числа одинаковые — из-за той же погрешности двоичного представления. Для float сравнивайте через допустимую разницу, а если речь о деньгах — используйте Decimal с самого начала расчёта.

Decimal, созданный из float. Decimal(19.99) выглядит правильно, но на самом деле передаёт в Decimal уже испорченное число: погрешность float никуда не денется. Всегда пишите цену строкой: Decimal("19.99").

Смешивание Decimal и float в одном выражении. Код вроде Decimal("10") + 0.5 вызовет ошибку TypeError: Python не станет молча превращать один тип в другой. Приведите оба числа к одному типу заранее, например через Decimal("0.5").

Что важно запомнить

  • 0.1 + 0.2 != 0.3 в Python — следствие стандарта IEEE 754, а не ошибка языка: дробные числа хранятся в двоичном виде с округлением.
  • Для денег и точных десятичных расчётов используйте from decimal import Decimal.
  • Всегда создавайте Decimal из строки: Decimal("0.1"), а не из float Decimal(0.1).
  • Fraction хранит число как числитель и знаменатель, складывает и умножает дроби точно, автоматически сокращая результат.
  • Комплексные числа встроены в Python как тип complex: 3 + 4j, без импорта. Атрибуты .real, .imag и функция abs() — основные операции с ними.
  • Decimal и float нельзя складывать напрямую — сначала приведите оба числа к одному типу.

Проверьте себя

7 вопросов

Расчёт стоимости с НДС через Decimal

Напишите функцию calculate_total(price_str, tax_rate_str), которая принимает цену и ставку налога как строки и возвращает итоговую сумму с налогом, округлённую до 2 знаков после запятой.

Используйте Decimal для точных вычислений и создавайте его только из строки. Формула: итог = цена * (1 + ставка налога).

Например, calculate_total("19.99", "0.18") должна вернуть Decimal("23.59").

Арифметика с рациональными числами

Premium