javascript

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

  • среда, 21 октября 2026 г. в 00:00:04
https://habr.com/ru/articles/1096910/

Вес роутера 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, для Хабра адаптировал примеры под наши магазины.