8 October 2019

Как Яндекс научил меня собеседовать программистов

Entertaining tasksJavaScriptDevelopment Management
После того, как я изложил свою историю «трудоустройства» в Яндекс в комменте к нашумевшей заметке «Как я проработала 3 месяца в Я.Маркете и уволилась», было бы несправедливо утаить и ту пользу, которую я вынес из своего опыта Яндекс.Собеседования.

В мои рабочие обязанности входит техническое интервьюирование кандидатов на позицию Fullstack JavaScript/TypeScript Developer, активно этим делом (стоит ли говорить, что слегка поднадоевшим?) я занимаюсь больше года, за плечами более 30 технических интервью.

Раньше на техническом интервью я задавал кандидату довольно бестолковые вопросы аля «что такое замыкание», «как в JavaScript реализуется наследование», «вот есть такая-то таблица в БД с такими-то индексами, расскажите, пожалуйста, как можно ускорить такой-то запрос», которые хоть и помогали выявить инженерные способности кандидата, но совершенно не позволяли сделать вывод о том, насколько хорошо человек сможет решать задачи и насколько быстро он сможет разобраться в уже существующем коде. Что не могло не привести к печальным последствиям…

Но всё поменялось после того, как я прошёл четыре круга технических собеседований в Яндексе.

Обычно в огород интервьюеров Яндекса летят камни за:

1. Задачи, которые не имеют практической ценности;
2. Необходимость решать эти задачи на листках бумаги карандашом или на доске.

Уже 2019-й год и настала пора запускать в аду отдельную производственную линию по отливу котлов для тех, кто заставляет людей писать от руки текст, не говоря уже о коде. Каждый человек пишет по-разному, и мне, подготавливая текст к этой заметке, пришлось переписать, например, конкретно вот этот абзац шесть раз — если бы я писал заметки для Хабра на бумаге, я бы не писал заметки для Хабра.

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

/* Необходимо реализовать функцию getRanges, которая возвращает следующие результаты: */
getRanges([0, 1, 2, 3, 4, 7, 8, 10]) // "0-4,7-8,10"
getRanges([4,7,10]) // "4,7,10"
getRanges([2, 3, 8, 9]) // "2-3,8-9"

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

function getRanges(arr: number[]) {
  return arr.map((v, k) => {
    if (v - 1 === arr[k - 1]) {
      if (arr[k + 1] === v + 1) {
        return ''
      } else {
        return `-${v},`
      }
    } else {
      return v + ','
    }
  }).join('').split(',-').join('-')
}

Из минусов: обращение к массиву по несуществующему индексу и некрасивая манипуляция со строкой: join-split-join. А ещё это решение неправильное, потому что на примере getRanges([1, 2, 3, 5, 6, 8]) возвращается «1-3,5-6,8,», и чтобы «убить» запятую в конце, нужно ещё нарастить условия, усложнив логику и снизив читаемость.

Вот решение в духе Яндекса:
const getRanges = arr => arr
  .reduceRight((r, e) => r.length ? (r[0][0] === e + 1 ? r[0].unshift(e) : r.unshift([e])) && r : [[e]], [])
  .map(a => a.join('-')).join(',')

Поможет ли гугление писать такие элегантные решения? Чтобы выдать такой код, нужны две составляющие: опыт работы с множеством алгоритмов и отличное знание языка. И именно об этом предупреждают рекрутёры Яндекса: вас будут спрашивать об алгоритмах и языке. Яндекс предпочитает нанимать разрабов, способных писать крутой код. Такие прогеры эффективны, но, самое главное, взаимозаменяемы: они будут писать примерно одинаковые решения. Менее подкованные в теоретическом плане разработчики на одну задачу способны выдать десятки разнообразных, порой просто удивительных решений: один из кандидатов на нашу вакансию завернул такой костыль, что у меня глаза на лоб полезли.

UPD: как заметил пользователь MaxVetrov, моё решение неверное:

getRanges([1,2,3,4,6,7]) // 1-2-3-4,6-7

Таким образом, я сам не смог нормально решить эту задачу до сих пор.

UPD2: В целом, комментарии убедили меня, что этот код получился хреновым, даже если бы и работал правильно.

Я не зря потратил время, переводя бумагу в офисе Яндекса, потому что за этим занятием я понял, как самому стать эффективным интервьюером. Я взял за основу идею и формат их задач, но:

  • Не предлагал писать код на бумаге, но просил писать в code.yandex-team.ru. Это многопользовательский онлайн-редактор кода без возможности его выполнения. Возможно, есть и другой, более удобный вариант, но искать и было, и осталось лень;
  • Не требовал идеального решения, но просил решить хоть как-то;
  • Не требовал знания языка наизусть, нужную функцию или метод можно было спросить;
  • Сократил время на техническое интервью до 30 минут.

Одна из задач нашего технического интервью:

let n = 0
while (++n < 5) {
	setTimeout(() => console.log(n), 10 + n)
}
// Что будет выведено в консоль?

Я считаю, что для JavaScript-разработчика это очень показательный тест. И дело здесь не в замыкании и не в понимании отличий между преинкрементом и постинкрементом, а в том, что по какой-то необъяснимой причине четверть интервьюируемых полагает, что console.log выполнится раньше, чем завершится цикл. Я не преувеличиваю. У этих людей в резюме и опыт работы минимум два года, да и другие задачи, не завязанные на коллбеки, они успешно решали. То ли это новое поколение JavaScript-разработчиков, выросшее на async/await, которое ещё что-то слышало про Promise, но коллбеки для них — как дисковый телефон для современного подростка — номер наберёт, пусть не с первого раза, но так и не поймёт, как оно работает и зачем.

Эта задача имеет продолжение: нужно дописать код так, чтобы console.log так же выполнялся внутри setTimeout, но в консоль вывелись значения 1, 2, 3, 4. Тут уместна поговорка «век живи — век учись», так как однажды один из интервьюируемых предложил такое решение:

setTimeout(n => console.log(n), 10 + n, n)

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

А вот эту задачу я позаимствовал у Яндекса как есть:

/* Необходимо реализовать функцию fetchUrl, которая будет использоваться следующим образом. Внутри fetchUrl можно использовать условный метод fetch, который просто возвращает Promise с содержимым страницы или вызывает reject */
fetchUrl('https://google/com')
  .then(...)
  .catch(...) // сatch должен сработать только после 5 неудачных попыток получить содержимое страницы внутри fetchUrl

Тут проверяются навыки работы с Promise. Обычно я прошу решить эту задачу на чистых промисах, а затем с использованием async/await. С async/await решение интуитивно простое:

function async fetchUrl(url) {
  for (let n = 0; n < 5; n++) {
    try {
      return await fetch(url)
    } catch (err) { }
  }
  throw new Error('Fetch failed after 5 attempts')
}

К этому решению тоже можно применить поговорку «век живи — век учись», но уже относительно моего интервьюера из Яндекса: он не уточнил, что можно/нельзя использовать async/await, и когда я написал такое решение, он удивился: «я не работал с async/await, не думал что это можно решить так просто». Наверное, он ожидал увидеть что-то такое:

function fetchUrl(url, attempt = 5) {
  return Promise.resolve()
    .then(() => fetch(url))
    .catch(() => attempt-- ? fetchUrl(url, attempt) : Promise.reject('Fetch failed after 5 attempts'))
}'error'

Этот пример способен показать насколько хорошо человек понимает промисы, это особенно важно на бэке. Однажды я видел код JavaScript разработчика, не разобравшегося до конца с промисами, подготовившего промис для транзакции sequelize следующим образом:

const transaction = Promise.resolve()
for (const user of users) {
  transaction.then(() => {
    return some_action...
  })
}

И удивлявшийся, почему в его транзакции фигурирует только один пользователь. Можно было бы использовать Promise.all, ну а можно было знать, что Promise.prototype.then не добавляет ещё один коллбэк, а создаёт новый промис и правильно будет так:

let transaction = Promise.resolve()
for (const user of users) {
  transaction = transaction.then(() => {
    await perform_some_operation...
    return some_action...
  })
}

Этот случай заставил меня задуматься над тем, чтобы усложнить задачу на понимание промисов, но сформулировать новое задание мне помог кандидат, который отказался решать задачи, назвал их буквально хренью и сказал, что он привык работать с реальным кодом, на что я, покопавшись пару минут в исходниках одного из наших проектов, дал ему реальный код:

public async addTicket(data: IAddTicketData): Promise<number> {
  const user = data.fromEmail ? await this.getUserByEmail(data.fromEmail) : undefined
  let category = data.category

  if (category === 'INCIDENT' && await this.isCategorizableType(data.type)) {
    category = 'INC_RFC'
  }
  const xml = await this.twig.render('Assyst/Views/add.twig', {
    from: data.fromEmail,
    text: data.text,
    category,
    user,
  })
  const response = await this.query('events', 'post', xml)

  return new Promise((resolve, reject) => {
    xml2js.parseString(response, (err, result) => {
      if (err) {
        return reject(new Error(err.message))
      }
      if (result.exception) {
        return reject(new Error(result.exception.message))
      }
      resolve(result.event.id - 5000000)
    })
  })
}

И попросил избавиться от ключевых слов async/await. С тех пор это задание стало первым и, в половине случаев, последним на собеседовании — его реально часто заваливают.

Сам я ни разу не решал эту задачу до написания этой заметки и делаю это в первый в третий раз (первый слишком долгий, а во втором я не заметил один оставшийся await):



Какой вывод из этого всего можно сделать? Собеседования — это интересно и полезно… Разумеется, если вы в срочном порядке не ищeте работу.

Напоследок приведу ещё одну задачу из истории с Яндексом, я её ещё никому* почти не показывал, берёг, что называется, для особого случая. Есть набор баннеров, у каждого баннера есть «вес», который указывает на то, с какой частотой будет отображаться баннер относительно других баннеров:

const banners = [
  { name: 'banner 1', weight: 1 },
  { name: 'banner 2', weight: 1 },
  { name: 'banner 3', weight: 1 },
  { name: 'banner 4', weight: 1 },
  { name: 'banner 5', weight: 3 },
  { name: 'banner 6', weight: 2 },
  { name: 'banner 7', weight: 2 },
  { name: 'banner 8', weight: 2 },
  { name: 'banner 9', weight: 4 },
  { name: 'banner 10', weight: 1 },
]

Например, если есть три баннера с весами 1, 1, 2, их совокупный вес равен 4, а вес третьего равен 2/4 от общего веса, значит и отображаться он должен в 50% случаев. Необходимо реализовать функцию getBanner, которая рандомно, но с учётом весов, возвращает один баннер для показа. Решение можно проверить в этом сниппете, там же выводится ожидаемое и фактическое распределение.

UPD: мне начали не только минусить саму статью, но и семимильными шагами жечь карму и я сперепугу скрыл материал, что некрасиво по отношению к комментаторам. Исправляю этот мудачизм с моей стороны.
Tags:собеседованиетрудоустройствоjavascriptрекрутинг
Hubs: Entertaining tasks JavaScript Development Management
+2
17.7k 95
Comments 121
Top of the last 24 hours