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

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

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
    16.10.2017 14:38
    +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


    1. timfcsm
      16.10.2017 14:45
      -2

      ещё покороче

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


      1. mayorovp
        16.10.2017 14:58

        Вы цикл забыли, вот у вас и вышло "покороче". И, раз уж вы решили так делать — проще пойти через дополнительные параметры 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);
        }


        1. timfcsm
          16.10.2017 15:00

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


          1. timfcsm
            16.10.2017 15:05

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


    1. iShatokhin
      17.10.2017 20:27

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


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


      1. mayorovp
        17.10.2017 20:29

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


        1. iShatokhin
          17.10.2017 20:32

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


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


  1. n0wheremany
    16.10.2017 14:54

    Доки:

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


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


    1. n0wheremany
      16.10.2017 15:00
      -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)
      }


      1. mayorovp
        16.10.2017 15:03

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


        1. Cryvage
          16.10.2017 15:18

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


          1. mayorovp
            16.10.2017 15:28

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

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


            1. Cryvage
              16.10.2017 18:12

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


              1. D01
                18.10.2017 13:43

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


      1. Cryvage
        16.10.2017 15:12

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


        1. n0wheremany
          16.10.2017 15:24

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


  1. master65
    16.10.2017 15:15

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


    1. prishelec
      17.10.2017 01:44
      +1

      Вы приняты.


  1. stardust_kid
    16.10.2017 15:18

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


    1. Sirion
      16.10.2017 18:48

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


    1. justhabrauser
      16.10.2017 21:13

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


    1. ermolaevalexey
      17.10.2017 08:58
      -1

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


      1. stardust_kid
        18.10.2017 02:33

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


  1. igormich88
    16.10.2017 15:40

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


  1. serf
    16.10.2017 16:06

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


    1. rualekseev
      16.10.2017 17:11

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


      1. SuperPaintman
        16.10.2017 20:08

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


    1. Aquahawk
      16.10.2017 17:22

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


      1. serf
        16.10.2017 18:07

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


  1. Aquahawk
    16.10.2017 16:25

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

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


    1. vanxant
      17.10.2017 18:47

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


  1. ameli_anna_kate
    16.10.2017 16:38

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

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


  1. haoNoQ
    16.10.2017 18:44

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


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


    1. kahi4
      16.10.2017 19:29
      +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. В случае если нужно делать замыкание на внутреннюю переменную (как в задаче в примере) — ее создание неизбежно, поэтому как не крутите, память будет выделена. Думаю, движок хрома делает еще более оптимальный байткод, так что на производительность влиять не будет.


  1. kahi4
    16.10.2017 19:23
    +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);


    1. SagePtr
      17.10.2017 08:31
      -2

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


  1. oleg_gf
    16.10.2017 20:55

    Я только изучаю 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);


  1. Antelle
    16.10.2017 23:14

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


  1. kuraga333
    17.10.2017 09:58
    -1

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


    1. mayorovp
      17.10.2017 10:06

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


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


      1. kuraga333
        17.10.2017 10:14

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


        1. kuraga333
          17.10.2017 10:19

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


  1. igrishaev
    17.10.2017 11:53

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


  1. roman_gemini
    17.10.2017 13:59

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