Разбор задач на позицию Go developer (Middle+ / Senior)
Новиков > путь в Big TechЧтобы успешно проходить собеседования, нужно практиковаться. Особенно важно - уделять внимание психологической подготовке, чтобы не волноваться и показывать весь свой потенциал.
Недавно рассказывал свою историю собеседования в один интересный стартап. В этой статье разберу самые хитрые задачи оттуда.
Задача 1. Слайсы (len, cap)
Лайвкодинг по Go часто начинается с простого вопроса: "Что выведет программа?". В таких задачах важно не спешить и анализировать все построчно и посимвольно.

Опустим строки с обозначением пакета (возможные импорты) и главной функции.
На 4-ой строке у нас происходит инициализация слайса четырьмя элементами типа int и связывание с переменной src (len=cap=4).
При работе со слайсами всегда обращаем внимание на значения длины (len) и емкости (cap), поэтому не будет лишним указывать это в комментариях при решении задачи.
На 6-ой строке - инициализация слайса с типом указатель на int и связывание с переменной target, а также явное задание len и cap: len=cap=4.
(!) Важно помнить, что инициализировать слайс с помощью ключевого слова make можно двумя способами:
// 1 make([]*int, 4) // len=cap=4 // 2 make([]*int, 0, 4) // len = 0, cap=4
На этом этапе важно правильно зафиксировать len и cap слайсов.
На 8-10 строке происходит итерация по элементам слайса src. Лучше также проговорить, что итерируемся именно по элементами, опуская первую переменную, в которую записывается индекс.
(!) Важно учесть, что при использовании конструкции range мы работаем с копией слайса src, поэтому внешние изменения на нашу итерацию не влияют.
На 9-ой строке ко второму слайсу target мы добавляем элементы из первого слайса src.
Ловушка 1:
Так как append добавляет элементы в конец слайса, а у нас target уже состоит из 4-х элементов, то остальные будут добавлены к ним. И с учетом количества элементов в первом слайсе, у нас в итоге target будет состоять из 8 элементов (len=cap=8).
Хоть мы и нашли первую ловушку, которую нам расставили интервьюеры, но это еще далеко не все.
Ловушка 2:
До go 1.22 существовала популярная проблема, что при работе с указателем на переменную внутри цикла range (в нашем случае s), мы каждую итерацию работаем только с одной переменной, то есть адреса памяти всегда будут одинаковы, что приводило к ошибкам. Для исправления приходилось каждый раз явно переопределять переменную, добавляя первой строчкой в цикле:
s := s
Ловушка 3:
Наконец, на 12-ой строке мы печатаем слайс в стандартный консольный вывод. Здесь ничего особого не происходит за исключением того, что напечатаем мы непосредственно адреса на ячейки памяти, но не сами значения элементов.
Задача 2. Изменение слайса
Разобравшись с первым примером, нас теперь могут попросить изменить слайс. Задание может звучать так:
Напишите функцию, которая будет складывать все элементы массива и добавлять результат в качестве последнего элемента.
Вариант 1
Если чувствуете, что из-за волнения можете запутаться при работе с указателями, то лучше написать "чистую" функцию, которая не модифицирует входной аргумент, а просто вернет результат.
При суммировании значений переданного слайса важно помнить о его типе. И, если он содержит указатели на значения, то нужно не забыть разыменовать их при сложении, предварительно проверяя, что указатель не равен nil.

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

Задача 3. Каналы
Работа с каналами и примитивами синхронизации - еще один крайне популярный тип задач с собеседований. Вопрос все тот же: "Что выведем программа?".

Так как мы работаем с каналами и знаем, что сложности в основном связаны с deadlock (дедлок - зависание программы) или случайной паникой (например, запись в закрытый канал), то автоматически можно предположить что-то из этих двух вариантов.
Но лучше не торопиться и проанализировать построчно (loc = line of code):
- loc4: инициируем буферизованный канал (размер буфера - 1000 элементов) типа int и связываем его с переменной pipe;
- loc6-10: итерируемся в цикле for, в котором фактически происходит создание 1000 горутин с функцией setUp и передачей в нее канала pipe;
- loc12: инициируем переменную sum типа int и задаем значение ноль;
- loc13-15: итерируемся в цикле range по каналу pipe и добавляем к переменной sum значения, которые вычитали из канала;
- loc17: печатаем текущее значение переменной sum;
- loc20-24: функция setUp , принимающая один аргумент типа chan int и в цикле for записывающая 3000 раз значение i в переданный канал; так как вызов функции происходит на 8-ой строке, то мы пишем в канал pipe;
Проанализировав построчно, составляем целую картину:
- Наша функция запускает 1000 горутин, каждая из которых записывает в буферизованный канал (размером 1000) 3000 элементов (значения от 0-2999). И мы понимаем, что после превышения размера буфера, мы заблокируемся, пока из канала не начнется чтение.
- Далее вычитываем значения из канала в цикле range и суммируем их в переменной sum.
- После завершения цикла печатаем итоговый результат sum.
Что сразу бросается в глаза? Мы нигде не закрываем канал, а если так, то дальше функции range мы не выйдем, так как итерация по каналу происходит до его закрытия.
Таким образом, из-за причины выше правильным ответом на первоначальный вопрос будет deadlock. Но теперь, очевидно, нас попросят эту ситуацию исправить.
Начинаем рассуждать:
1. Нам необходимо закрыть канал, но в каком месте мы пока не знаем.
2. Так как у нас 1000 одновременных горутин, то нужно дожидаться выполнения каждой, иначе получим панику при попытке записи в закрытый канал.
3. Если нам нужно дождаться всех горутин, то необходимо использовать примитивы синхронизации.
4. Так как количество горутин определенно и мы явно понимаем, когда они заканчивают работать, то нам может подойти примитив sync.WaitGroup.
5. Добавление счетчика wg.Add(1000) мы можем произвести сразу, wg.Done() нам также понятно где разместить, но сходу непонятно где ожидать завершения с помощью wg.Wait() ...
6.a) За пределы функции range без закрытия канала мы не выйдем.
6.b) Внутри функции range ожидать завершения не можем, так как после завершения ожидания мы хотим закрыть канал, а при итерации у нас может случиться ситуация, что получим панику из-за множественного закрытия (если вызываем close в цикле).
6.c) Остается единственный вариант - написать до функции range, но сделать мы должны это таким образом, чтобы не заблокировались и могли читать из канала. Также помним, что чтение происходит у нас только внутри range...
6.d) Таким образом, единственный вариант - поместить wg.Done() в отдельную горутину до функции range и там же закрыть канал, когда дождемся все горутины.
Собирая все вместе, исправляем:
- loc8-9: инициируем примитив синхронизации sync.WaitGroup{}, связываем с переменной wg и сразу обновляем внутренний счетчик до 1000, так как будем ожидать выполнение 1000 горутин;
- loc14: добавляем wg.Done(), чтобы уменьшать значение счетчика, когда очередная горутина завершит работу;
- loc18-21: добавляем функцию в горутине, которая будет ожидать завершения всех горутин через wg.Wait() и также закрывать канал close(pipe), когда мы прекратим запись.

У задач описанных выше существует не одно решение и часто в ходе лайвкодинга интервьюеры, чтобы оценить глубину ваших знаний по платформе Go, могут интересоваться альтернативными решениями, их сравнением, а также внутренним устройством используемых конструкций.
Попутные вопросы
- Что представляет собой map? (рассказать про хэш-функции и про внутреннее устройство: бакеты, эвакуации и пр.)
- Какие бывают каналы и чем отличаются? (типы: буферизованный и небуферизованные, рассказать про блокировку при чтении/запись)
- Что такое примитивы синхронизации и какие бывают? (см. пакет sync)
- Что такое горутина и как происходит переключение между ними? (рассказать про планировщик)