Математики в шоке: в JavaScript происходит такое!

В это невозможно поверить! 0.1 + 0.2 не равно 0.3! Math.max() меньше, чем Math.min()! Заходи и узнай сам, как JavaScript нас обманывает!

Математики в шоке: в JavaScript происходит такое!

Доводилось ли вам сталкиваться с необъяснимым? Если вы работаете с JavaScript – определенно, да. Этот язык полон тайн, загадок и невероятных феноменов, которые шокируют неподготовленных разработчиков. Но сегодня мы раскроем все секреты JavaScript! Так ли он нелогичен, как нам кажется?

Предупреждение: уберите от экранов детей и особо впечатлительных взрослых!

Невероятно, но JavaScript

Надеемся, ваши нервы достаточно крепки, чтобы выдержать это.

Что вы ожидаете получить в результате каждого из этих примеров?

0.1 + 0.2 == 0.3 
9007199254740991 + 1 === 9007199254740991 + 2
Math.sqrt(-1) == Math.sqrt(-1)
Math.max() > Math.min()
['1', '7', '11'].map(parseInt)
[] + {}
{} + []
1 < 2 < 3
3 > 2 > 1

Вероятно, совсем не то, что вы получите на самом деле!

Математики в шоке: в JavaScript происходит такое!

Откройте JavaScript-консоль браузера и проверьте.

Математики в шоке: в JavaScript происходит такое!

Безусловно, вы шокированы. Вы чувствуете себя обманутым. Язык JavaScript растоптал ваши ожидания, он лжет вам в лицо, коверкая простейшие математические операции. Вы вправе негодовать и осуждать его!

Но подождите, может быть, у JavaScript есть причины так поступать? Может быть, узнав об этих причинах, мы сможем понять его и простить? Возможно, этот язык программирования не совсем потерян для общества и достаточно логичен, чтобы иметь с ним дело?

Когда точки умеют плавать

Феномен

0.1 + 0.2 == 0.3 // false

Объяснение

В JavaScript есть всего лишь один тип для представления чисел – Number, напоминающий double из Java. Это числа двойной точности (64 разряда) с плавающей точкой, соответствующие стандарту IEEE 754.

Суть концепции чисел с плавающей точкой заключается в разделении одного числа на два:

  • мантисса, или значимая часть, содержит цифры;
  • экспонента указывает, где в мантиссе необходимо расположить десятичную точку.

В самом старшем разряде числа хранится его знак, еще 11 битов отведено под экспоненту, оставшиеся занимает мантисса.

Реализация таких чисел может быть очень сложной, ведь в ограниченном количестве разрядов необходимо разместить много информации.

Проблемы такого формата широко известны – он плохо справляется с десятичными дробями. Особенно это важно при работе с денежными суммами. Так, язык JavaScript (и не только он) не может точно представить 0.1 и большую часть других дробей, которые в двоичной системе являются бесконечными.

Вот как на самом деле JS видит эти числа:

Математики в шоке: в JavaScript происходит такое!

Математики в шоке: в JavaScript происходит такое!

Чаще всего погрешности очень малы (иногда они даже взаимно поглощаются) и не приносят никаких проблем. Если высокая точность дробей не является критичной для вашего приложения, достаточно просто округлять полученные результаты или пользоваться обходными приемами вроде умножения и последующего деления на 10.

Чтобы числа с плавающей запятой перестали пугать вас, загляните сюда:

Когда умеешь считать только до 9 квадриллионов

Феномен

9007199254740991 + 1 === 9007199254740991 + 2 // true

Объяснение

Разумеется, это огромное число (9007199254740991) не взято с потолка. Это значение константы Number.MAX_SAFE_INTEGER. Иными словами – верхний предел диапазона безопасных вычислений.

Есть, конечно, и нижний предел – Number.MIN_SAFE_INTEGER.

Пока JavaScript разработчик оперирует числами в этом диапазоне, математические законы (сочетательный, распределительный) работают без фокусов. Вне его начинаются чудеса.

Например, порядок сложения одних и тех же чисел может повлиять на результат:

Математики в шоке: в JavaScript происходит такое!

Читайте подробнее о безопасных числах и других интересных математических особенностях JavaScript в нашей статье.

Когда "не-число" – это число

Феномен

Math.sqrt(-1) == Math.sqrt(-1) // false

Объяснение

Результатом выражения Math.sqrt(-1) является NaN (Not a Number) – специальное значение JavaScript. Получается, что одно значение NaN не равно другому.

На самом деле NaN не равно вообще ничему. Так определено в стандарте IEEE 754, с которым мы познакомились чуть раньше. Даже если вы запишете его в переменную и сравните ее с самой собой, получите false. Вот такое специфическое значение.

В этом есть определенный смысл. По своей сути NaN – это результат, который не определен или не имеет смысла. И один такой результат не может быть равен другому.

Математики в шоке: в JavaScript происходит такое!

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

Math.sqrt(-1) // NaN
Math.log(-5) // NaN
parseInt("string") // NaN

NaN здесь не какое-то конкретное значение, а лишь абстрактное представление неудавшейся операции для трех разных выражений.

Не следует при этом забывать, что NaN в JavaScript является числом:

Математики в шоке: в JavaScript происходит такое!

Когда бесконечность – это предел

Феномен

Math.max() > Math.min() // false

Объяснение

Понять это неравенство очень просто – достаточно вывести на консоль значение каждой стороны отдельно:

Математики в шоке: в JavaScript происходит такое!

Сложнее понять, почему метод Math.max без аргументов возвращает минус бесконечность, а Math.min – плюс бесконечность. Впрочем, здесь тоже все довольно логично.

Для Math.min минимальным значением в наборе становится самое большое возможное число, которое меньше или равно каждому члену набора. В непустом множестве такое число ограничено меньшим из его членов. Но если множество пустое, то для этого числа нет никаких ограничений – и возвращается бесконечность. В пустом наборе нет члена больше, чем бесконечность.

Математики в шоке: в JavaScript происходит такое!

Аналогичные рассуждения приводят нас к заключению, что минус бесконечность – вполне логичный результат вызова Math.max без аргументов.

Когда пытаешься помочь

Неявное приведение типов (type coercion) в JavaScript – самое благодатное поле для появления непониманий и неожиданностей. Правил преобразования – много, ситуаций – еще больше, ну как тут разобраться. Язык очень старается облегчить жизнь разработчику, но частенько, наоборот, усложняет ее.

Феномен

[] + {} // '[object Object]'
{} + [] // 0

Объяснение

В первом выражении плюс действует как простой оператор конкатенации. Чтобы сложить пустой массив и пустой объект, он приводит их к строковому представлению. Вы, разумеется, помните, что для этого необходимо вызвать метод toString().

Array.prototype.toString просто объединяет все значения массива в строку. Так как массив пустой, возвращается пустая строка.

Для обычного объекта метод toString() возвращает строку [object Object].

Математики в шоке: в JavaScript происходит такое!

В итоге получается "" + "[object Object]" -> "[object Object]".

Во втором выражении плюс ведет себя совсем по-другому. Фигурные скобки в начале он принимает за отдельно стоящий блок инструкций, пустой блок, поэтому игнорирует его. Остается лишь часть + []. Здесь плюс является простым унарным оператором, который неявно приводит значение к числу. Пустой массив приводится к нулю.

Математики в шоке: в JavaScript происходит такое!

Феномен

1 < 2 < 3 // true
3 > 2 > 1 // false

Объяснение

JavaScript начинает разбирать выражение слева направо. В первом примере сначала обрабатывается сравнение 1 < 2 и возвращается true. Теперь мы имеем true < 3. Чтобы решить эту задачку интерпретатор приводит булево значение (true) к числовому (1). 1 меньше 3, так что на выходе получается true.

Разберемся точно так же со вторым выражением:

  • Выполнение первого неравенства: 3 > 2 -> true.
  • Теперь имеем true > 1.
  • Для выполнения полученного неравенства нужно привести типы – 1 > 1, что, разумеется, ложно.

Феномен

const a = {
  i: 1,
  toString: function () { 
    return a.i++; 
  }
}
a == 1 && a == 2 && a == 3 // true

Математики в шоке: в JavaScript происходит такое!

Объяснение

Практический смысл этой конструкции мало понятен, однако в коде порой встречаются и более страшные монстры. Если JavaScript разработчик не до конца отдавал себе отчет, создавая этот фрагмент, он может столкнуться с необычным поведением. На самом же деле тут нет ничего таинственного.

  • a – это объект;
  • При сравнении объекта с числом интерпретатор JavaScript пытается привести его к примитивному значению и использует для этого метод toString().
  • При каждом вызове переопределенный метод toString() возвращает значение свойства a.i и увеличивает его на единицу.
  • Таким образом, в первом равенстве a.i равно единицы, а во втором уже двойке, так как вызванный метод toString() успел его увеличить.
  • Вуаля! Все три равенства выполняются с истинным результатом.

Когда никто не читает документацию

Феномен

['1', '7', '11'].map(parseInt) // [1, NaN, 3]

Объяснение

Вы, вероятно, ожидали, что функция parseInt, переданная в перебирающий метод массива map, просто превратит строки в числа. Но что-то пошло не так. Уже догадались, что именно?

Смотрите, в таком виде выражение отработает абсолютно правильно:

Математики в шоке: в JavaScript происходит такое!

Проблема в количестве аргументов в сигнатуре каждой из функций.

Метод Array.prototype.map передает в коллбэк для каждого элемента массива не один, а сразу 3 параметра:

  • значение текущего элемента;
  • его индекс;
  • и весь исходный массив целиком.

Таким образом, вот что передается в функцию обратного вызова для каждого элемента массива:

  • '1', 0, ['1', '7', '11']
  • '7', 1, ['1', '7', '11']
  • '11', 2, ['1', '7', '11']

Именно эти аргументы шаг за шагом передаются в функцию parseInt, которая является коллбэком в нашем примере.

Теперь обратимся к документации этой функции и выясним, что она принимает не один параметр (сама строка для парсинга), а два (строка и основание системы счисления в диапазоне от 2 до 36). MDN web docs предупреждает:

Всегда указывайте этот параметр (основание системы), чтобы исключить ошибки считывания и гарантировать корректность исполнения и предсказуемость результата. Когда основание системы счисления не указано, разные реализации могут возвращать разные результаты.

Математики в шоке: в JavaScript происходит такое!

С такой ошибкой мы и столкнулись в этом феномене – в функцию parseInt передавалось неожиданное основание системы счисления для парсинга.

  • Первый элемент. Строка – '1', основание – 0. При неопределенном основании JavaScript предположил, что мы хотим работать с десятичной системой и распарсил строку правильно.
  • Второй элемент. Строка – '7', основание – 1. В системе с таким основанием не может быть цифры 7, возвращается NaN.
  • Третий элемент. Строка – '11', основание – 2. В двоичной системе число 11 – это вовсе не привычные нам 11, а всего лишь 3.

JavaScript: все объяснимо

Язык JavaScript нас вовсе не обманывает. Наоборот, он пытается сделать жизнь разработчика немного легче с помощью неявного приведения типов и скрытия особенностей арифметики чисел с плавающей точкой.

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

Узнайте JavaScript поближе, и загадок станет гораздо меньше :)

Математики в шоке: в JavaScript происходит такое!

Сталкивались с другими "странностями" JavaScript? Расскажите о них.

МЕРОПРИЯТИЯ

Комментарии

ВАКАНСИИ

Добавить вакансию

ЛУЧШИЕ СТАТЬИ ПО ТЕМЕ