Если у JavaScript и есть фирменная фишка, так это коллекция выражений, на которых даже опытный разработчик морщит лоб. [] == ![] — true. typeof null — «object». Math.max() без аргументов — -Infinity. Со стороны выглядит как ошибки реализации, но почти всё это — задокументированное поведение, которое прописано в спецификации языка с конца девяностых.
Чтобы такие выражения не пугали, а наоборот укрепляли понимание языка, ниже разобраны шесть тематических групп типичных «странностей» — с короткими сниппетами и объяснением, что именно делает движок на каждом шаге.
Почему == доверчивее, чем кажется
Оператор нестрогого равенства == старается сравнить любые два операнда — даже разных типов. Если типы не совпали, движок включает приведение типов (type coercion): пытается привести значения к общему виду, обычно к числу, и только потом сравнивает. Отсюда и берутся почти все парадоксы из подборок «wat?!».
2 == [2] // true
[] == ![] // true
null == undefined // true
true == "1" // true
false == "0" // true
Разбираем по шагам. В первом случае массив [2] при сравнении с числом проходит через внутренний алгоритм ToPrimitive: вызывается valueOf(), потом toString(), и получается строка "2". Дальше строка приводится к числу — 2. Сравнение 2 == 2 очевидно истинно.
Во втором случае справа стоит ![]. Оператор ! сначала приводит операнд к булевому значению, и для любого объекта — даже пустого массива — результат true, потому что объекты всегда truthy. Инверсия даёт false. Слева остаётся пустой массив, который при приведении к числу превращается в 0. Сравниваем 0 == false — false приводится к 0, и снова сходится.
Третий случай — единственное исключение в этом разделе, которое прописано в спеке отдельной строкой: null и undefined равны друг другу и больше ничему. Это сделано осознанно, чтобы одной проверкой x == null ловить оба варианта «значения нет».
Последние две строчки разбираются так же, как и первая: булевые значения сначала приводятся к числу (true → 1, false → 0), строки тоже — и сравниваются уже числа. То есть true == "1" по факту превращается в 1 == 1.
Если такая логика кажется слишком хрупкой — стандартный совет: использовать строгое равенство ===, которое сравнивает значения без приведения типов. Об этом ниже.
Тема truthy/falsy ценностей и того, как !! используют для явного приведения к булевому, разобрана в отдельной статье про двойной восклицательный знак.
typeof и его исторические косяки
Оператор typeof возвращает строку с названием типа — вроде бы простой и предсказуемый. Но два значения он возвращает совсем не такие, как ожидаешь:
typeof NaN // "number"
typeof null // "object"
С NaN всё чуть-чуть логично: аббревиатура расшифровывается как Not a Number, но по факту это специальное значение внутри числового типа IEEE 754, тот же тип, что у 3.14 или Infinity. Поэтому typeof честно отвечает "number".
С null история другая — это известный баг первой реализации JavaScript 1995 года, который намеренно не стали чинить ради обратной совместимости. В исходниках движка тип хранится в нижних битах указателя, и для объектов туда писались нули — null совпал по битовому представлению. К ES6 этот вопрос всплывал в комитете, было предложение исправить, но любой код, который проверяет typeof x === "object", тут же бы сломался — и решение оставили как есть.
Практический вывод: для проверок этих двух значений typeof не подходит. Для null — обычное равенство:
if (value === null) { /* ... */ }
Для NaN — Number.isNaN() из ES6, потому что глобальный isNaN() предварительно приводит аргумент к числу и даёт ложноположительные срабатывания на строках:
isNaN("hello") // true — строка превратилась в NaN
Number.isNaN("hello") // false — в аргументе именно строка, не NaN
Number.isNaN(NaN) // true
Ещё один полезный способ сравнить любые значения «как они есть» — метод Object.is(), который правильно различает NaN и пары +0 / -0:
NaN === NaN // false
Object.is(NaN, NaN) // true
Object.is(+0, -0) // false
+0 === -0 // true
Плюс склеивает, минус считает
Оператор + в JavaScript — перегруженный: он одновременно и сложение чисел, и склеивание строк. Если хоть один операнд — строка, движок приводит второй тоже к строке и выполняет конкатенацию. Если строк нет — оба операнда приводятся к числу.
"1" + 1 // "11"
2 + "2" // "22"
"5" - 3 // 2
"10" * "2" // 20
А вот -, *, / и % работают только с числами — у них перегруженной строковой версии нет. Поэтому строки молча приводятся к числу, и выражение "5" - 3 возвращает обычное число 2.
На той же асимметрии построено самое известное JS-выражение из мемов:
"b" + "a" + + "a" + "a" // "baNaNa"
Разбираем слева направо. После "b" + "a" получается "ba". Дальше встречается двойной плюс: первый — это всё ещё конкатенация, а второй — унарный плюс, попытка привести следующее значение к числу. Строка "a" к числу не приводится и возвращает NaN. Получается "ba" + NaN, что снова конкатенация: "baNaN". Последний + "a" добавляет в конец строку — "baNaNa". В консоли это выглядит как трюк, но никакой магии тут нет: только три правила приведения, применённых одно за другим.
Объекты сравниваются по ссылке, а цепочки — слева направо
Следующая пара странностей напоминает: даже визуально похожие выражения движок исполняет шагами, по правилам, и не «угадывает» намерение.
{} == {} // false
[] === [] // false
7 > 6 > 5 // false
Два пустых объекта не равны друг другу, потому что оператор сравнения объектов проверяет идентичность ссылок, а не структурное совпадение. Каждый литерал {} создаёт новый объект в памяти — и адреса у них разные. Поэтому если нужно сравнить объекты «по содержимому», придётся писать это руками: пройтись по ключам через Object.keys() и сравнить попарно, либо использовать утилиту вроде lodash.isEqual. Про разные подходы к работе с объектами — статья про клонирование объектов, там же затрагивается тема ссылок и копирования.
Цепочка сравнений 7 > 6 > 5 в математике читается как «7 больше 6, и 6 больше 5». В JavaScript никакой особой связи между двумя сравнениями нет — они вычисляются последовательно, как обычные бинарные операторы. Сначала 7 > 6 → true. Потом true > 5: булево приводится к числу 1, и сравнение становится 1 > 5 → false. Чтобы написать «a больше b и b больше c», оба условия нужно явно соединить через &&:
7 > 6 && 6 > 5 // true
Сюда же стоит дописать одну из вечных тем для шуток про JavaScript:
0.1 + 0.2 === 0.3 // false
0.1 + 0.2 // 0.30000000000000004
Это, кстати, не баг и не вина JS — так ведут себя все языки, которые используют формат IEEE 754 для чисел с плавающей точкой (а это практически все языки). Часть десятичных дробей в двоичной системе записывается с бесконечным хвостом, поэтому при сложении накапливается крошечная погрешность. В практическом коде это лечится округлением через Number.prototype.toFixed() или сравнением с допуском:
Math.abs(0.1 + 0.2 - 0.3) < Number.EPSILON // true
Странности встроенных API: Math и parseInt
Несколько неожиданных моментов прячутся прямо в библиотечных функциях — не в синтаксисе, а в выбранных стандартом значениях по умолчанию.
Math.max() // -Infinity
Math.min() // Infinity
На первый взгляд должно быть наоборот. Логика следующая: Math.max реализован как накопительная свёртка — берёт стартовое значение и сравнивает с каждым следующим аргументом, оставляя большее. Чтобы любое реальное число гарантированно «победило» на первом шаге, стартовым значением выбран -Infinity. Аргументов нет — и он же возвращается. Для Math.min симметрично: стартовое значение +Infinity.
Практический эффект: вызывая Math.max(...arr) на пустом массиве, в результате получится -Infinity, не ошибка. Если код потом использует это значение в арифметике, баг тихо расползётся дальше. Поэтому пустые массивы стоит проверять заранее.
Похожая ловушка у parseInt: функция определяет систему счисления по префиксу.
parseInt("10") // 10
parseInt("0x10") // 16 — распознан hex-префикс
parseInt("0b10") // 0 — binary-префикс она не понимает
parseInt("08") // 8
Последняя строчка раньше была настоящим pitfall: до ES5 ведущий ноль в строке трактовался как восьмеричный (октальный) префикс — и parseInt("08") в старых движках возвращал 0, потому что цифры 8 в восьмеричной системе не существует. На этом регулярно ловились скрипты, которые брали день месяца или минуты из строкового времени. ES5 (2009) убрал это поведение из parseInt по умолчанию, но привычка осталась: всегда передавайте второй аргумент — систему счисления:
parseInt("08", 10) // 8 — явно десятичная, никаких сюрпризов
В современных проектах для парсинга десятичных чисел чаще берут Number() или унарный + — они не пытаются угадать систему счисления и работают предсказуемо.
Где var и delete ведут себя странно
Последний блок — не про приведение типов, а про область видимости. Тут две классические истории.
delete не удаляет аргументы функции. Оператор delete в JavaScript предназначен для удаления свойств объектов, а не переменных. Параметры функции формально не свойство, к ним delete просто не применяется — в обычном режиме возвращает false и ничего не делает:
(function (param) {
delete param;
return param;
})(42);
// 42 — параметр остался на месте
В строгом режиме ("use strict") та же попытка вообще ловится на этапе разбора и кидает SyntaxError. Подробный разбор строгого режима и того, что в нём ловится сразу — отдельный пост про use strict.
var и замыкания в цикле. Этот пример многих заставлял пересесть на let:
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
// 3, 3, 3
Ожидание — 0, 1, 2. Реальность — три тройки. Причина в том, что var создаёт переменную с областью видимости всей функции, а не блока. Все три коллбэка ссылаются на одну и ту же ячейку памяти. К моменту, когда сработает первый setTimeout, цикл уже завершился, и в ячейке лежит 3. Замените var на let — и получится ожидаемое:
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
// 0, 1, 2
Ключевое слово let объявляет переменную с блочной областью видимости — каждая итерация цикла получает свою независимую ячейку i, и коллбэк замыкает именно её. Подробнее про разницу между var, let и const — в отдельной статье про объявление переменных, а про hoisting и область видимости в целом — в материале про область видимости и поднятие.
Как с этим жить
Большая часть странностей выше — следствие свободного приведения типов и нескольких исторических решений начала девяностых. На практике их легко обойти, держа в голове несколько простых правил.
- Использовать === и !== по умолчанию. Строгое равенство не приводит типы и убирает разом большинство сюрпризов вроде [] == ![]. Стайлгайды Airbnb, Google и StandardJS на этом сходятся; в ESLint правило eqeqeq включает проверку на любое использование ==.
- Для NaN — Number.isNaN(), для одинаковости значений — Object.is(). Не глобальный isNaN() и не ===: ни тот, ни другой не различают нужные крайние случаи.
- Не использовать var. Блочный let и иммутабельный const закрывают все нужные сценарии и не создают сюрпризов с замыканиями.
- Всегда передавать второй аргумент parseInt. Даже если ES5 сделал поведение по умолчанию безопаснее — явный 10 читается лучше и сразу снимает вопросы у того, кто будет читать код через год.
- Подумать про TypeScript на больших проектах. Статическая типизация ловит большую часть таких странностей ещё на этапе компиляции — складывать строку с числом в нём вообще не получится без явного приведения. Если ещё не пробовали — статья про то, почему стоит начать с TS.
Большинство «wat?!»-выражений в JavaScript — не магия и не баги, а закономерные следствия правил, которым язык следует ровно одинаково в любом движке. Зная эти правила, странности перестают быть сюрпризом и превращаются в обычные шаги вычисления.
Комментарии (0)