На технических собеседованиях, помимо проверки теоретических знаний, принято задавать задачки, чтоб оценить уровень практических знаний кандидата, его способность писать код, способность мыслить логически и алгоритмически. Часто в этот список входят алгоритмические задачи. Все уже к ним привыкли и при подготовке, в первую очередь, смотрят именно на них. Список там большой, но основное, что чаще всего встречается, выглядит примерно так:
- факториал
- числа Фибоначчи
- уникальность элементов массива
- проверка на сбалансированность скобок внутри текста
- сортировки (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
UPD Вношу некоторые корректировки в способ async/await, следуя комментариям. Действительно, можно было бы сократить способ и дать ему большей наглядности с помощью for of
:
async function asyncAwaitWay(callback) {
let results;
for (const url of urls) {
results = await fakeFetch(url, results);
}
callback(result);
}
sshikov
>И наконец, последний метод решения, который я и ожидал от всех кандидатов.
Хм. Не, ну метод в общем вполне возможный, но с другой стороны — это способ из мира ФП, ожидать его от всех — ну это выглядит немного как перебор. Ну и потом, в таком языке, как js, когда система типов вам многое не подсказывает, сломать нетривиальный reduce при рефакторинге — раз плюнуть.
qbz
Примитивнее reduce — разве что только map. Что там трудного и причем тут вообще типы?
sshikov
>Что там трудного и причем тут вообще типы?
Посмотрите скажем вот сюда, тут есть примерчик, когда reduce используется на слегка более сложных типах, чем числа, и что из этого может получиться. Насколько автор легко забирается в дебри, и насколько мало язык ему в этом помогает.
А типы тут при том, что функция в reduce да, сложнее, чем в map. И если аргументы (и результат) не просто числа или строки (а например объекты js) — то сломать, а потом починить это будет совсем не просто. Да и читать тоже. В языке со статической типизацией (хотя бы на уровне Java) компилятор обычно подсказывает, что вы функцию сломали, и сигнатура не соответствует — а тут в js этого не будет.
Я не против reduce вовсе — я за то, чтобы в js его применять аккуратно.
Zenitchik
В этом примерчике используются функции с побочкой.
Если использовать чистые функции — то всё просто и понятно не зависимо от типа возвращаемого значения.
Ну да. Приходится самому смотреть и километрами комментов код покрывать.
sshikov
>В этом примерчике используются функции с побочкой.
Ну, я не уверен, что проблемы примера только в этом. Там сложная свертка, и формируемые диапазоны — это не число, а некая структура данных (список пар «начало-конец»). Фактически, мы там пытаемся понять, входит ли очередное число в имеющийся диапазон, или же формирует новый — и в первом случае этот имеющийся нужно расширить. Ну т.е. тут случай, когда иметь мутабельные данные для диапазона проще, по крайней мере на первый взгляд.
А иммутабельную структуру и чистую функцию построить не совсем тривиально.
qbz
По иронии судьбы в статье, что вы написали в комментах есть мое решение. И оно тоже проще некуда. И тоже на редьюс. Незнаю почему его так много людей боятся, это же реально простой метод.
sshikov
Я разве говорил, что мне не нравится такое решение? Я просто констатирую этот же странный факт — к сожалению, на сегодня многие люди его не понимают (думаю, боятся по этой же причине).
Посмотрел решение в том посте. Пожалуй, нечего уже добавить к этому комментарию. Решение норм, но когда свертка — это не сумма/произведение, такое решение часто будет на грани понимания для множества людей. Возможно даже не самых глупых — но с некоторой, скажем так, «традиционной» подготовкой.