Вес 0.7000000000000001 кг: откуда в JavaScript берутся такие числа

—

от автора

Вес роутера 0.7000000000000001 кг на М.Видео. Штемпель 0.30000000000000004×2.5 см на Wildberries. Эти числа никто не вводил руками: где-то значение посчитали и забыли округлить.

Если вы хоть раз выкатывали в прод что-то вроде

0.1 + 0.2;

то знаете, какие отзывы бывают после этого.

Разберёмся, откуда берутся такие числа:

0.300000000000000040.7000000000000001

Ровно они и попали в карточки выше.

Постараюсь объяснить понятно, а не исчерпывающе. Цель в том, чтобы разобраться мог и дизайнер, и тестировщик, и бэкенд-разработчик, а не только JS-разработчик.

Для начала несколько примеров

Вот что JavaScript спокойно возвращает:

1.1 * 3// 3.30000000000000030.57 * 100// 56.999999999999994.35 * 100// 434.999999999999940.1 + 0.7// 0.7999999999999999

Но почему?

Возьмём обычный калькулятор:

10 / 3 = 3.333333333333...

1/3 нельзя точно записать конечным числом цифр в десятичной системе.

На это никто не обижается: мы к этому привыкли.

Внутри компьютера происходит то же самое. Просто проблема не с 1/3.

JavaScript хранит Number в двоичном формате

Обычный Number в JavaScript хранится в формате IEEE 754 binary64. На самом базовом уровне это значит, что число представлено битами:

01

Например:

0 → 01 → 12 → 103 → 114 → 1005 → 1016 → 1107 → 1118 → 1000

С небольшими целыми всё просто: они представляются точно. А что с дробями?

Как записать 0.25 в двоичной системе?

Для дробной части есть простой алгоритм.

Берём число и умножаем на 2. Целую часть записываем как очередной бит.

0.25 × 2 = 0.5 → 00.5 × 2 = 1.0 → 1

Дробная часть стала 0, останавливаемся:

0.25₁₀ = 0.01₂

Почему умножаем именно на 2? Потому что умножение на основание системы просто сдвигает запятую. В десятичной системе 3.14 × 10 = 31.4: цифра перепрыгнула через запятую. Умножение на два делает то же самое в двоичной системе. Каждый шаг выталкивает очередной бит, и мы его записываем.

Теперь попробуем 0.1

Здесь начинается самое интересное.

0.1 × 2 = 0.2 → 00.2 × 2 = 0.4 → 00.4 × 2 = 0.8 → 00.8 × 2 = 1.6 → 10.6 × 2 = 1.2 → 10.2 × 2 = 0.4 → 00.4 × 2 = 0.8 → 00.8 × 2 = 1.6 → 1...

Мы снова и снова попадаем на одни и те же значения. Получается:

0.1₁₀ =0.0001100110011001100110011...₂

И это 1100 повторяется бесконечно.

С этого и начинается вся проблема.

Спросим у самого JavaScript

JavaScript может показать число в двоичном виде:

(0.1).toString(2);

Получим:

0.0001100110011001100110011001100110011001100110011001101

Важная оговорка: toString(2) не показывает буквально 64 бита, которые лежат в памяти. Это строковое представление числа в двоичной системе.

Но проблему оно показывает отлично: в двоичной системе 0.1 никогда не заканчивается.

Посмотрим на 0.1 с другой стороны

(0.1).toFixed(20);

Получаем:

"0.10000000000000000555"

То есть 0.1 это не совсем 0.1. Точнее, математическое 0.1 нельзя точно представить в используемом формате, поэтому JavaScript хранит ближайшее представимое значение.

С другими числами та же история:

(0.2).toFixed(20);// "0.20000000000000001110"(0.3).toFixed(20);// "0.29999999999999998890"

А теперь самое интересное:

(0.1 + 0.2).toFixed(20);// "0.30000000000000004441"

Посмотрите, что происходит по шагам. Оба слагаемых округлились вверх, каждое чуть больше реального значения:

0.1 → 0.100000000000000005550.2 → 0.20000000000000001110

Складываются именно эти значения binary64. Операция идёт по правилам арифметики с плавающей точкой, и результат тоже должен быть представим в binary64:

0.1 0.00011001100110011001100110011001100110011001100110011010.2 0.0011001100110011001100110011001100110011001100110011010---------------------------------------------------------sum 0.0100110011001100110011001100110011001100110011001100111

Чтобы точно записать эту промежуточную сумму, нужно больше значащих битов, чем позволяет binary64. Поэтому результат округляется до ближайшего представимого значения:

0.1 + 0.2 → 0.30000000000000004441

А настоящее 0.3 хранится наоборот, чуть меньше, чем нужно:

0.3 → 0.29999999999999998890
(0.1 + 0.2) === 0.3// false

Вот и вся история. Оба слагаемых чуть великоваты, сумма округлилась ещё выше, и всё это сравнивается с числом, которое само чуть меньше нужного.

Ещё одна оговорка: toFixed(20) тоже не показывает содержимое памяти. Он просто форматирует уже сохранённое значение с 20 знаками после запятой.

Теперь главное. Формат, в котором JavaScript хранит числа, конечен: ровно 64 бита на число. А наша двоичная дробь бесконечна.

Если интересно, как эти 64 бита устроены внутри, подробности есть на MDN: Number encoding.

Откуда вообще взялся этот формат

Справедливый вопрос: кто решил, что числа надо хранить именно так, и почему все с этим согласились?

До IEEE 754 разные компьютеры и производители могли использовать разные форматы и разные правила работы с плавающей точкой. Вот как это описывает IEEE: форматы зависели от производителя, а индустрия раскололась на двоичный, восьмеричный и шестнадцатеричный лагеря. Одна и та же программа давала разные ответы на разных машинах, а арифметические библиотеки приходилось подгонять под каждую модель. Переносимых численных вычислений просто не существовало.

За это взялся математик из Беркли Уильям Кэхэн. Intel пришла к нему за внутренним корпоративным стандартом, а он убедил руководство разрабатывать арифметику с прицелом на будущий международный стандарт. Так появился IEEE 754, принятый в 1985 году. В 1989 году Кэхэн получил за это премию Тьюринга. В формулировке так прямо и сказано: за работу по созданию стандарта IEEE 754.

Несколько практических вещей, которые решают почти всё

Не сравнивайте числа с плавающей точкой через ===.

// это никогда не будет trueif (total === 0.3) {  // сюда мы не попадём}// а так работаетconst almostEqual = (a, b) =>  Math.abs(a - b) <= Number.EPSILON * Math.max(1, Math.abs(a), Math.abs(b));almostEqual(0.1 + 0.2, 0.3);// true

Деньги, если бизнес-логика позволяет, удобно хранить в минимальных единицах, например в копейках. Целые числа до Number.MAX_SAFE_INTEGER представляются и сравниваются безопасно.

const toKopecks = (rub) => Math.round(rub * 100);toKopecks(0.1) + toKopecks(0.2);// 30

Считайте в целых, а форматируйте для пользователя только при выводе.

Форматируйте через Intl.NumberFormat.

new Intl.NumberFormat("ru-RU", {  style: "currency",  currency: "RUB",}).format(0.1 + 0.2);// "0,30 ₽"

И не доверяйте toFixed на границах округления. Это самая коварная ловушка, потому что она выглядит как решение:

(1.005).toFixed(2);// "1.00" ← ожидали 1.01(2.675).toFixed(2);// "2.67" ← ожидали 2.68

Причина та же, что и всегда: 1.005 внутри Number чуть меньше математического 1.005. toFixed() форматирует уже сохранённое значение и округляет по своим правилам. Представление числа он не исправляет.

Так что делать с такими карточками товаров?

Представим, что API уже отдал нам вот это:

Вес: 0.7000000000000001 кг

И мы просто выводим это на страницу.

Фронтенд может поставить перед выводом санитайзер, который ищет подозрительно длинные дробные числа и возвращает их в нормальный вид:

const fmt = new Intl.NumberFormat("en-US", {  maximumSignificantDigits: 12,  useGrouping: false,});function sanitizeText(text) {  return text.replace(/(?<![\d.])\d+\.\d+(?![.\d])/g, (value) => {    const [, fraction] = value.split(".");    if (fraction.length < 12) {      return value;    }    const number = Number(value);    if (!Number.isFinite(number)) {      return value;    }    return fmt.format(number);  });}

Проверяем:

sanitizeText("Вес: 0.7000000000000001 кг");// "Вес: 0.7 кг"sanitizeText("Штемпель с набором для, 0.30000000000000004×2.5 см синий");// "Штемпель с набором для, 0.3×2.5 см синий"sanitizeText("версия 1.2.3 вышла");// "версия 1.2.3 вышла" (не тронуто)

Но это не решение. В лучшем случае экстренная заплатка.

Такой санитайзер нельзя использовать как универсальную обработку чисел. Фронтенд понятия не имеет, что значит вот это:

1.234567890123456789

Это может быть артефакт плавающей точки. А может быть реальное число, где важна каждая цифра.

Поэтому чинить нужно там, где число вычисляется.

Фронтенд может спрятать улики. Но лучше найти виновника.

Изначально я написал этот текст на английском для LinkedIn, для Хабра адаптировал примеры под наши магазины.

ссылка на оригинал статьи https://habr.com/ru/articles/1096910/