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

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

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

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

0.1 + 0.2;

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

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

0.30000000000000004
0.7000000000000001

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

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

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

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

1.1 * 3
// 3.3000000000000003

0.57 * 100
// 56.99999999999999

4.35 * 100
// 434.99999999999994

0.1 + 0.7
// 0.7999999999999999

Но почему?

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

10 / 3 = 3.333333333333...

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

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

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

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

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

0
1

Например:

0 → 0
1 → 1
2 → 10
3 → 11
4 → 100
5 → 101
6 → 110
7 → 111
8 → 1000

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

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

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

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

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

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

0.25₁₀ = 0.01₂

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

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

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

0.1 × 2 = 0.2 → 0
0.2 × 2 = 0.4 → 0
0.4 × 2 = 0.8 → 0
0.8 × 2 = 1.6 → 1
0.6 × 2 = 1.2 → 1
0.2 × 2 = 0.4 → 0
0.4 × 2 = 0.8 → 0
0.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.10000000000000000555
0.2 → 0.20000000000000001110

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

0.1 0.0001100110011001100110011001100110011001100110011001101
0.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.

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

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

// это никогда не будет true
if (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/


Интересные статьи

Интересные статьи

Привет! Снова подготовили для вас материал от Романа Стрельникова, руководителя направления по информационной безопасности 1С-Битрикс. Сегодня поговорим про offensive security. Вокруг темы много споро...
В любой организации есть свои бизнес-процессы: взаимодействие с заказчиком, отгрузка товара другие операции — все это требует выполнения определенных действий. Конечно, эти операции могут выполня...
В данной статье рассматриваются способы интеграции Large Language Models (LLM) в корпоративные системы хранения данных. Основное внимание уделено использованию LLM для автоматического извлечения инфор...
Хорошая новость для тех, кто интересуется развитием продуктов «Газинформсервис». На сайте компании появился отдельная вкладка «Новости по продуктам». В этом разделе можно найти самые актуальные новост...
ВведениеВ данной статье я бы хотел рассмотреть проблему обновления PHP в виртуальной машине BitrixVM, и действия, которые возможно применить если выполнение переезда на машину с обновленным ПО невозмо...