company_banner

Очень странные дела: JavaScript

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


Автор материала, перевод которого мы сегодня публикуем, предлагает читателям взглянуть на необычные 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 — это замечательный язык, полный неочевидных возможностей и странностей. Хочется верить, что эта статья позволила вам лучше разобраться в некоторых интересных механизмах языка, и вы, когда в следующий раз столкнётесь с чем-то подобным, будете точно знать о том, что происходит.

А вы сталкивались с какими-нибудь странностями JavaScript?



RUVDS.com
RUVDS – хостинг VDS/VPS серверов

Похожие публикации

Комментарии 33

    +2
    Вот оно, следствие слабой динамической типизации и отсутствия этапа компиляции:)
    В этих примерах такие эффекты не более чем забавные задачки на сообразительность, а если что-то подобное возникнет в реальном коде? )
      +10
      Если вы в реальном коде будете так делать — да пребудет с вами разум :)
        +3
        Такое же может произойти и не особо умышленно. Опечатается кто-то где-то. Или скопипастит криво. Мало ли что бывает.
        +4
        Интересно, при каких обстоятельствах в реальном коде нужно будет складывать строчки с объектами и приводить массив к числу? :)

          0
          Опечатки;
          Использование результата функции (особенно если return в нескольких местах — и в одном случайно возвращается не тот тип, что в остальных);
          Ошибки при работе со сложными составными объектами
            +1
            Опечатки и бездумное проектирование функций (и бездумное применение любых фич) может доставить проблемы при использовании совершенно любого языка.
            Те, кто хочет исключить возможность выстрела в ногу — давно используют TS или eslint.

        +9
        Но ведь это уже было в предыдущем посте от ru_vds
        +29

        Да как вы задрали-то! Я читал и думал "пожалуйста не скатитесь в складывание цифр и строк, пожалуйста, пожалуйста".


        Меня уже не столько злят вот эти "особенности", как то, сколько контента они производят и как они затмевают все остальное. Казалось бы, ну напиши ты там о каких-то необычных вещах в модульной системе или там особенности Map/Set.


        Так нет же, надо в 9007199254740991ый раз обсасывать, что там будет если ты сложишь пустой массив с числом и сравнишь полученный результат с левой ногой.

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

          а что-то ценное по JS редко пишут, потому что для этого напрячься нужно. да и не модно. всегда есть «язык отпущения». раньше это была пыха, теперь JS.
            +4
            Не не, php никуда не делся. Он до сих пор умирает, не принижайте его :)
              0
              За необъявленную переменную и многие другие шалости в php 8 будут сразу кидать на костёр, без замечаний и предупреждений, да и складывать яблоки с числами с каждым годом всё сложнее, то ли дело JS, где можно сделать .bind(object) для стрелочной функции и ничего за это не будет ни сейчас, ни через 10 лет.
          0

          «Рискну быть зазвездённым», но
          “””
          ‘1’ mod 0 == 1
          ‘7’ mod 1 == NaN
          “””
          это логично?

            +4
            это на каком языке?
              +3

              Я, конечно, не специалист, но, по-моему, это английский

              +2

              Довольно смело вычислять остаток от деления на ноль.
              Опять же "11" mod 2 может быть равным 3 только в каком-либо параллельном мире.

                +1
                Делить строку на число, делить на ноль, сравнение без учёта типа — и кто Вам после этого враг?
                +6
                Статья устарела года на 2, очень часто повторяется, и публиковали уже не раз. Насчёт Banana — точно. Все практикующие JS-программеры это уже знают. Про преведение типов и прочее.

                Считаю, не надо догадываться. Примеры искусственные, в жизни не понадобятся.

                Код должен быть clearly понятным, и self-documented.
                А эти особенности JS — только для археологов.

                  +2
                  Не на два, а на все двадцать.
                    +1
                    Абсолютно согласен. А с распространением TypeScript сложения массивов с числами вообще пресекаются в зародыше )
                    0
                    Разбор такого кода, по мнению автора статьи, поможет всем желающим лучше разобраться в JavaScript, в очень странном, но многими любимом языке.

                    https://habr.com/ru/company/ruvds/blog/499670/#comment_21560988

                      0
                      Риторический вопрос — а зачем в JS и сложение и конкатенация выполняются через "+". Понятно, что так язык устроен, но почему? Ведь в PHP для сборки строк использовали точку (вида $var = $var.'привет';) — и это как бы снижает возможность ошибок. И не надо думать про корректность переменных, если через точку собираем строку и число = в результате получится строка.
                        +1
                        Понятно, что так язык устроен, но почему?

                        Потому что так было в Java и в C++, и потому что PHP на момент создания JS был хобби-проектом одного гика.
                          +2

                          А как PHP отличает конкатенацию от чтения свойства в таких случаях 'string'.someVar? Это конкатенация с переменной someVar или чтение свойства someVar? Знак $ в начале переменных в этом как-то замешан?

                            +2
                            В PHP для чтения свойств вместо точки применяют ->
                              0
                              В случае 'string'.someVar это однозначно конкатенация.
                              Вообще в PHP обращение к свойству/методу зависит от. В одних случаях используются [] (например associative_array['key']), в других случаяхит от, знак -> (object->method()), в третьих случаях — знак :: (ClassName::method());
                            +11
                            Может хватит уже?
                              +9

                              о, спасибо
                              я больше 5 лет с JS и до сих пор не знал о таком
                              да и никто не знал наверное
                              вы открыли глаза
                              очень полезная статья
                              /sarcasm

                                0
                                {} + {} // -> '[object Object][object Object]'
                                — тут тоже пара фигурных скобок может интерпретироваться как блок кода.
                                Firefox:
                                {} + {} // -> NaN

                                Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                                Самое читаемое