Как компьютер хранит дробные числа
Тип float в Python — это число двойной точности по стандарту IEEE 754, устроенное как мантисса и степень двойки, а не как привычная десятичная запись с точкой. Двоичная система идеально представляет дроби вида одна вторая, одна четверть или одна восьмая, потому что это степени двойки, но большинство обычных десятичных дробей — 0.1, 0.2, 0.3 — в двоичном виде превращаются в бесконечную периодическую последовательность, которую приходится обрывать до конечного числа битов.
Похожая ситуация возникает в десятичной системе с дробью одна треть — она записывается только как бесконечное 0.333 с продолжением, и любая конечная запись вроде 0.33 уже приближение. С float происходит то же самое, только незаметно для человека: число 0.1 хранится в памяти как ближайшее двоичное число, которое удалось уместить в 64 бита, а вовсе не как ровно одна десятая — очень близкое к ней значение, но не идеально равное.
Классический пример — 0.1 плюс 0.2
Если в интерпретаторе Python напечатать 0.1 + 0.2, результат окажется 0.30000000000000004 — крошечный хвост из нулей и четвёрки на конце выдаёт накопленную погрешность обоих слагаемых. Сравнение 0.1 + 0.2 == 0.3 при этом вернёт False, хотя человек интуитивно ждёт True: слева получилось число, чуть большее настоящих 0.3, а справа стоит своё, отдельно округлённое представление той же тройки десятых, и они не совпадают побитно.
Похожая история с умножением: 1.1 умножить на 3 в Python даёт 3.3000000000000003 вместо ровных 3.3. Ошибка здесь исчезающе мала — на пятнадцатом знаке после запятой, — но при работе с деньгами именно такие крошечные хвосты со временем превращаются в заметную разницу между тем, что показывает программа, и тем, что реально лежит на счёте.
Накопление ошибки при сложении сумм
Единичная погрешность в пятнадцатом знаке выглядит безобидно, но при сложении в цикле она складывается сама с собой на каждом шаге. Если в цикле десять раз подряд прибавить к переменной 0.1, результат окажется не 1.0, а 0.9999999999999999 — на последнем разряде минус крошечная доля. Проверка на равенство этого результата единице вернёт False, хотя человек, складывавший десять монет по десять копеек, ожидает ровно рубль.
Масштаб проблемы растёт вместе с числом операций: тысяча сложений той же 0.1 даёт уже 99.9999999999986 вместо ровных 100 — расхождение заметно выросло по сравнению с десятью сложениями. Учётная система, которая складывает тысячи мелких транзакций за день через float, рискует накопить погрешность, заметную даже без специальной проверки — именно поэтому банковское и бухгалтерское программное обеспечение с float для денег работать избегает.
Округление тоже не спасает от погрешности
Интуиция подсказывает, что round решит проблему — просто отрезать лишние цифры после запятой. На практике round(2.675, 2) в Python возвращает 2.67, а не ожидаемые человеком 2.68, потому что само число 2.675 уже хранится в памяти как значение чуть меньше настоящих 2.675, и округление честно работает с этим слегка заниженным представлением, а не с идеальной десятичной записью, которую видит программист в коде.
У встроенной функции round есть и второй сюрприз — банковское округление до чётной цифры для ровно половинных значений. round(0.5) даёт 0, round(1.5) даёт 2, round(2.5) снова даёт 2, а round(3.5) даёт 4: округление всегда идёт к ближайшему чётному числу, а не механически вверх, как учат в школе. Для денежных расчётов такое поведение стоит знать заранее, а не обнаруживать в продакшене на реальных суммах.
- 0.1 + 0.2 == 0.3 возвращает False из-за разных округлений слагаемых
- сумма 0.1 в цикле десять раз даёт 0.9999999999999999, а не 1.0
- round(2.675, 2) даёт 2.67, потому что 2.675 хранится с занижением
- round(2.5) даёт 2 — округление банковское, к ближайшему чётному числу
Как правильно считать деньги — целые копейки
Самый надёжный и самый распространённый в реальных финансовых системах подход — хранить сумму как целое число наименьших единиц: копеек или центов, а не рублей с дробной частью. Целые числа в Python складываются и вычитаются абсолютно точно, без какой-либо погрешности округления, поэтому сумма 199 плюс 299 копеек всегда даст ровно 498, без сюрпризов. Для показа пользователю сумму делят на сто только в момент форматирования строки — сама арифметика всё время идёт в целых копейках.
Decimal как альтернатива для дробных расчётов
Когда целые копейки неудобны — например, при расчёте процентной ставки или скидки с дробным множителем — на помощь приходит модуль decimal из стандартной библиотеки. Decimal хранит число как точную десятичную дробь, а не как двоичное приближение, поэтому Decimal('0.1') плюс Decimal('0.2') даёт ровно Decimal('0.3') без единого лишнего знака. Создавать значения decimal нужно из строки, а не из float: Decimal(0.1) унаследует уже испорченное двоичное приближение исходного float, и вся точность модуля пропадёт впустую.
Почему банки предпочитают именно банковское округление
Округление всегда вверх на половинных значениях кажется естественным, но при массовой обработке тысяч транзакций оно систематически завышает итоговую сумму: каждое округление ровно наполовину смещает результат в одну и ту же сторону. Банковское округление к ближайшему чётному числу распределяет эту погрешность поровну между завышением и занижением, и на большом объёме операций отклонения гасят друг друга вместо того, чтобы накапливаться в одном направлении.
Частые вопросы
Почему просто нельзя увеличить точность float
Проблема кроется в самой двоичной системе счисления, а не в количестве разрядов — многие обычные десятичные дроби в ней принципиально не представляются точно, сколько бы битов ни выделили под число.
Всегда ли Decimal медленнее float
Да, арифметика с Decimal заметно медленнее из-за более сложного внутреннего представления числа, но для финансового кода точность обычно важнее скорости, а разница на реальных объёмах транзакций редко становится узким местом.
Можно ли просто округлять float на каждом шаге
Округление после каждой операции снижает риск, но полностью проблему не убирает — само округление float подчиняется той же банковской логике и своим граничным случаям, разобранным выше.
Что делать с уже написанным кодом на float для денег
Стоит постепенно переводить хранение сумм на целые копейки или Decimal, начиная с мест, где происходит суммирование множества операций — именно там погрешность накапливается быстрее всего и заметнее всего проявляется.