Каждый разработчик рано или поздно встречает странность. Складывает 0.1 и 0.2, ожидает 0.3, а программа отвечает 0.30000000000000004. Первое желание - считать это багом языка или железа. На деле это фундаментальное свойство двоичного представления чисел, которое одинаково проявляется в JavaScript, Python, Java, C++ и любом другом языке с типом двойной точности. Понимание этой природы отличает инженера, который пишет надёжный код, от инженера, который однажды услышит от бухгалтерии вопрос о пропавшей копейке.
Почему десятичные дроби не помещаются в двоичный мир
Человек привык к системе с основанием десять. Дробь 1/10 записывается там красиво - одна цифра после запятой. Но компьютер считает в системе с основанием два, где после запятой допустимы только степени двойки: 1/2, 1/4, 1/8, 1/16 и так далее. Попытка собрать 0.1 из таких слагаемых даёт бесконечную периодическую двоичную дробь 0.0001100110011... Примерно как 1/3 в десятичной системе превращается в 0.3333... и её невозможно записать конечным числом цифр.
Память конечна, значит, бесконечный хвост цифр приходится обрезать. Компьютер сохраняет не 0.1, а ближайшее к ней число, которое чуть больше истинного значения. Ту же участь терпит 0.2. Когда программа складывает две такие заготовки, накапливаются две маленькие ошибки, и сумма выбивается за пределы того числа, которое хранится под именем 0.3. Инженер видит длинный хвост лишних цифр и удивляется, хотя арифметика сработала ровно так, как спроектирована.
Внутри стандарта IEEE 754
Подавляющее большинство вычислений использует формат двойной точности, описанный в стандарте IEEE 754. Одно число занимает 64 бита, и они распределены очень конкретно. Один бит отвечает за знак, одиннадцать бит - за экспоненту, пятьдесят два бита - за мантиссу, то есть за значащую часть числа. Скрытая единица, которая полагается по умолчанию перед мантиссой, поднимает эффективную точность до 53 бит.
Пятьдесят три двоичных знака дают примерно 15.95 десятичных знаков точности. Поэтому осторожный инженер считает, что надёжны первые пятнадцать значащих цифр, а всё дальше - территория шума. Вторая важная величина - машинный эпсилон, равный примерно 2.22 умножить на десять в минус шестнадцатой степени. Это разница между единицей и ближайшим к ней большим числом, и то же значение определяет минимальный шаг представления чисел порядка единицы. Ниже этого порога арифметика просто не различает значения.
При сложении 0.1 и 0.2 двойная точность выдаёт 0.3000000000000000444 и дальше хвост цифр. Число, которое язык по умолчанию печатает как 0.3, на самом деле чуть меньше этой суммы. Разница крошечная, но для оператора сравнения она решает всё.
Правила выживания инженера
Опыт выработал набор приёмов, которые позволяют жить с плавающей запятой, а не воевать с ней.
- Никогда не сравнивать дробные числа напрямую. Вместо проверки a равно b проверяют, что модуль разности a минус b меньше выбранного допуска. Допуск опирается на машинный эпсилон и масштаб данных. Для чисел порядка единицы берут значение вида 1e-9, для больших чисел - относительный допуск, умноженный на масштаб операндов.
- Деньги хранить и считать в целых числах. Копейка, цент или филлинг кодируется целым, и сумма в рублях превращается в целое число копеек. При этом исключается любое дробное округление до самого финального шага, где копейки делятся на сто ради красивой строки в интерфейсе.
- Там, где нужна точная десятичная арифметика, брать десятичные типы. В Java это BigDecimal, в C# тип decimal - сто двадцать восемь бит с десятичной базой, где 0.1 представляется точно. Цена - меньший диапазон и заметно более медленные операции, поэтому такие типы живут в финансовых расчётах, а не в физических симуляциях.
- Помнить о порядке суммирования. Добавление крошечного слагаемого к огромной сумме просто теряется, потому что малый вклад тонет в погрешности крупного числа. Массивы сортируют по возрастанию модулей или применяют суммирование Кэхэна, где отдельная переменная накапливает потерянные хвосты и возвращает их в итог.
- Остерегаться вычитания близких чисел. Когда из числа вычитают почти такое же, значащие цифры взаимно уничтожаются, и на передний план выходят биты погрешности. Этот эффект называют катастрофической потерей значимости. Часто формулу удаётся переписать алгебраически так, чтобы близкие величины не вычитались напрямую.
- Округлять осознанно в отчётности. Классическая ловушка: 2.675 невозможно представить конечной двоичной дробью, и в формате 64 бит оно хранится как 2.674999999999999822..., то есть строго меньше середины. Поэтому любое округление до сотых, независимо от правила тай-брейкера, даст 2.67 - виновата потеря точности мантиссы, а не правило округления. Само банковское округление (round half to even) срабатывает лишь на точной середине: 2.5 даёт 2, а 3.5 даёт 4, потому что берётся ближайшее чётное.
Где это кусает на практике
Бухгалтерия страдает первой. Стоимость заказа из тысячи позиций, рассчитанная в дробных рублях, расходится с суммой, собранной из целых копеек, и сверка конца месяца показывает странный долг в несколько копеек. Налоговая форма отвергает отчёт, потому что суммы по строкам не бьются с итогом. Разработчик ищет ошибку в логике, а она прячется в представлении числа.
Игры и симуляции получают другую рану - дрейф. Физический движок десятки тысяч раз в секунду пересчитывает координаты, и крошечные погрешности шаг за шагом накапливаются. Объект медленно уползает с места, точные спиральные траектории осыпаются, две копии симуляции на разных машинах расходятся через несколько минут. Игроки называют такое поведение нелепостью движка, а инженер зовёт его накопленной ошибкой округления.
Особняком стоит NaN - тихий исполнитель. Это особое значение означает не число, и по стандарту выражение NaN равно NaN ложно. Любое сравнение с NaN ложно, включая сравнение с самим собой. Единственный надёжный способ проверки - функция вида isnan, а не сравнение. Кто забыл это правило, однажды долго читал код, где переменная никогда не равнялась сама себе.
Знаменитая история накопления ошибки
Инженерная культура хранит известный случай системы с копеечным таймером. Таймер внутри такой системы отсчитывал доли секунды целыми тиками, а перевод тика в секунду выполнялся умножением на константу, которую внутренний регистр хранил с обрезанной точностью. Пока часы работы исчислялись несколькими часами, ошибка оставалась микроскопической. После нескольких суток непрерывной работы накопившееся отставание достигло заметной доли секунды, и система сопровождения начала видеть цель не там, где та находилась на самом деле. Урок прост и непригляден: любая константа, которая участвует в пересчёте времени, должна либо быть точной, либо процедура обязана периодически сбрасывать счётчик, не давая ошибке копиться. Тот случай по сей день разбирают на курсах надёжности как эталонный пример того, что копеечная экономия бит в таймере обходится недёшево.
Как читать числа без страха
Зрелый инженер относится к плавающей запятой как к инструменту с известной погрешностью. Он выбирает тип под задачу: целые копейки для денег, decimal для законодательно важных расчётов, двойную точность с допусками для физики и графики. Он пишет функцию сравнения с допуском один раз и использует её везде. Он суммирует массивы осмысленно, а в критических местах - по Кэхэну. Он округляет только на границе с человеком и явным правилом. Парадокс 0.1 плюс 0.2 перестаёт быть загадкой и становится естественным свойством инструмента, которым пользуются с уважением, а не со страхом.
Деньги, копейки и десятичная арифметика
Финансовая математика - самая обидчивая к ошибкам область, и у опытных программистов есть для неё железное правило: деньги не считают в double. Валютная копейка представляет ровно одну сотую единицы, а сотые доли в двоичной системе не представимы точно никогда, независимо от ширины мантиссы. Решение старое как бухгалтерия: считать в целых копейках, храня в целом типе количество сотых долей, и делить на сто только в момент печати на чек. Современные языки идут дальше и дают десятичный тип: decimal в C# упаковывает 96 бит мантиссы с десятичной экспонентой и потому держит 28 значащих десятичных цифр без двоичных пережитков, что делает его родным для ERP и биллинга. Плата за честность - производительность: десятичная арифметика в десятки раз медленнее аппаратной двоичной, и для физических симуляций с его помощью не работают.
Суммы Кэхэна и аккуратное сложение
Суммирование длинных последовательностей - тихий генератор дрейфа. Каждое сложение в double даёт ошибку не хуже половины ULP, но ошибки суммируются, и на сотнях миллионов слагаемых они съедают точность видимо. Классическое лекарство - компенсированное суммирование: алгоритм Кэхэна хранит отдельную накопительную ошибку и вычитает её из следующего слагаемого, доводя итоговую погрешность до уровня, почти не зависящего от длины списка. Другой приём порядок силы: сортировка слагаемых по возрастанию модуля перед суммированием дешево и эффективно, потому что малые добавки не глушатся ушедшей вперёд большой суммой. Инженерам численных библиотек известны и попарные схемы: складывают соседей попарно как турнирную сетку, и ошибка растёт логарифмически вместо линейно.
Сравнение с допуском и вычитание близких величин
Сравнение двух float на точное равенство после цепочки вычислений - экзаменационная ошибка: считайте разницу с модулем допуска, и выбирайте допуск не абсолютный, а относительный к масштабу операндов. Обратная по значимости ловушка - вычитание близких величин: из двух больших близких чисел разность состоит в основном из шумовых битов, а относительная погрешность взрывается. В численных пакетах потому и существуют формулы: вместо sqrt(x+1)-1 пишут вариант с подавлением двойного вычитания, вместо 1-cos(x) при малых x - равносильный 2sin²(x/2). Эти переписывания выглядят избыточными лишь до тех пор, пока два почти равных числа не разошлись в результате до знака, которого в физическом мире, из которого они пришли, быть не должно.
Отчётность и человеческий фактор
У делового пользователя к проблеме своё лицо: Excel после суммирования тысяч строк выдаёт 0.30000000000004 в итоге, и бухгалтер теряет дар речи перед аудитором. Здесь помогают не только численные типы, но и дисциплина отчётности: округлять вручную перед печатью и после суммирования, а не доверять форматированию ячеек, которое прячет проблему, а не решает её. Ради иллюзии порядка отчётность ломать нельзя - разница в последнем знаке потом встретится принтером и аудитором, и ей придётся объясняться двумя разными людьми.
Почему это видно не только в отчётах. Игровой физический движок со накоплением ошибок позиции замечает эффект на больших координатах: у персонажа, убежавшего далеко от центра мира, начинают дергаться анимации, и причина не в движке плохой, а в том, что мантисса в 24 бита одиночного формата перестаёт разделять соседние миллиметры. Разработчики открытых миров поэтому прибегают к пересчёту начала координат: мир движется вокруг игрока, а не игрок бежит по бесконечной дали. Такое переустройство нередко приводит к последствиям в сетевой игре - одна и та же комната для двух клиентов может описываться немного разными координатами, и синхронизация лагов начинает жить собственной жизнью. Здесь уже нет ничего мысленного: разработчики движка формулируют численные правила как предмет разработки, а отладчик на видеокарте показывает дрейф позиций прямо в кадре. Отдельный приём - использовать двойную точность на сервере и хранить координаты относительно локальной ячейки, превращая глобальную картографию в адресное пространство из страниц.
Короткое домашнее правило гласит просто: если точно важны десятичные доли, оставайтесь в десятичном типе или целых копейках; если важна скорость и математика сигналов, принимайте двоичную природу и защищайте сравнения допусками. Граница между этими мирами не тонкая, а прагматичная, и оплачивается она всегда тестированием, а не декларациями.