На технических собеседованиях, помимо проверки теоретических знаний, принято задавать задачки, чтоб оценить уровень практических знаний кандидата, его способность писать код, способность мыслить логически и алгоритмически. Часто в этот список входят алгоритмические задачи. Все уже к ним привыкли и при подготовке, в первую очередь, смотрят именно на них. Список там большой, но основное, что чаще всего встречается, выглядит примерно так:
- факториал
- числа Фибоначчи
- уникальность элементов массива
- проверка на сбалансированность скобок внутри текста
- сортировки (mergeSort, insertionSort, bubbleSort, quickSort)
- деревья (обход в глубину / обход в ширину / нахождение кратчайшего пути между узлами)
За последние два года, проведя порядка 70 собеседований по JavaScript, постепенно начал понимать, что они не всегда отражают действительность, так как именно их и ожидает кандидат, именно к ним он и подготовился лучше всего (а если не подготовился, то сам виноват).
Поэтому хотелось задание, которое удовлетворяло бы таким критериям:
- легкость для понимания кандидатом
- приближено к реальной задаче
- способность отразить уровень практических знаний кандидата
- наличие нескольких решений
- не занимало бы много времени на решение
И самая, на мой взгляд, простая практическая задача оказалась в числе претендентов совершенно случайно.
Задача звучала примерно следующим образом:
Предположим, нам надо сделать несколько последовательных запросов к серверу со следующими условиями:
- количество запросов заранее неизвестно
- результат выполнения каждого запроса должен передаваться в качестве параметров в следующий
- без использования сторонних библиотек
Схематично это выглядело бы примерно так:
fetch(url1) => fetch(url2, resultsUrl1) => fetch(url3, resultsUrl2)
или что-то вроде
compose(res2 => fetch(url3, res2), res1 => fetch(url2, res1), () => fetch(url1))
как бы мы могли решить эту задачу?
И по прошествии нескольких десятков собеседований я составил примерный список ответов, которые сходу предлагали кандидаты (отсортированный по частоте их использования):
- генераторы
- async/await
- рекурсия
И именно тот способ, который я ожидал изначально услышать, никогда не назывался (речь идет о методе reduce
, он будет последним решением).
Потом мы переходили к решению задачи, предложенным кандидатом методом; очень быстро понимали, что этот метод чем-то нас не устраивает; предлагались другие методы; и решение простой задачи часто растягивалось во времени и строчках кода.
Тогда я решил сам решить задачу всеми способами, чтоб иметь возможность сравнить все решения между собой объективнее. Именно этот опыт предоставил бы возможность более предметно дискутировать. Ведь всегда оставалась вероятность, что есть более лаконичное и красивое решение среди предложенных кандидатами, которое я просто отметал, основываясь на своем субъективном мнении.
Так как список решений кандидатов не казался мне исчерпывающим, я добавил еще решение задачи с помощью асинхронных генераторов и обычного reduce метода, являющегося прототипом Array
. Тем самым общий список решений дополнился двумя пунктами:
- асинхронные генераторы
- метод reduce
И так, для простоты возьмем фейковую fetch функцию, которая будет имитировать запросы к серверу:
function fakeFetch (url, params='-') {
// этот вывод в консоль покажет порядок вызовов с их входящими параметрами
console.log(`fakeFetch to: ${url} with params: ${params}`);
return new Promise(resolve => {
setTimeout(() => resolve(`${url} is DONE`), 1000);
})
};
Список адресов ограничим тремя элементами (для простоты):
const urls = ['url1', 'url2', 'url3'];
Но наше решение должно не зависеть от их количества (сморим условие 1), т.е цепочки вида then().then().then()
и await; await; await;
заранее отбраковываются.
Для наглядности, результат будем выбрасывать в callback. Тогда вызов функции во всех случаях будет выглядеть следующим образом:
fetchSeries(result => console.log(`result: ${result}`))
Я не смог найти универсального способа оценки, поэтому оценивать будем по количеству строк. Для этого будем придерживаться одинаковых правил переноса строк, чейнинг методов и блоков, чтобы в результате получить наиболее объективные оценки.
Генераторы
Ни один из кандидатов, выбравший этот способ для решения задачи первоначально, не смог довести решение до конца. Изначально, всем оно казалось самым простым и целесообразным, но начиная идти этим путем, все быстро сдавались.
function generatorWay(callback) {
function* generateSequence() {
let results;
for (let i = 0; i < urls.length; i++) {
results = yield fakeFetch(urls[i], results);
}
return results;
}
function execute(generator, yieldValue) {
let next = generator.next(yieldValue);
if (!next.done) {
return next.value
.then(result => execute(generator, result));
} else {
callback(next.value);
}
}
execute(generateSequence())
}
попробовать можно тут
Общий принцип такой:
- генератор generateSequence
yield'ит
не просто значения, а промисы. - есть специальная функция
execute(generator)
, которая запускает генератор последовательными вызовамиnext
, получает из него промисы — один за другим, и, когда очередной промис выполнится, возвращает его результат в генератор следующимnext
. - последнее значение генератора
execute
уже обрабатывает как окончательный результат, вызывая callback.
Асинхронные генераторы
Чтобы избежать рекурсии в предыдущем способе, можно воспользоваться асинхронным генератором и итерировать его циклом while:
async function asyncGeneratorWay(callback) {
async function* generateSequence() {
let results;
for (let i = 0; i < urls.length; i++) {
results = yield await fakeFetch(urls[i], results);
}
return results;
}
let generator = generateSequence();
let result;
while (!result || !result.done) {
result = await generator.next(result && result.value);
}
callback(result.value);
}
попробовать можно тут
Так мы экономим несколько строк и получаем более наглядный код (хотя этот аргумент довольно спорный).
Перебирать же с помощью for await of
не выйдет, потому что это нарушит дополнительное условие 2.
Async/await
Второй по популярности способ. Он вполне пригоден, но пропадает вся красота использования конструкций async/await. А также, внешнюю функцию тоже приходится объявлять как async, что не всегда удобно и целесообразно.
async function asyncAwaitWay(callback) {
const series = async () => {
let results;
for (let i = 0; i < urls.length; i++) {
results = await fakeFetch(urls[i], results);
}
return results;
}
const result = await series();
callback(result);
}
попробовать можно тут
тут мы просто в цикле вызываем каждый fakeFetch
и ждем его выполнения с помощью await
;
Recursion
По сути, это повторение метода reduce (о котором речь пойдет немного дальше), только перебор мы осуществляем рекурсивным вызовом функции recursion
самой себя. Но количество кода получается вдвое больше. Выглядит немного неуклюже, будто создаем рекурсию ради рекурсии:
function recursionWay(callback) {
const recursion = (arr = [], promise = Promise.resolve()) => {
if (!arr.length) {
return promise;
}
const [url, ...restUrls] = arr;
return promise
.then(res => recursion(restUrls, fakeFetch(url, res)));
}
recursion(urls)
.then(result => callback(result));
}
попробовать можно тут
на самом деле можно было использовать метод shift вместо деструктуризации, но количество строк от этого не меняется. А деструктуризация выглядит немного читабельнее для нашего примера.
Promise.resolve(), в качестве значения по-умолчанию, используем для первой итерации, когда никакого промиса у нас еще нет, чтоб избежать постоянных проверок.
Reduce
И наконец, последний метод решения, который я и ожидал от всех кандидатов. Разумеется, в беседе мы часто приходили к этому решению, но изначально ни одним кандидатом он не был озвучен как возможное решение задачи.
function reduceWay(callback) {
urls
.reduce((accum, item) => {
return accum
.then(res => fakeFetch(item, res))
}, Promise.resolve())
.then(result => callback(result));
}
попробовать можно тут
тут все просто:
- итерируемся по массиву
- по цепочке запускаем следующий fakeFetch из метода then;
- так же как и в предыдущем способе, Promise.resolve(), в качестве значения по-умолчанию, используем для первой итерации, когда никакого обещания(Promise) у нас еще нет, чтоб избежать постоянных проверок. Это выглядит равноценно такой записи:
function reduceWay(callback) {
urls
.reduce((accum, item) => {
if (!accum) {
return fakeFetch(item);
}
return accum
.then(res => fakeFetch(item, res));
})
.then(result => callback(result));
}
при этом получаем на 2 строки кода меньше.
Выводы
Получилась вот такая таблица сравнений. Это все, что можно выдать за объективность:
способ | кол. строк | разница |
---|---|---|
reduce | 6 | 1 |
async/await | 9 | x1.5 |
recursion | 10 | x1.67 |
генераторы (асинхронные) | 13 | x2.17 |
генераторы | 17 | x2.83 |
И фаворитом в этой "гонке", как видно из таблицы, оказался обычный метод reduce. Разумеется, в реальных условиях этот код будет еще читабельнее и короче (за счет форматирования). И будет выглядеть, например, так:
const reduceWay = callback => urls.reduce(
(acc, item) => acc.then(res => fakeFetch(item, res)),
Promise.resolve())
.then(result => callback(result));
}
Послесловие
Простая, казалось бы, практическая задача ставила в тупик многих кандидатов, что и послужило причиной выбора ее для дальнейших собеседований.
Для сильных кандидатов была возможность проверить знание и умение работы с генераторами, для средних — с рекурсиями. Для евангелистов async/await — показать, что не везде синхронность написания асинхронных вызовов уместна и лаконична. Новичков всегда можно было определить по неумению работы с reduce и/или боязни использования рекурсий.
Это не полноценная задача для оценки уровня кандидата, но начало для беседы, в результате которой рождается истина… но это не точно.
Полезные ссылки
Генераторы
Асинхронные генераторы
Массив: перебирающий метод reduce
Рекурсия
Async/await
Промисы
Цепочка промисов