Хитрый вопрос по JavaScript, который задают на собеседованиях в Google и Amazon +9

- такой же как Forbes, только лучше.

Привет Хабр! Есть один вопрос, с виду — не такой уж и сложный, который нередко задают разработчикам на собеседованиях.

Сегодня мы его разберём и поговорим о подходах к поиску ответа. Задавая вопрос, о котором идёт речь, интервьюер предлагает рассказать о том, что выведет примерно такой код:

const arr = [10, 12, 15, 21];
for (var i = 0; i < arr.length; i++) {
  setTimeout(function() {
    console.log('Index: ' + i + ', element: ' + arr[i]);
  }, 3000);
}

А вы знаете, что появится в консоли?

Сразу хочется сказать, что этот вопрос направлен на понимание таких механизмов JS, как замыкания, области видимости и функция setTimeout. Правильный ответ выглядит так:

Index: 4, element: undefined
Index: 4, element: undefined
Index: 4, element: undefined
Index: 4, element: undefined

Если вы ожидали чего-то другого, надеемся, в этом материале мы сможем рассказать о том, почему вывод этого фрагмента кода оказался именно таким, и о том, как привести его в более приличный вид.

Почему этот вопрос так популярен?


Один пользователь Reddit рассказал о том, что ему задавали такой вопрос на собеседовании в Amazon. Я и сам сталкивался с подобными вопросами, направленными на понимание циклов и замыканий в JS, даже на собеседовании в Google.

Этот вопрос позволяет проверить владение некоторыми важными концепциями JavaScript. Учитывая особенности работы JS, ситуация, которая смоделирована в представленном фрагменте кода, нередко может возникать и в ходе реальной работы. В частности, это касается использования setTimeout или какой-нибудь другой асинхронной функции в цикле.

Хорошее понимание функциональных и блочных областей видимости в JavaScript, особенностей устройства анонимных функций, замыканий и IIFE, поможет вашему профессиональному росту и позволит показать себя с хорошей стороны на собеседованиях.

Подходы к ответу на вопрос и к избавлению от undefined


На самом деле, я уже писал о возможных подходах к ответу на этот вопрос в некоторых моих предыдущих материалах. В частности, в этом и этом. Позволю себе процитировать кое-что из этих публикаций:
Причина подобного заключается в том, что функция setTimeout создаёт функцию (замыкание), у которой есть доступ к внешней по отношению к ней области видимости, представленной в данном случае циклом, в котором объявляется и используется переменная i. После того, как пройдут 3 секунды, функция выполняется и выводит значение i, которое, после окончания работы цикла, остаётся доступным и равняется 4-м. Переменная, в ходе работы цикла, последовательно принимает значения 0, 1, 2, 3, 4, причём, последнее значение оказывается сохранённым в ней и после выхода из цикла. В массиве имеется четыре элемента, с индексами от 0 до 3, поэтому, попытавшись обратиться к arr[4], мы и получаем undefined. Как избавиться от undefined и сделать так, чтобы код выводил то, чего от него и ждут, то есть — значения элементов массива?

Вот пара распространённых подходов к решению подобной задачи, а конкретно — к тому, чтобы организовать доступ к нужному значению переменной цикла внутри функции, вызываемой setTimeout.

Первый предусматривает передачу необходимого параметра во внутреннюю функцию, второй основан на использовании возможностей ES6.

Итак, вот первый вариант:

const arr = [10, 12, 15, 21];
for (var i = 0; i < arr.length; i++) {
  // передадим функции переменную i, в результате
  // у каждой функции будет доступ к правильному значению индекса
  setTimeout(function(i_local) {
    return function() {
      console.log('The index of this number is: ' + i_local);
    }
  }(i), 3000);
}

Вот второй вариант:

const arr = [10, 12, 15, 21];
for (let i = 0; i < arr.length; i++) {
  // использование ключевого слова let, которое появилось в ES6,
  // позволяет создавать новую привязку при каждом вызове функции
  // подробности смотрите здесь: http://exploringjs.com/es6/ch_variables.html#sec_let-const-loop-heads
  setTimeout(function() {
    console.log('The index of this number is: ' + i);
  }, 3000);
}

На Reddit мне удалось найти похожий ответ на этот вопрос. Вот — хорошее разъяснение особенностей замыканий на StackOverflow.

Итоги


Можно отметить, что вопрос, с которого мы начали этот материал, часто сбивает с толку людей, обладающих небольшим опытом в области JavaScript или в функциональном программировании. Причина заключается в непонимании сущности замыканий. При формировании замыкания не выполняет передача значения переменной или ссылки на неё. Замыкание захватывает саму переменную.

Уважаемые читатели! Знаете ли вы интересные вопросы, которые задают на собеседованиях по JavaScript? Если да — просим поделиться.

Вы можете помочь и перевести немного средств на развитие сайта



Комментарии (45):

  1. mayorovp
    /#10475326 / +1

    Третий вариант:


    const arr = [10, 12, 15, 21];
    arr.forEach(function (item, i) {
        setTimeout(function () {
            console.log('Index: ' + i + ', element: ' + item);
        });
    });

    PS если уж и оставляли ссылку на SO — можно было бы и на русскоязычное объяснение сослаться. Например, на вот это: https://ru.stackoverflow.com/a/433888/178779

    • timfcsm
      /#10475350 / -2

      ещё покороче

      setTimeout(function(item, i) {
          console.log('Index: ' + i + ', element: ' + item);
        }.bind(this, arr[i], i), 3000);
      

      • mayorovp
        /#10475392

        Вы цикл забыли, вот у вас и вышло "покороче". И, раз уж вы решили так делать — проще пойти через дополнительные параметры setTimeout:


        const arr = [10, 12, 15, 21];
        for (var i = 0; i < arr.length; i++) {
          setTimeout(function(i) {
            console.log('Index: ' + i + ', element: ' + arr[i]);
          }, 3000, i);
        }

        • timfcsm
          /#10475394

          я его не забыл, а просто не стал писать, для наглядности — где я что поменял

          • timfcsm
            /#10475412

            извиняюсь, что-то я затупил и не увидел что у вас forEach, а не просто две обертки в цикле)

    • iShatokhin
      /#10478232

      Более современный вариант:


      const arr = [10, 12, 15, 21];
      for (const [i, item] of arr.entries()) {
          setTimeout(function () {
              console.log(`Index: ${i}, element: ${item}`);
          });
      }

      • mayorovp
        /#10478234

        Если уж использовать for-of и деструктуризацию, то и стрелочные функции тоже использовать можно :-)

        • iShatokhin
          /#10478244

          На самом деле, даже стрелочные не нужны.


          const arr = [10, 12, 15, 21];
          for (const [i, item] of arr.entries()) {
              setTimeout(console.log, 0, `Index: ${i}, element: ${item}`);
          }

  2. n0wheremany
    /#10475378

    Доки:

    var timerId = setTimeout(func / code, delay[, arg1, arg2...])


    Правда не сработает на <IE9

    • n0wheremany
      /#10475398 / -1

      И касаемо 1 варианта — почему автор не сделал так? Есть какие то ограничения?

      const arr = [10, 12, 15, 21];
      for (var i = 0; i < arr.length; i++) {
      (function(i){
        setTimeout(function() {
          console.log('Index: ' + i + ', element: ' + arr[i]);
        }, 3000);
      })(i)
      }

      • mayorovp
        /#10475404

        Это же то же самое, вид сбоку.

        • Cryvage
          /#10475436

          Нет, это совсем не то же самое. Тут в коллюэке setTimeout будет замыкаться не «i», объявленный в цикле for, а «i», являющийся параметром функции-обёртки. В итоге выведутся индексы от 0 до 3 и соответствующие им элементы.

          • mayorovp
            /#10475458

            И касаемо 1 варианта — почему автор не сделал так? Есть какие то ограничения?

            Напомню, первый вариант — это то где setTimeout(function(i_local) { ... }(i), 3000)

            • Cryvage
              /#10476018

              А, ну тогда понятно о чём речь. Я почему-то подумал что первый вариант это тот который до ката. Каюсь, был невнимателен.

              • D01
                /#10479312

                Просто тут гланды через _опу (плохо читается, поэтому тут только часть невнимательности))

      • Cryvage
        /#10475426

        Да потому что пример специально написан с ошибкой, чтобы выяснить, понимает ли человек, как работают замыкания, или нет.

        • n0wheremany
          /#10475448

          Вопрос то мой в другом — зачем в результате выполнения функция, а не выполнение функции в результате :)

  3. master65
    /#10475432

    Можно просто удалить SetTimeout и все будет работать

  4. stardust_kid
    /#10475440

    Этому вопросу уже лет 15 как минимум. Авторам блога можно было и посвежее найти.
    И про замыкания не объяснили как следует.

    • Sirion
      /#10476120

      «Никогда не было, и вот опять» (с)

    • justhabrauser
      /#10476348

      Если авторам блога вчера исполнилось 14 или менее — то они могли что-то пропустить.

    • ermolaevalexey
      /#10476834 / -1

      Вы таки не поверите, но до сих пор многие «сеньеры-помидоры», разглагольствующие про graphql, на этом вопросе сыпятся

      • stardust_kid
        /#10478592

        Мне вот такие собеседования напоминают рассказ Шукшина "Срезал".

  5. igormich88
    /#10475506

    В Java подобный код вообще не скомпилируется — потребует явно копировать в локальную переменную, по моему это правильно.

  6. serf
    /#10475598

    Вопрос был бы чуть более хитрым если бы в setTimeout таймаут было не 3000, а 0 (значение по умолчанию). Хитрость ведь не только в области видимости и замыканиях, а еще в понимании того что JS однопоточный и event loop блокировать очень нежелательно.

    • rualekseev
      /#10475824

      Я совсем не программирую на js, но мои познания в других языках позволили мне правильно ответить про замыкание (все вопросы про замыкания, сводятся к подобной формулировке).
      А вот ваше уточнение про таймаут 0 и однопоточность не очень понятно, что мы получим?

      • SuperPaintman
        /#10476252

        Да тоже самое получаем, timeout работает по принципу "когда нибудь, но только не сейчас", т.е. если вы даже напишите -500, он сработает не раньше чем через один тик (зависит от браузера, но если не изменяет память минимальный таймаут 5-10мс).

    • Aquahawk
      /#10475856

      так ничего же с 0 не изменится. А перенос времени вычисления вычислительно сложной задачи в рамках этого потока ничего не даст, всё равно луп залочится. Можно порезать задачку на куски и через performance.now отъедать не больше например 10 ms на итерацию, но это изврат. А вообще воркеры же есть. Но на самом деле не все задачи подходят.

      • serf
        /#10475990

        Да ничего не измениться по сути, просто не все понимают как происходит планировка подобных «отложенных» задач в JS, а там тоже есть что обсудить. И вот как раз воркеры были бы к месту в обсуждении.

  7. Aquahawk
    /#10475656

    а мне вот такое решение кажется интересным.

    const arr = [10, 12, 15, 21];
    for (let i = 0; i < arr.length; i++) {
      setTimeout(function() {
        console.log('Index: ' + i + ', element: ' + arr[i]);
      }, 3000);
    }
    

    Вообще это не хитрый а самый что ни есть базовый вопрос по js на любую позицию кроме может совсем зелёного джуна. Если человек утверждает что знает JS и работает на нём профессионально, то незнание этих вещей означает полную профнепригодность в принципе. Скоупы, что их создаёт и как работает замыкание являются базовыми знаниями для языка который на этом построен чуть менее чем полностью.

    • vanxant
      /#10478036

      Зашел написать этот же комментарий.
      Еще можно спрашивать, чему равно 2+3*4, уровень сложности примерно такой же.
      Только при чем тут гугль?

  8. ameli_anna_kate
    /#10475696

    Что-то я разочарована, вполне рядовой вопрос на собесах в московских компаниях в течение уже нескольких лет. Сталкивалась и с такой формулировкой: «Как можно исправить данный пример? Напишите все способы, какие знаете»

    К тому же кандидат мог почитать статьи о часто задаваемых вопросах на собеседованиях и тупо выучить как правильно ответить, все же не мешало бы просто отдельно спросить стандартные вопросы:
    «Какие типы функций вы знаете и какие особенности у каждого?
    Что такое замыкания и область видимости переменной?
    Что такое setTimeout/setInterval, чем отличаются?»
    … и тд.

  9. haoNoQ
    /#10476106

    Хмм. Следует ли из вышесказанного что в циклах, в которых мы не хотим создавать такие замыкания, var i будет работать чуть быстрее, чем let i, ведь интерпретатору не надо создавать новую переменную i на каждой итерации?


    Производительность JS это, конечно, мутно, но я не настоящий сварщик.

    • kahi4
      /#10476200 / +1

      Возьмем код


      const arr = [10, 12, 15, 21];
      for (let i = 0; i < arr.length; i++) {
        setTimeout(function() {
          console.log('The index of this number is: ' + i);
        }, 3000);
      }
      
      for (let i = 0; i < arr.length; i++) {
        console.log(i);
      }

      Вставим сюда и будет результат:


      'use strict';
      
      var arr = [10, 12, 15, 21];
      
      var _loop = function _loop(i) {
        setTimeout(function () {
          console.log('The index of this number is: ' + i);
        }, 3000);
      };
      
      for (var i = 0; i < arr.length; i++) {
        _loop(i);
      }
      
      for (var i = 0; i < arr.length; i++) {
        console.log(i);
      }

      Видно, что когда лишние телодвижения не нужны, babel транслирует в код 1-в-1. В случае если нужно делать замыкание на внутреннюю переменную (как в задаче в примере) — ее создание неизбежно, поэтому как не крутите, память будет выделена. Думаю, движок хрома делает еще более оптимальный байткод, так что на производительность влиять не будет.

  10. kahi4
    /#10476190 / +1

    Хитрый вопрос? Хитрее только "чему равен typeof null".
    Вот вам еще хитрый вопрос для написания статьи на знание основ js:


    function foo() {
        'use strict';
         console.log(bar());
         function bar() { return 'bar'};
    }

    Будет undefined, reference error или 'bar'?


    Как вариант решения задачки из топика — даешь больше es6:


    const arr = [10, 12, 15, 21];
    arr.forEach((item, i) => setTimeout(function() {
        console.log('Index: ' + i + ', element: ' + item);
      }, 3000));

    А вообще


    Rx.Observable.from([10, 12, 15, 21]).delay(3000).do(console.log);

    • SagePtr
      /#10476798 / -2

      const arr = [10, 12, 15, 21];
      arr.forEach((item, i) => setTimeout(_ => console.log('Index: ' + i + ', element: ' + item), 3000));
      

  11. oleg_gf
    /#10476310

    Я только изучаю JavaScript, ещё не дошёл до асинхронности, но уже подзабыл синтаксис for'а.
    Вот такой код нормальный результат выдаёт:

    const arr = [10, 12, 15, 21];
    const iter = (i) => {
      if (i >= arr.length) {return ;}
      setTimeout(function() {
        console.log('Index: ' + i + ', element: ' + arr[i]);
      }, 3000);
      return iter(i + 1);
    };
    iter(0);

  12. Antelle
    /#10476544

    omg, «хитрый» вопрос из google и amazon… Не задают его нигде уже, то есть, задают, но в каком-нибудь первом тесте для отсева неадеквата.

  13. kuraga333
    /#10476926 / -1

    (к первому листингу и пояснению к нему)
    Во-первых, почему последнее значение i — 4, а не 3?
    Во-вторых, де-факто в консоли выводится иное…

    • mayorovp
      /#10476938

      В консоли выводится то же самое, просто некоторые консоли умеют отслеживать дублирующиеся сообщения и оставлять только одно.


      А 4 выводится потому что после окончания цикла переменная i принимает именно это значение. На значении 3 цикл закончиться не может, потому что 3 < arr.length. Цикл заканчивается когда нарушается его условие — а оно нарушается когда i >= arr.length.

      • kuraga333
        /#10476950

        Да, сорри, какой-то не тот код выполнил. Своими глазами видел элементы массива в выводе.
        Сам удивился. Спросоня. Эх, жаль карму…

        • kuraga333
          /#10476956

          Там var на let заменили, в комментарии выше. Не заметил :-) Ну про 4 тупанул, еще раз сорри :-) Чувствую себя первоклашкой :-(

  14. igrishaev
    /#10477198

    У меня на собеседовании по Питону похожее спрашивали. В цикле создается список лямбд, каждая из которых ссылается на счетчик. Функции выполнятся уже когда счетчик будет максимальным, из-за лексической видимости будет напечатано последнее значение.

  15. roman_gemini
    /#10477490

    Был уверен что напечатается 4 раза последний элемент массива. Но после того как увидел правильный ответ, первая мысль — точно, это же for! Не знаю теперь кто я с точки зрения Amazon или Microsoft… for ведь такой же как в большинстве Си-подобных языков. Это не знание основ javascript или же не знание основ Си?)