Математики в шоке: в JavaScript происходит такое!
В это невозможно поверить! 0.1 + 0.2 не равно 0.3! Math.max() меньше, чем Math.min()! Заходи и узнай сам, как 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 есть причины так поступать? Может быть, узнав об этих причинах, мы сможем понять его и простить? Возможно, этот язык программирования не совсем потерян для общества и достаточно логичен, чтобы иметь с ним дело?
Когда точки умеют плавать
Феномен
0.1 + 0.2 == 0.3 // false
Объяснение
В JavaScript есть всего лишь один тип для представления чисел – Number, напоминающий double из Java. Это числа двойной точности (64 разряда) с плавающей точкой, соответствующие стандарту IEEE 754.
Суть концепции чисел с плавающей точкой заключается в разделении одного числа на два:
- мантисса, или значимая часть, содержит цифры;
- экспонента указывает, где в мантиссе необходимо расположить десятичную точку.
В самом старшем разряде числа хранится его знак, еще 11 битов отведено под экспоненту, оставшиеся занимает мантисса.
Реализация таких чисел может быть очень сложной, ведь в ограниченном количестве разрядов необходимо разместить много информации.
Проблемы такого формата широко известны – он плохо справляется с десятичными дробями. Особенно это важно при работе с денежными суммами. Так, язык JavaScript (и не только он) не может точно представить 0.1 и большую часть других дробей, которые в двоичной системе являются бесконечными.
Вот как на самом деле JS видит эти числа:
Чаще всего погрешности очень малы (иногда они даже взаимно поглощаются) и не приносят никаких проблем. Если высокая точность дробей не является критичной для вашего приложения, достаточно просто округлять полученные результаты или пользоваться обходными приемами вроде умножения и последующего деления на 10.
Чтобы числа с плавающей запятой перестали пугать вас, загляните сюда:
- What Every Computer Scientist Should Know About Floating-Point Arithmetic
- Арифметические операции над числами с плавающей запятой
Когда умеешь считать только до 9 квадриллионов
Феномен
9007199254740991 + 1 === 9007199254740991 + 2 // true
Объяснение
Разумеется, это огромное число (9007199254740991) не взято с потолка. Это значение константы Number.MAX_SAFE_INTEGER. Иными словами – верхний предел диапазона безопасных вычислений.
Есть, конечно, и нижний предел – Number.MIN_SAFE_INTEGER.
Пока JavaScript разработчик оперирует числами в этом диапазоне, математические законы (сочетательный, распределительный) работают без фокусов. Вне его начинаются чудеса.
Например, порядок сложения одних и тех же чисел может повлиять на результат:
Читайте подробнее о безопасных числах и других интересных математических особенностях JavaScript в нашей статье.
Когда "не-число" – это число
Феномен
Math.sqrt(-1) == Math.sqrt(-1) // false
Объяснение
Результатом выражения Math.sqrt(-1) является NaN (Not a Number) – специальное значение JavaScript. Получается, что одно значение NaN не равно другому.
На самом деле NaN не равно вообще ничему. Так определено в стандарте IEEE 754, с которым мы познакомились чуть раньше. Даже если вы запишете его в переменную и сравните ее с самой собой, получите false. Вот такое специфическое значение.
В этом есть определенный смысл. По своей сути NaN – это результат, который не определен или не имеет смысла. И один такой результат не может быть равен другому.
Например, квадратный корень из отрицательного числа не определен, так же как и его логарифм, и попытка преобразования строки в число.
Math.sqrt(-1) // NaN Math.log(-5) // NaN parseInt("string") // NaN
NaN здесь не какое-то конкретное значение, а лишь абстрактное представление неудавшейся операции для трех разных выражений.
Не следует при этом забывать, что NaN в JavaScript является числом:
Когда бесконечность – это предел
Феномен
Math.max() > Math.min() // false
Объяснение
Понять это неравенство очень просто – достаточно вывести на консоль значение каждой стороны отдельно:
Сложнее понять, почему метод Math.max без аргументов возвращает минус бесконечность, а Math.min – плюс бесконечность. Впрочем, здесь тоже все довольно логично.
Для Math.min минимальным значением в наборе становится самое большое возможное число, которое меньше или равно каждому члену набора. В непустом множестве такое число ограничено меньшим из его членов. Но если множество пустое, то для этого числа нет никаких ограничений – и возвращается бесконечность. В пустом наборе нет члена больше, чем бесконечность.
Аналогичные рассуждения приводят нас к заключению, что минус бесконечность – вполне логичный результат вызова Math.max без аргументов.
Когда пытаешься помочь
Неявное приведение типов (type coercion) в JavaScript – самое благодатное поле для появления непониманий и неожиданностей. Правил преобразования – много, ситуаций – еще больше, ну как тут разобраться. Язык очень старается облегчить жизнь разработчику, но частенько, наоборот, усложняет ее.
Феномен
[] + {} // '[object Object]' {} + [] // 0
Объяснение
В первом выражении плюс действует как простой оператор конкатенации. Чтобы сложить пустой массив и пустой объект, он приводит их к строковому представлению. Вы, разумеется, помните, что для этого необходимо вызвать метод toString().
Array.prototype.toString просто объединяет все значения массива в строку. Так как массив пустой, возвращается пустая строка.
Для обычного объекта метод toString() возвращает строку [object Object]
.
В итоге получается "" + "[object Object]" -> "[object Object]"
.
Во втором выражении плюс ведет себя совсем по-другому. Фигурные скобки в начале он принимает за отдельно стоящий блок инструкций, пустой блок, поэтому игнорирует его. Остается лишь часть + []
. Здесь плюс является простым унарным оператором, который неявно приводит значение к числу. Пустой массив приводится к нулю.
Феномен
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 разработчик не до конца отдавал себе отчет, создавая этот фрагмент, он может столкнуться с необычным поведением. На самом же деле тут нет ничего таинственного.
- a – это объект;
- При сравнении объекта с числом интерпретатор JavaScript пытается привести его к примитивному значению и использует для этого метод toString().
- При каждом вызове переопределенный метод toString() возвращает значение свойства a.i и увеличивает его на единицу.
- Таким образом, в первом равенстве a.i равно единицы, а во втором уже двойке, так как вызванный метод toString() успел его увеличить.
- Вуаля! Все три равенства выполняются с истинным результатом.
Когда никто не читает документацию
Феномен
['1', '7', '11'].map(parseInt) // [1, NaN, 3]
Объяснение
Вы, вероятно, ожидали, что функция parseInt, переданная в перебирающий метод массива map, просто превратит строки в числа. Но что-то пошло не так. Уже догадались, что именно?
Смотрите, в таком виде выражение отработает абсолютно правильно:
Проблема в количестве аргументов в сигнатуре каждой из функций.
Метод 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 предупреждает:
Всегда указывайте этот параметр (основание системы), чтобы исключить ошибки считывания и гарантировать корректность исполнения и предсказуемость результата. Когда основание системы счисления не указано, разные реализации могут возвращать разные результаты.
С такой ошибкой мы и столкнулись в этом феномене – в функцию parseInt передавалось неожиданное основание системы счисления для парсинга.
- Первый элемент. Строка – '1', основание – 0. При неопределенном основании JavaScript предположил, что мы хотим работать с десятичной системой и распарсил строку правильно.
- Второй элемент. Строка – '7', основание – 1. В системе с таким основанием не может быть цифры 7, возвращается NaN.
- Третий элемент. Строка – '11', основание – 2. В двоичной системе число 11 – это вовсе не привычные нам 11, а всего лишь 3.
JavaScript: все объяснимо
Язык JavaScript нас вовсе не обманывает. Наоборот, он пытается сделать жизнь разработчика немного легче с помощью неявного приведения типов и скрытия особенностей арифметики чисел с плавающей точкой.
Но иногда разработчики сами мешают языку, создавая странный код, пользуясь плохими практиками, забывая о некоторых компромиссах или отказываясь читать документацию.
Узнайте JavaScript поближе, и загадок станет гораздо меньше :)