Как хранятся дробные числа
В переменной типа float в Python, Number в JavaScript или double в C и Java хранится не десятичная дробь, а двоичное число с плавающей точкой двойной точности. Значение складывается из знака, мантиссы и двоичного порядка — то есть из конечной суммы степеней двойки. Десятичная дробь представима точно только в том случае, если её знаменатель в несократимой записи — степень двойки: 0.5, 0.25 и 0.75 хранятся ровно, а 0.1, 0.2 и 0.3 в двоичной системе превращаются в бесконечные периодические дроби. Вместо них хранится ближайшее представимое число, которое чуть отклоняется от литерала.
На заглавном примере 0.1 и 0.2 хранятся чуть больше записанного, а 0.3 — чуть меньше. Сумма округляется до ближайшего представимого числа, и оно оказывается соседним с тем, которым хранится литерал 0.3. Поэтому сравнение 0.1 + 0.2 == 0.3 ложно. В какую сторону отклоняется хранимое значение, зависит от конкретного числа.
Это свойство двойной точности для значений в переменных, а не дефект языка. Результат одинаков во всех языках с таким типом, а само поведение общее для всех языков с двоичными дробными числами; примеры в тексте даны на Python. Оговорка про переменные не случайна: там, где компилятор вычисляет константные выражения точно, запись литералами может дать истину, а те же значения в переменных — ложь. В Python разницы нет: литералы и переменные дают один результат.
В C и Java float — одинарная точность, другой тип с тем же именем. В ней на числах заглавного примера сравнение случайно истинно (0.1f + 0.2f == 0.3f), но механизм тот же и проявляется на других числах.
print(0.1 + 0.2 == 0.3) # False
Почему печать обманывает
Что появится на экране, решает функция вывода, а не формат. Python и JavaScript печатают кратчайшую десятичную запись, которая при обратном чтении даёт то же двоичное число. Поэтому 0.1 выводится как 0.1, а сумма — как 0.30000000000000004. Вывод с фиксированным числом знаков (спецификатор %f в C без указания точности) покажет 0.300000, но сравнение с 0.3 останется ложным. Хранимое значение в Python показывает Decimal(0.1) из модуля decimal.
from decimal import Decimal
print(Decimal(0.1)) # 0.1000000000000000055511151231257827021181583404541015625
Когда сравнение через == корректно
Оператор == работает для значений, которые хранятся и складываются точно. Это целые числа в пределах точного диапазона и дроби со знаменателем-степенью двойки. Например, 0.5 + 0.25 == 0.75 истинно. Граница точных целых — свойство двойной точности, а не языка. За ней шаг между соседними представимыми числами больше единицы, и соседние целые становятся неразличимы. В JavaScript граница задана константой Number.MAX_SAFE_INTEGER, проверка — Number.isSafeInteger. В Python тип int произвольной длины, и граница возникает только при переводе в float.
Для приближённых значений сравнение требует допуска. Встроенная функция math.isclose(a, b) в Python проверяет условие abs(a - b) <= max(rel_tol * max(abs(a), abs(b)), abs_tol). По умолчанию абсолютный допуск отключён: параметр abs_tol равен нулю, поэтому сравнение ненулевого числа с нулём всегда ложно. Относительный допуск по умолчанию задан параметром rel_tol, его значение показывает help(math.isclose). Один относительный допуск не работает около нуля, один абсолютный — на больших величинах, поэтому рабочая проверка задаёт оба: и rel_tol, и abs_tol.
Машинный эпсилон (sys.float_info.epsilon в Python, Number.EPSILON в JavaScript) — это расстояние от единицы до следующего представимого числа, а не универсальный допуск. Шаг между соседними числами удваивается с каждой степенью двойки. Проверка abs(0.1 + 0.2 - 0.3) < sys.float_info.epsilon истинна, но та же проверка для 1000.1 + 1000.2 и 2000.3 ложна.
Где ошибка растёт
Сложение с плавающей точкой не ассоциативно. (0.1 + 0.2) + 0.3 даёт 0.6000000000000001, а 0.1 + (0.2 + 0.3) — 0.6. Десять прибавлений 0.1 в явном цикле s += 0.1 дают 0.9999999999999999. Сумма зависит от порядка слагаемых, и это одна из причин, по которой ответ языковой модели не повторяется побитово даже при жадном выборе.
Граница точных целых касается и сумм в целых единицах, если они попали в дробный тип. За ней шаг между числами больше единицы, и соседние целые становятся неразличимы.
s = 0.0
for _ in range(10):
s += 0.1
print(s) # 0.9999999999999999
Как считать деньги
Для денег двоичные дробные типы не подходят. Значение от ввода до хранения живёт либо в целых минимальных единицах, либо в десятичном типе, созданном из строки. Десятичный тип точно хранит десятичные дроби, но не делает точной любую арифметику. В Python у decimal контекст по умолчанию ограничивает число значащих цифр и округляет половину к чётному. Текущие значения показывает decimal.getcontext().
Десятичное значение создают из строки. Decimal('0.1') хранит ровно одну десятую, а Decimal(0.1) наследует двоичную погрешность исходного числа. Перевод суммы в минимальные единицы умножением дробного числа теряет единицу: 19.99 * 100 даёт 1998.9999999999998, int(0.29 * 100) — 28. Значение разбирают из строки ввода. В каком виде передавать сумму между системами, разобрано в материале JSON как контракт: где он подводит; здесь речь только об арифметике внутри программы.
Ставка или курс, записанные двоичным числом, возвращают погрешность в расчёт через множитель. Поэтому их тоже задают десятичным типом или целым числом долей. Число минимальных единиц в основной зависит от валюты. Есть валюты без дробной части и валюты, где знаков после запятой больше двух. Поэтому сумма в целых единицах хранится вместе с валютой, а множитель не зашивается в код константой.
В базе столбец суммы — целое число минимальных единиц или десятичный тип numeric: рядом с ним в SQL существуют двоичные real и double precision, и столбец суммы такого типа даёт ту же погрешность в агрегатах.
from decimal import Decimal
print(Decimal('0.1') + Decimal('0.2') == Decimal('0.3')) # True
Чего не решает десятичный тип
Десятичный тип не избавляет от необходимости задавать режим округления. В Python Decimal('2.665').quantize(Decimal('0.01')) даёт 2.66, потому что контекст по умолчанию округляет половину к чётному. Округление «половина вверх» задаётся явно параметром rounding=ROUND_HALF_UP.
Деление с остатком порождает копеечное расхождение. Одна треть не представима конечной записью и в десятичной системе. Поэтому деление суммы на доли требует явного правила: доли округляют вниз, а оставшиеся минимальные единицы раздают по одной по заранее названному правилу. Например, 100 // 3 даёт 33, остаток 1, доли 34, 33, 33. Задача арифметическая, а не форматная: она одинакова для целых единиц и для десятичного типа.
Копеечное расхождение итога с суммой строк имеет две независимые причины: двоичную погрешность и порядок округления. Три строки по Decimal('0.335') при округлении до сотых «половина вверх» дают в сумме 1.02, а округлённая сумма — 1.01. Вторая причина сохраняется и в десятичном типе, и в целых минимальных единицах.
Тупик: округление
Первым пробуют округлить результат перед сравнением или сумму перед показом. На заглавном примере приём срабатывает: round(0.1 + 0.2, 2) == 0.3 истинно, потому что ближайшее двоичное число к округлённому значению совпадает с тем, которым хранится литерал. Но округление слагаемых до сложения не работает: round(0.1, 1) + round(0.1, 1) + round(0.1, 1) == round(0.3, 1) ложно.
Округление только при показе не убирает погрешность, уже накопленную в промежуточных суммах: на экране число выглядит ровным, а в переменной остаётся прежнее значение, и следующий расчёт берёт именно его. На границе половины действуют два независимых механизма. Представление: 2.675 хранится чуть меньше записанного, и round(2.675, 2) даёт 2.67; 8.345 хранится чуть больше, и round(8.345, 2) даёт 8.35. Правило округления: round в Python округляет половину к чётному, поэтому round(2.5) даёт 2, а round(0.5) — 0, хотя оба числа хранятся точно. В JavaScript Math.round(2.5) даёт 3 — правило там другое.
Форматирование (toFixed в JavaScript, f-строка в Python) возвращает строку, а не число. Для вывода оно уместно, но ошибка — возвращать его результат в расчёт.
Позиция редакции
Для денег и любых величин, которые сверяются до единицы, двоичные дробные типы не используются. Значение от ввода до хранения живёт либо в целых минимальных единицах, либо в десятичном типе, созданном из строки, с явно заданным режимом округления. Сравнение с допуском — приём для измерений и геометрии, а не способ починить денежный расчёт.
Неверна эта позиция для величин, приближённых по своей природе: физических измерений, координат, статистики, графики. Входные данные там уже неточны, десятичный тип точности не добавит, а считать будет медленнее. И её одной недостаточно, когда расчёт порождает дробные минимальные единицы: ставки, курсы, деление суммы на доли. Тогда нужно ещё явное правило округления и распределения остатка. Базовые типы подводят и в других случаях — например, время в приложении.