Числа: точность и округление

типы≈ 7 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Числа в JavaScript устроены проще, чем кажется: тип всего один, и внутри он всегда дробный. Но за простоту приходится платить — 0.1 + 0.2 здесь не равно 0.3, и это не баг движка, а честная двоичная арифметика.

Разделы темы:·

В JavaScript нет отдельных типов для целых и дробных значений. Есть один number — 64-битное число двойной точности по стандарту IEEE 754. Поэтому 5 и 5.0 — буквально одно и то же значение, а деление никогда не «обрубает» дробную часть, как в C или Java.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Из 64 бит на значащие цифры (мантиссу) отведено 52, остальное — знак и порядок. Мантисса двоичная: число хранится как сумма степеней двойки. Половина, четверть, восьмушка выражаются так точно, а вот одна десятая — нет, ровно как одна треть не записывается конечной десятичной дробью.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Происходит вот что. В памяти лежат не 0.1 и 0.2, а ближайшие к ним двоичные приближения. Складываются приближения, результат снова округляется до ближайшего представимого числа — и промахивается мимо 0.3 на одну «ступеньку» в последнем разряде. Ошибка крошечная, но накапливается при каждой операции.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Это свойство не JavaScript, а формата: то же самое покажут Python, Java, C# и Swift. Калькулятор на телефоне выдаёт ровное 0.3 просто потому, что считает в десятичной системе, а не в двоичной.

При выводе движок печатает самую короткую десятичную запись, которая при обратном чтении даёт ровно то же двоичное число. Для приближения одной десятой такая запись — «0.1», лишние знаки просто не нужны. А у суммы 0.1 + 0.2 двоичное значение другое, и кратчайшая честная запись для него уже длинная.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Чтобы показать число пользователю, его округляют. Метод toFixed(n) оставляет n знаков после точки и возвращает строку — это самый частый источник багов: строки не складываются, а склеиваются.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

«Пятёрку округляем вверх» работает не всегда, и виноват опять формат. Записанное в коде 1.005 на самом деле хранится как чуть меньшее число, поэтому округляется вниз. Предсказать такие случаи на глаз нельзя — их просто нужно допускать как возможные.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Для денег есть приём надёжнее любого округления: не хранить рубли дробью. Считайте в копейках целыми числами — целые до девяти квадриллионов складываются абсолютно точно — и делите на 100 только в момент вывода.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Деньги — в копейках, целыми. Тогда и округлять нечего.

Сравнивать дробные результаты через === бессмысленно. Правильный вопрос — не «равны ли числа», а «различаются ли они меньше, чем на допустимую погрешность». Готовый размер такой погрешности лежит в Number.EPSILON: это наименьший заметный шаг рядом с единицей.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Оговорка важная: Number.EPSILON — абсолютный допуск, посчитанный для чисел около единицы. У больших чисел собственный шаг гораздо крупнее, и проверка снова начнёт врать. Там сравнивают относительную разницу или, что в прикладном коде честнее, просто округляют оба числа до нужного знака.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Number(str) строг: он переводит в число строку целиком и отдаёт NaN, если помешал хоть один символ. parseInt и parseFloat терпимы: читают число с начала строки и останавливаются на первом непонятном месте. Отсюда разница в поведении на «грязных» данных.

СтрокаNumber()parseInt()parseFloat()
"12px"NaN1212
"3.9"3.933.9
" 42 "424242
"px12"NaNNaNNaN
"1e3"100011000
"" (пустая)0NaNNaN
js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Любой промах даёт NaN — «не число». Это единственное значение в языке, не равное самому себе, поэтому проверять его сравнением нельзя — об этом был раздел про NaN в статье о сравнениях. Оттуда же и различие двух проверок, которое здесь понадобится сразу: старая функция isNaN сначала приводит аргумент к числу и потому отвечает «да» на любую нечисловую строку, а Number.isNaN ничего не приводит и говорит правду.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Пятьдесят два бита мантиссы плюс неявная ведущая единица дают 53 значащих разряда, а с ними и предел точности целых: 253 − 1, то есть 9007199254740991. Дальше соседние целые перестают различаться — на них просто не хватает разрядов. Константа называется Number.MAX_SAFE_INTEGER.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Практический вывод: длинные идентификаторы из чужих API — id в соцсетях, номера заказов, ключи из базы — нельзя принимать как number, их держат строками. А если с большими целыми нужно именно считать, в языке есть отдельный тип BigInt: суффикс n у литерала, точность ограничена только памятью.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Смешивать BigInt с обычными числами в арифметике нельзя: 1n + 1 бросит TypeError, сначала нужно привести одно к другому. Как BigInt ведёт себя в арифметике — вплоть до деления, которое отбрасывает дробную часть, — покажет раздел о bigint в статье про примитивы TypeScript в части III. Здесь достаточно знать, что выход за безопасный предел решается не хитрым округлением, а другим типом.

  • В JavaScript один числовой тип — 64-битный double по IEEE 754; отдельных целых нет, поэтому 5 и 5.0 неразличимы.
  • Десятичные дроби вроде 0.1 не имеют точного двоичного представления: 0.1 + 0.2 даёт 0.30000000000000004, и это нормально для любого языка с таким форматом.
  • Для вывода — toFixed (возвращает строку), для сравнения — разница меньше Number.EPSILON, для денег — целые копейки вместо дробных рублей.
  • Number() разбирает строку целиком и даёт NaN при любой помехе, parseInt и parseFloat читают число с начала строки; пустая строка даёт 0 и NaN соответственно.
  • NaN не равен самому себе, проверять его нужно через Number.isNaN; за пределом Number.MAX_SAFE_INTEGER целые перестают различаться, и там начинается территория BigInt.

1/3Что выведет console.log(typeof (5).toFixed(2)); ?

2/3Что выведет console.log(isNaN("42abc"), Number.isNaN("42abc")); ?

3/3Что выведет console.log(Number("") + parseInt("8")); ?

Сумма без хвостов

Цены магазина хранятся в рублях: [0.1, 0.2, 0.3]. Наивное сложение даёт результат, который стыдно показать покупателю.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Напишите функцию total(prices), которая складывает массив цен и возвращает ровное число: для [0.1, 0.2, 0.3] — 0.6, для [19.99, 0.01, 5.5] — 25.5. Округление на выводе не подходит: функция должна вернуть число, а не строку.

Переводим каждую цену в целые копейки, складываем целыми — на этом этапе ошибок не бывает — и делим на 100 один раз в самом конце.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Math.round внутри обязателен: умножение на 100 не всегда даёт целое — 19.99 * 100 равно 1998.9999999999998, а 1.1 * 100 — 110.00000000000001. На обоих наборах из условия ответ выходит ровным и без округления (у одних цен произведение и так целое, у других хвост теряется при следующем сложении), но это везение, а не правило: total([1.1, 2.2]) без Math.round вернёт 3.3000000000000007 вместо 3.3.

Сложить только числа

Из формы пришёл массив строк: ["10", "20px", "abc", "3.5"]. Сложите только те значения, которые целиком являются числами, — должно получиться 13.5. Строка "20px" считается мусором и в сумму не попадает.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Здесь нужен именно строгий Number(): он отсеивает всё, что не является числом целиком. Промах ловим через Number.isNaN.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

parseFloat тут навредил бы: для "20px" он вернёт 20 и молча добавит в сумму лишнее. И обратите внимание на Number.isNaN — обычный isNaN в этой задаче тоже сработал бы, но только потому, что мы уже привели значение к числу сами.