Почему 0.1 + 0.2 ≠ 0.3: числа с плавающей точкой и деньги
Практика3 минРедакция Innovialabs
Коротко
Тип float хранит числа в двоичном виде приблизительно, поэтому для денег используют целые числа в минимальных единицах (тийины, центы) или десятичные типы вроде Decimal и NUMERIC.
Короткий ответ
Большинство языков хранят дробные числа по стандарту IEEE 754 — в двоичной системе. Число 0.1 в двоичном виде бесконечная дробь, как 1/3 в десятичной. Компьютер обрезает её, и в памяти лежит очень близкое, но не точное значение.
0.1 + 0.2 // 0.30000000000000004
0.1 + 0.2 === 0.3 // false
Для научных расчётов такая погрешность обычно не важна. Для денег — важна: копейка, потерянная в одной операции, превращается в расхождение в отчёте, а сумма к оплате не совпадает с суммой в чеке.
Правило: не храните и не считайте деньги во float и double.
Как устроен float
Число в формате IEEE 754 состоит из трёх частей: знак, мантисса (значащие цифры) и экспонента (куда сдвинуть точку). Тип double даёт примерно 15–17 значащих десятичных цифр.
Отсюда два вида проблем:
- Непредставимые дроби. 0.1, 0.2, 0.7 нельзя записать в двоичной системе точно.
- Потеря точности на больших числах. Когда число очень велико, младшие разряды просто не помещаются в мантиссу.
Ошибки накапливаются: сложите тысячу раз по 0.1 — и результат уже заметно отличается от 100.
Способ 1: целые числа в минимальных единицах
Храните сумму как целое число в самой мелкой единице валюты: центы для доллара, тийины для сума.
- 15 000,50 сум →
1500050тийинов - $19.99 →
1999центов
Целые числа складываются и вычитаются точно. Переводить в «человеческий» вид нужно только при выводе на экран.
price_tiyin = 1_500_050
qty = 3
total_tiyin = price_tiyin * qty # 4 500 150, точно
print(f"{total_tiyin / 100:,.2f} сум") # только для отображения
Учтите: у разных валют разное число знаков после запятой. Для узбекского сума по ISO 4217 их два, хотя тийины в наличном обороте почти не встречаются. Многие системы для сума ведут учёт сразу в целых сумах — это допустимо, если так зафиксировано в требованиях.
Способ 2: десятичные типы
Десятичные типы хранят число в десятичной системе и считают без двоичных погрешностей.
| Среда | Тип |
|---|---|
| Python | decimal.Decimal |
| Java | BigDecimal |
| C# | decimal |
| PostgreSQL, MySQL | NUMERIC(p, s) / DECIMAL(p, s) |
| JavaScript | сторонние библиотеки для десятичной арифметики |
from decimal import Decimal, ROUND_HALF_UP
a = Decimal("0.1") + Decimal("0.2") # Decimal('0.3')
vat = (Decimal("15000.50") * Decimal("0.12")).quantize(
Decimal("0.01"), rounding=ROUND_HALF_UP
)
Важно создавать Decimal из строки, а не из float: Decimal(0.1) унаследует ту же погрешность.
Округление — это бизнес-правило
Даже с точными типами деньги приходится округлять: налоги, скидки, курсы валют. Определите заранее:
- Режим округления. Математическое «половина вверх» или банковское (к чётному). Например, встроенный
round()в Python использует банковское. - Момент округления. На каждой строке чека или один раз на итоге — результаты могут различаться.
- Разделение сумм. 100 на троих не делится ровно: 33.33 + 33.33 + 33.34. Остаток явно отдают одной из частей.
Чек-лист для проекта
- В базе —
NUMERICили целое число в минимальных единицах, никогдаFLOATилиREAL. - В API — суммы строкой (
"15000.50") или целым числом в минимальных единицах, рядом код валюты. - Валюта хранится вместе с суммой, а не подразумевается.
- Правила округления описаны в требованиях и покрыты тестами.
- Курсы валют хранятся с достаточной точностью и датой.
- Фронтенд только отображает суммы, а итоговый расчёт делает сервер.
Частые ошибки
- Сравнение float через
==. parseFloatдля сумм из формы с последующей арифметикой.- Тип
moneyили float в JSON без договорённости о точности. - Округление на каждом промежуточном шаге без правила.