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

https://medium.com/coderbyte/a-tricky-javascript-interview-question-asked-by-google-and-amazon-48d212890703
  • Перевод
Привет Хабр! Есть один вопрос, с виду — не такой уж и сложный, который нередко задают разработчикам на собеседованиях.

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

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? Если да — просим поделиться.
RUVDS.com 474,43
RUVDS – хостинг VDS/VPS серверов
Поделиться публикацией
Комментарии 51
  • 0

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


    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

    • –6
      ещё покороче
      setTimeout(function(item, i) {
          console.log('Index: ' + i + ', element: ' + item);
        }.bind(this, arr[i], i), 3000);
      
      • +3

        Вы цикл забыли, вот у вас и вышло "покороче". И, раз уж вы решили так делать — проще пойти через дополнительные параметры 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
          я его не забыл, а просто не стал писать, для наглядности — где я что поменял
          • +2
            извиняюсь, что-то я затупил и не увидел что у вас forEach, а не просто две обертки в цикле)
          • –1
            Из задания: в цикле for вызывается функция setTimeout, которой передаётся анонимная функция и значение задержки таймера. Когда заканчивает выполнятся функция setTimeout, анонимная функция остаётся жить и её можно запускать в другом месте кода (?) (т.е. замыкает на себя переменные всех внешних функций, но выполняется последней, — к тому моменту в [[scope]] i = 4).
            mayorovp, можете пояснить что происходит здесь, для тех кто не пишет на JavaScript (не знаком с асинхронными функциями)?
            • 0
              Не понял вопроса.
              • 0
                Разобрался
                Из задания: анонимная функция замыкает на себя переменные всех внешних функций. В цикле for с задержкой по таймеру вызывается функция setTimeout, поэтому анонимная функция вызывается когда цикл for уже завершён.

                Из вашего примера: в функцию setTimeout добавили параметр i, который сохраняет изменённое значение для передачи анонимной функции (хоть setTimeout выполняется с задержкой по таймеру).
                Неверно понял логику (По-русски описать нужно что происходит. Задание дали, а решение описали размыто и ткнули на справочник; тем кто пишет на JavaScript, — и так всё понятно). Похоже, все функции с задержкой асинхронные.
        • 0

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


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

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

            • +1

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


              const arr = [10, 12, 15, 21];
              for (const [i, item] of arr.entries()) {
                  setTimeout(console.log, 0, `Index: ${i}, element: ${item}`);
              }
        • 0
          Доки:
          var timerId = setTimeout(func / code, delay[, arg1, arg2...])


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

          • 0
            И касаемо 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)
            }
            • 0
              Это же то же самое, вид сбоку.
              • 0
                Нет, это совсем не то же самое. Тут в коллбэке setTimeout будет замыкаться не «i», объявленный в цикле for, а «i», являющийся параметром функции-обёртки. В итоге выведутся индексы от 0 до 3 и соответствующие им элементы.
                • 0
                  И касаемо 1 варианта — почему автор не сделал так? Есть какие то ограничения?

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

                  • 0
                    А, ну тогда понятно о чём речь. Я почему-то подумал что первый вариант это тот который до ката. Каюсь, был невнимателен.
                    • 0
                      Просто тут гланды через _опу (плохо читается, поэтому тут только часть невнимательности))
              • 0
                Да потому что пример специально написан с ошибкой, чтобы выяснить, понимает ли человек, как работают замыкания, или нет.
                • 0
                  Вопрос то мой в другом — зачем в результате выполнения функция, а не выполнение функции в результате :)
            • НЛО прилетело и опубликовало эту надпись здесь
            • +14

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

              • 0
                «Никогда не было, и вот опять» (с)
                • 0
                  Если авторам блога вчера исполнилось 14 или менее — то они могли что-то пропустить.
                  • –2
                    Вы таки не поверите, но до сих пор многие «сеньеры-помидоры», разглагольствующие про graphql, на этом вопросе сыпятся
                    • +1

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

                  • –5
                    В Java подобный код вообще не скомпилируется — потребует явно копировать в локальную переменную, по моему это правильно.
                    • +1
                      Вопрос был бы чуть более хитрым если бы в setTimeout таймаут было не 3000, а 0 (значение по умолчанию). Хитрость ведь не только в области видимости и замыканиях, а еще в понимании того что JS однопоточный и event loop блокировать очень нежелательно.
                      • 0
                        Я совсем не программирую на js, но мои познания в других языках позволили мне правильно ответить про замыкание (все вопросы про замыкания, сводятся к подобной формулировке).
                        А вот ваше уточнение про таймаут 0 и однопоточность не очень понятно, что мы получим?
                        • 0

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

                        • 0
                          так ничего же с 0 не изменится. А перенос времени вычисления вычислительно сложной задачи в рамках этого потока ничего не даст, всё равно луп залочится. Можно порезать задачку на куски и через performance.now отъедать не больше например 10 ms на итерацию, но это изврат. А вообще воркеры же есть. Но на самом деле не все задачи подходят.
                          • 0
                            Да ничего не измениться по сути, просто не все понимают как происходит планировка подобных «отложенных» задач в JS, а там тоже есть что обсудить. И вот как раз воркеры были бы к месту в обсуждении.
                        • +1
                          а мне вот такое решение кажется интересным.
                          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
                            Зашел написать этот же комментарий.
                            Еще можно спрашивать, чему равно 2+3*4, уровень сложности примерно такой же.
                            Только при чем тут гугль?
                          • +2
                            Что-то я разочарована, вполне рядовой вопрос на собесах в московских компаниях в течение уже нескольких лет. Сталкивалась и с такой формулировкой: «Как можно исправить данный пример? Напишите все способы, какие знаете»

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

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


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

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

                                • 0
                                  Ради интереса запустил цикл 1,000,000,000 раз в хроме, то с let работает быстрее.
                                  console.time('loop');
                                  for (let i = 0; i < 1000000000; i++) {}
                                  console.timeEnd('loop');
                                  // loop: 685.50390625ms
                                  
                                  console.time('loop');
                                  for (var i = 0; i < 1000000000; i++) {}
                                  console.timeEnd('loop');
                                  // loop: 2530.260986328125ms
                                  
                                • +3

                                  Хитрый вопрос? Хитрее только "чему равен 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);
                                  • –2
                                    const arr = [10, 12, 15, 21];
                                    arr.forEach((item, i) => setTimeout(_ => console.log('Index: ' + i + ', element: ' + item), 3000));
                                    
                                  • –3
                                    Я только изучаю 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);
                                    • 0
                                      omg, «хитрый» вопрос из google и amazon… Не задают его нигде уже, то есть, задают, но в каком-нибудь первом тесте для отсева неадеквата.
                                      • –1
                                        (к первому листингу и пояснению к нему)
                                        Во-первых, почему последнее значение i — 4, а не 3?
                                        Во-вторых, де-факто в консоли выводится иное…
                                        • 0

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


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

                                          • 0
                                            Да, сорри, какой-то не тот код выполнил. Своими глазами видел элементы массива в выводе.
                                            Сам удивился. Спросоня. Эх, жаль карму…
                                            • 0
                                              Там var на let заменили, в комментарии выше. Не заметил :-) Ну про 4 тупанул, еще раз сорри :-) Чувствую себя первоклашкой :-(
                                        • 0

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

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

                                              Вот такой код:
                                              local cb = {}
                                              local arr = {1,2,3,4}
                                              local counter = 1
                                              
                                              for i = 1, 4 do
                                                table.insert(cb, function ()
                                                  print('index: ' .. i .. ', element: ' .. arr[i] .. ', counter:' .. counter)
                                                end)
                                                counter = counter + 1
                                              end
                                              
                                              for i = 1, #cb do
                                                cb[i]()
                                              end
                                              


                                              Даст вот такой результат:
                                              index: 1, element: 1, counter:5
                                              index: 2, element: 2, counter:5
                                              index: 3, element: 3, counter:5
                                              index: 4, element: 4, counter:5

                                              • –1
                                                Вопрос конечно хороший, но я один думаю, что асинхронные функции в цикле на практике лучше его не использовать из за неочевидного поведения?

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

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