[Перевод] Очень странные дела: JavaScript
Никто из обычных людей не достиг в этом мире ничего значимого.
Джонатан, «Очень странные дела»
Автор материала, перевод которого мы сегодня публикуем, предлагает читателям взглянуть на необычные JavaScript-конструкции. А именно, речь пойдёт о коде, результаты работы которого могут показаться неожиданными. Разбор такого кода, по мнению автора статьи, поможет всем желающим лучше разобраться в JavaScript, в очень странном, но многими любимом языке.
Сценарий №1: ['1', '7', '11'].map (parseInt)
Взглянем на следующий фрагмент кода:
['1', '7', '11'].map(parseInt);
Можно подумать, что результатом его выполнения будет такой массив:
[1, 7, 11]
Но на самом деле всё не так. Вот что он нам выдаст:
[1,NaN,3]
Поначалу такой результат может показаться совершенно непонятным, но всё это вполне объяснимо. А именно, для того чтобы разобраться в том, что тут происходит, нужно как следует вникнуть в особенности работы использованных здесь механизмов языка: метода массива map()
и функции parseInt()
.
▍Метод map ()
Метод map()
вызывает предоставленный ему коллбэк по одному разу для каждого элемента массива, обходя массив в порядке следования его элементов, и создаёт новый массив, содержащий результаты обработки элементов исходного массива. Коллбэк вызывается только для тех индексов массива, которым назначены какие-то значения (включая undefined).
При этом коллбэк, показанный выше, получит некоторые параметры. Изучим это на примере коллбэка, представленного методом console.log()
.
Здесь и далее код и результаты его выполнения представлены так, как они могут выглядеть в консоли инструментов разработчика браузера:
[1, 2, 3].map(console.log)
1 0 > (3) [1, 2, 3]
2 1 > (3) [1, 2, 3]
3 2 > (3) [1, 2, 3]
Как видно, метод map()
передаёт коллбэку не только значение элемента, но и индекс этого элемента, и сам этот массив. Этот факт очень важен, и он, отчасти, влияет на результат выполнения кода, который мы анализируем.
▍Функция parseInt ()
Функция parseInt()
разбирает строковой аргумент и возвращает целое число в заданной системе счисления.
Функция parseInt(string [, radix])
ожидает поступления одного обязательного параметра, строкового представления числа, и одного необязательного — основания системы счисления.
▍Раскрытие тайны
Теперь, когда мы достаточно много знаем об используемых здесь методах и функциях, попытаемся понять сущность происходящего. Начнём с исходного фрагмента кода и пошагово его разберём:
['1', '7', '11'].map(parseInt);
Как нам известно, коллбэк, переданный map()
, получит три аргумента. Поэтому перепишем код так:
['1', '7', '11'].map((currentValue, index, array) => parseInt(currentValue, index, array));
Вы уже начали понимать происходящее? Когда мы добавили в коллбэк аргументы, становится понятным то, что функция parseInt()
получает дополнительные параметры, а не только значение элемента массива. Зная это, мы можем исследовать поведение функции в каждом из случаев. При этом тот параметр, в котором содержится исходный массив, мы можем проигнорировать, так как функция parseInt()
просто не обращает на него внимания. Вот что у нас получится:
parseInt('1', 0)
1
parseInt('7', 1)
NaN
parseInt('11', 2)
3
Эти результаты позволяют объяснить поведение исходного фрагмента кода. Как видно, на результат работы parseInt()
влияет переданное ей основание системы счисления, от которого зависят результаты преобразования строки в число.
Можно ли изменить код так, чтобы получить ожидаемый результат — массив с результатами преобразования строк в числа?
Теперь, когда мы знаем о том, как всё это работает, мы можем, без особых сложностей, исправить код и получить желаемый результат:
['1', '7', '11'].map((currentValue) => parseInt(currentValue));
> (3) [1, 7, 11]
Сценарий №2: ('b'+'a'+ + 'a' + 'a').toLowerCase () === 'banana'
Возможно, вы думаете, что в результате проверки равенства, представленного в заголовке этого раздела, получится false
. В конце концов, в его левой части, там, где мы собираем строку, нет буквы n
. Выясним это:
('b'+'a'+ + 'a' + 'a').toLowerCase() === 'banana'
true
Наверное, вы уже поняли причины происходящего, но, если это не так, предлагаю вместе во всём разобраться. Сосредоточимся на левой части выражения, так как в его правой части, уж поверьте на слово, ничего странного нет.
('b'+'a'+ + 'a' + 'a').toLowerCase()
"banana"
Самое интересное здесь то, как именно формируется слово banana
. Поэтому давайте исследуем код формирования строки, убрав вызов метода toLowerCase()
, ответственный за преобразование строки к нижнему регистру:
('b'+'a'+ + 'a' + 'a')
"baNaNa"
Вот оно! Теперь мы знаем о том, откуда тут взялись буквы N
. Похоже, что в формировании строки приняло участие значение NaN
. Возможно, его источником является выражение + +
. Представим себе, что это так, и попробуем переписать код формирования строки следующим образом:
('b'+'a'+ NaN + 'a' + 'a')
"baNaNaa"
Как видно, тут получается вовсе не baNaNa
, так как в итоговой строке появилась лишняя a
. Попробуем что-нибудь другое:
+ + 'a'
NaN
Похоже, мы наконец во всём разобрались. Комбинация + +
сама по себе ничего не делает, но если добавить после неё символ a
, вся конструкция превращается в NaN
. А это объясняет результат, полученный в исходном фрагменте кода. Значение NaN
, в виде строки, конкатенируется с остальными строковыми значениями и, после приведения полученной строки к нижнему регистру, мы получаем banana
.
Сценарий №3: (я даже названия для него придумать не могу)
Вот код, который я хочу тут разобрать:
(![] + [])[+[]] + (![] + [])[+!+[]] + ([![]] + [][[]])[+!+[] + [+[]]] + (![] + [])[!+[] + !+[]] === 'fail'
true
Что не так в этом мире? Как из кучи скобок получилось слово fail
? И я не погрешу против истины, сказав, что такой JavaScript-код работает без ошибок и выдаёт строку fail
.
Давайте с этим разберёмся. А именно, обратим внимание на одну из конструкций, которая встречается тут несколько раз:
(![] + [])
В результате выполнения этого выражения получается false
. Это странно, но это — демонстрация работы правил, на которых основан JavaScript. Так, оказывается, что истинным является следующее выражение:
false + [] === 'false'
При вычислении этого выражения используются внутренние механизмы языка, о которых мы здесь говорить не будем.
Итак, после того, как нам удалось получить строку false
, всё остальное легко: достаточно найти в полученной строке позиции нужных символов. Исключением является лишь символ i
, которого в строке false
нет.
Для того чтобы раздобыть букву i
, используется такая конструкция, немного отличающаяся от той, которую мы уже рассмотрели:
([![]] + [][[]])
"falseundefined"
Как видите, результатом выполнения этого выражения является строка falseundefined
. Суть тут в том, что мы получаем значение undefined
и конкатенируем строковое представление false
со строковым представлением undefined
. А всё остальное вы уже знаете.
Пока интересно? Давайте взглянем ещё на некоторые странности.
Сценарий №4: значение true и истинные значения, значение false и ложные значения
Что такое «истинные» и «ложные значения? Почему они отличаются от значений true
и false
?
Существуют правила, по которым разные значения в JavaScript приводятся к логическим значениям. Те значения, которые приводятся к значению true
, называются истинными. Те, которые приводятся к false
— ложными. Эти значения используются в операциях, в которых ожидается наличие логических значений, но такие значения этим операциям не предоставляются. Весьма вероятно то, что иногда вы пользуетесь примерно такими конструкциями:
const array = [];
if (array) {
console.log('Truthy!');
}
В вышеприведённом коде константа array
не является значением логического типа, но значение, записанное в неё, является «истинным», поэтому при выполнении этого кода выводится Truthy!
.
▍Истинным или ложным является значение?
Всё, что не является ложным, является истинным. Ужасное объяснение, правда? Но оно достаточно логично. Исследуем его.
Ложными являются значения, приводимые к false
:
- 0
- -0
- 0n
- '' или »
- null
- undefined
- NaN
Все остальные значения являются истинными.
Сценарий №5: сравнение массивов с другими значениями
Кое-что в JavaScript — это просто странно. Но эти странности закреплены в стандартах, поэтому мы принимаем их такими, какие они есть. Рассмотрим несколько примеров сравнения массивов с другими значениями:
[] == '' // -> true
[] == 0 // -> true
[''] == '' // -> true
[0] == 0 // -> true
[0] == '' // -> false
[''] == 0 // -> true
[null] == '' // true
[null] == 0 // true
[undefined] == '' // true
[undefined] == 0 // true
[[]] == 0 // true
[[]] == '' // true
[[[[[[]]]]]] == '' // true
[[[[[[]]]]]] == 0 // true
[[[[[[ null ]]]]]] == 0 // true
[[[[[[ null ]]]]]] == '' // true
[[[[[[ undefined ]]]]]] == 0 // true
[[[[[[ undefined ]]]]]] == '' // true
Если вам интересны причины получения подобных результатов — взгляните на раздел 7.2.14 Abstract Equality Comparison стандарта ECMAScript 2019. Но предупреждаю сразу: обычным людям этого лучше не видеть :-).
Сценарий №6: математика — это математика, если только не…
В обычной жизни мы знаем о том, что математика — это математика. Мы знаем о том, как работают математические операторы. Ещё детьми мы усваиваем, например, правила сложения чисел, и знаем о том, что если сложить одни и те же числа, то всегда получится один и тот же результат. Верно? Но в мире JavaScript это не всегда так. Взглянем на следующие примеры:
3 - 1 // -> 2
3 + 1 // -> 4
'3' - 1 // -> 2
'3' + 1 // -> '31'
'' + '' // -> ''
[] + [] // -> ''
{} + [] // -> 0
[] + {} // -> '[object Object]'
{} + {} // -> '[object Object][object Object]'
'222' - -'111' // -> 333
[4] * [4] // -> 16
[] * [] // -> 0
[4, 4] * [4, 4] // NaN
В первых строках этого фрагмента кода всё выглядит так, как ожидается, до тех пор, пока мы не доберёмся до следующего:
'3' - 1 // -> 2
'3' + 1 // -> '31'
Когда из строки вычитают число, и строка и число ведут себя как числа. А когда к строке число прибавляют, и строка и число ведут себя как строки. Почему? Потому что язык так устроен. Вот простая таблица, которая поможет вам разобраться в том, как поведёт себя язык в каждом из случаев:
Number + Number -> сложение
Boolean + Number -> сложение
Boolean + Boolean -> сложение
Number + String -> конкатенация
String + Boolean -> конкатенация
String + String -> конкатенация
А как насчёт других примеров? Для того чтобы с ними разобраться, нужно учитывать то, что для массивов, []
, и объектов, {}
, перед сложением вызываются методы для преобразования их в примитивные значения. Вот разделы ECMAScript 2019, в которых можно найти подробности о вычислении подобных выражений:
Стоит отметить, что результат вычисления выражения {} + []
отличается от результата вычисления выражения [] + {}
. Причина этого в том, что в первом случае пара фигурных скобок интерпретируется как блок кода. А унарный оператор +
преобразует пустой массив, []
, в число. В результате JavaScript-интерпретатор видит первый пример так:
{
// это - блок кода
}
+[]; // -> 0
Для того чтобы это выражение дало бы тот же результат, что и [] + {}
, его нужно заключить в круглые скобки:
({} + []); // -> [object Object]
Итоги
Надеюсь, вам было так же интересно читать этот материал, как мне — его писать. JavaScript — это замечательный язык, полный неочевидных возможностей и странностей. Хочется верить, что эта статья позволила вам лучше разобраться в некоторых интересных механизмах языка, и вы, когда в следующий раз столкнётесь с чем-то подобным, будете точно знать о том, что происходит.
А вы сталкивались с какими-нибудь странностями JavaScrip