[Перевод] Очень странные дела: JavaScript

Никто из обычных людей не достиг в этом мире ничего значимого.
Джонатан, «Очень странные дела»

Автор материала, перевод которого мы сегодня публикуем, предлагает читателям взглянуть на необычные JavaScript-конструкции. А именно, речь пойдёт о коде, результаты работы которого могут показаться неожиданными. Разбор такого кода, по мнению автора статьи, поможет всем желающим лучше разобраться в JavaScript, в очень странном, но многими любимом языке. 

femrjv2lfusxsocumlb8o_np7ni.png

Сценарий №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

© Habrahabr.ru