Почему 0.1 + 0.2 ≠ 0.3 и при чём тут ваши деньги
Илья Никсан
14 марта — неофициальный праздник в мире программирования: день числа Пи (3.14). Это отличный повод поговорить о том, как компьютеры работают с дробными числами, почему результаты арифметических операций иногда выглядят неожиданно, и к каким последствиям это может привести в реальных проектах — особенно там, где речь идёт о деньгах.
Эксперимент, который удивляет каждого новичка
Откройте интерактивную консоль любого языка программирования и выполните простейшее выражение:
0.1 + 0.2
Dart, JavaScript, Python, Go, C++, Ruby — результат будет одинаковым: 0.30000000000000004. Не 0.3, как подсказывает здравый смысл, а число с длинным хвостом из нулей и четвёркой на конце.
Это не баг конкретного языка и не ошибка в вашем коде. Это фундаментальное свойство того, как современное оборудование представляет дробные числа в памяти.
Как компьютер хранит дробные числа
Вся информация в компьютере хранится в двоичном виде — последовательностях нулей и единиц. С целыми числами это работает безупречно: 5 в двоичной системе записывается как 101, 10 — как 1010. Каждое целое число имеет точное двоичное представление.
С дробными числами ситуация принципиально иная. Чтобы перевести десятичную дробь в двоичную, применяется метод последовательного умножения на 2 с выделением целой части. Посмотрим, что произойдёт с числом 0.1:
0.1 × 2 = 0.2 → 0
0.2 × 2 = 0.4 → 0 ← начало цикла
0.4 × 2 = 0.8 → 0
0.8 × 2 = 1.6 → 1
0.6 × 2 = 1.2 → 1 ← конец цикла
0.2 × 2 = 0.4 → 0 ← цикл повторяется
...
В результате получается 0.0(0011) — бесконечная периодическая двоичная дробь. Ситуация аналогична тому, как в десятичной системе дробь 1/3 превращается в бесконечное 0.3333... — записать её точно в конечном числе знаков невозможно.
Число 0.2 сталкивается с той же проблемой. Оба числа, с которыми мы выполняем сложение, уже на этапе записи в память содержат погрешность.
Стандарт IEEE 754: компромисс между точностью и производительностью
Подавляющее большинство современных процессоров используют стандарт IEEE 754 для работы с числами с плавающей точкой. Тип double (64-битное число двойной точности), который является стандартным для большинства языков программирования, устроен следующим образом:
- 1 бит отводится под знак числа (положительное или отрицательное)
- 11 бит — под экспоненту (порядок числа)
- 52 бита — под мантиссу (значащие цифры)
52 бита мантиссы — это примерно 15–17 значащих десятичных цифр. Когда двоичное представление дроби оказывается бесконечным, оно обрезается на 52-м бите. Именно в этот момент возникает погрешность представления.
При сложении двух чисел, каждое из которых уже содержит погрешность, ошибки накапливаются. Так 0.1 + 0.2 превращается в 0.30000000000000004.
Когда погрешность допустима
Справедливости ради, для большинства задач эта погрешность совершенно незначительна. Речь идёт об ошибке порядка 10⁻¹⁶ — это одна квадриллионная.
В следующих областях double работает прекрасно:
- Компьютерная графика и рендеринг — разница в шестнадцатом знаке после запятой невидима для человеческого глаза
- Физические симуляции в играх — объекты ведут себя реалистично, погрешность не влияет на восприятие
- Научные вычисления — в большинстве случаев результат округляется до нужной точности
- Статистика и машинное обучение — модели оперируют приближениями по своей природе
Когда погрешность становится критической: финансовые вычисления
Ситуация кардинально меняется, когда речь заходит о деньгах. В финансовых системах каждая копейка должна быть учтена точно. Ошибка в шестнадцатом знаке после запятой может показаться несущественной для одной транзакции, но при обработке тысяч и миллионов операций эти микроскопические погрешности накапливаются и превращаются в реальные расхождения в балансах.
Рассмотрим конкретный пример. Допустим, система обрабатывает 100 000 транзакций в день, и в каждой из них возникает погрешность в 0.000000000000004. За день это 0.0000000004 — ничтожно мало. Но умножьте на год, добавьте более сложные операции с умножением и делением, где погрешности растут значительно быстрее, — и вы получите суммы, которые невозможно объяснить ни бухгалтеру, ни аудитору.
Надёжные подходы к работе с денежными суммами
Подход первый: минорные единицы как целое число
Суть подхода проста: вместо хранения суммы в рублях или долларах как дробного числа, она хранится в наименьших единицах валюты (копейках, центах, пенни) как целое число.
19999 копеек — это всегда ровно 199 рублей и 99 копеек. Никаких округлений, никаких сюрпризов, никаких 0.000000004 в хвосте. Целочисленная арифметика в компьютерах абсолютно точна.
Этот подход широко используется в платёжных системах. Stripe, например, оперирует суммами исключительно в минимальных единицах валюты.
Отдельно стоит упомянуть альтернативный вариант для API: передача денежных сумм в виде строк. В этом случае интерфейс остаётся человекочитаемым ("199.99" понятнее, чем 19999), а ответственность за парсинг и выбор подходящего числового типа ложится на клиентскую сторону.
Подход второй: библиотеки произвольной точности (BigDecimal)
Второй надёжный вариант — использование специализированных типов данных, которые хранят десятичные числа без преобразования в двоичную систему:
- Dart — пакет
decimal - Java —
java.math.BigDecimal - Python — модуль
decimalиз стандартной библиотеки - JavaScript — предложение Decimal на стадии рассмотрения, а пока — библиотеки вроде
decimal.js - Go — пакет
shopspring/decimal
Эти типы представляют числа в десятичном виде, полностью избегая двоичных приближений. Выражение 0.1 + 0.2 == 0.3 при использовании BigDecimal возвращает true — гарантированно.
Платой за точность является производительность: операции с BigDecimal значительно медленнее, чем с double. Однако в контексте финансовых вычислений, где корректность результата важнее скорости, это более чем приемлемый компромисс.
Какой подход выбрать
Оба подхода решают проблему, но подходят для разных ситуаций:
| Критерий | Минорные единицы (int) | BigDecimal |
|---|---|---|
| Производительность | Максимальная | Ниже |
| Простота реализации | Высокая | Средняя (зависит от языка) |
| Поддержка дробных единиц | Нет (только целые) | Да |
| Мультивалютность | Требует знания количества знаков | Работает из коробки |
Для большинства e-commerce и платёжных систем подход с минорными единицами является оптимальным. BigDecimal предпочтительнее в сценариях, где необходимы промежуточные дробные вычисления — например, при расчёте процентов, налогов или конвертации валют.
Заключение
Если в вашем проекте есть денежные суммы, следует придерживаться одного из двух правил:
- Хранить и передавать суммы в минорных единицах как целое число (
int) - Использовать типы с произвольной точностью (
BigDecimalи его аналоги)
Использование double для финансовых вычислений — это техническая задолженность, которая может не проявляться месяцами, но в определённый момент приведёт к расхождениям, причину которых будет крайне сложно диагностировать.
Выбирайте инструменты, соответствующие задаче, и будьте внимательны к тому, как ваш язык программирования представляет числа в памяти. Это одна из тех вещей, о которых полезно знать до того, как она станет проблемой на продакшене.
С днём числа Пи! 🥧