Задачи с собеседования на Golang-разработчика в Альфа-Банк
Сегодня мы разберём собеседование на позицию Go-разработчика в Альфа-Банке, в ходе которого кандидату предлагается решить три задачи, проверяющие понимание работы с Unicode-символами, механизма panic/recover в Go и написание функции для генерации уникальных случайных чисел.
Вопрос 1. Что выведит программа, итерирующаяся по строке с помощью range в цикле, и почему? Как корректно обработать Unicode-символы?
Таймкод: 00:00:04
Ответ собеседника: Правильный. Программа выведит позицию и элемент строки, а затем длину строки. Однако range по строке идёт по байтам, а не по символам. Кириллические символы занимают 2 байта, поэтому на второй итерации будет распечатан первый байт второго символа, а не сам символ. Длина строки также выведется как количество байт (6), а не символов. Для корректной обработки Unicode-символов нужно использовать итерацию по рунам, а для получения длины — предварительно преобразовать строку в слайс рун.
Правильный ответ:
К сожалению, в ответе собеседника содержится принципиальная ошибка: range по строке в Go итерируется именно по рунам (Unicode code points), а не по байтам. Это одно из ключевых свойств языка Go, которое отличает его от многих других языков (например, C или Python 2).
Как работает range по строке в Go
Когда вы используете for i, ch := range "Привет", компилятор Go автоматически декодирует UTF-8 последовательность и на каждой итерации возвращает:
i— байтовый индекс начала текущей руны в строкеch— саму руну (типrune, который является алиасом дляint32)
package main
import "fmt"
func main() {
s := "Привет"
for i, ch := range s {
fmt.Printf("индекс=%d, руна=%c, тип=%T\n", i, ch, ch)
}
}
Вывод:
индекс=0, руна=П, тип=int32
индекс=2, руна=р, тип=int32
индекс=4, руна=и, тип=int32
индекс=6, руна=в, тип=int32
индекс=8, рука=е, тип=int32
индекс=10, руна=т, тип=int32
Обратите внимание: индекс прыгает через 2, потому что каждая кириллическая руна занимает 2 байта в UTF-8. Но сама переменная ch содержит полный символ, а не отдельный байт.
Длина строки: len() vs utf8.RuneCountInString()
Здесь действительно есть нюанс:
s := "Привет"
fmt.Println(len(s)) // 12 — количество байт
fmt.Println(utf8.RuneCountInString(s)) // 6 — количество рун (символов)
Функция len() возвращает количество байт в строке, а не количество символов. Для получения количества символов нужно использовать utf8.RuneCountInString() из пакета unicode/utf8.
Когда range действительно идёт по байтам
Если вы итерируете строку вручную через индекс, то да — вы получите байты:
s := "Привет"
for i := 0; i < len(s); i++ {
fmt.Printf("байт=%d\n", s[i]) // будут напечатаны отдельные байты
}
Или при преобразовании в []byte:
bytes := []byte("Привет")
for i, b := range bytes {
fmt.Printf("индекс=%d, байт=%d\n", i, b)
}
Суррогатные пары и составные символы
Есть ещё более тонкий момент: некоторые Unicode-символы состоят из нескольких code points (например, эмодзи с модификаторами кожи или флаги стран). В этом случае даже range по рунам не даст одного «визуального символа» — он выдаст несколько рун:
s := "👨👩👦" // семейный эмодзи — это несколько рун, соединённых через ZWJ
fmt.Println(utf8.RuneCountInString(s)) // 5 (а не 1!)
Для корректной обработки таких случаев нужна работа с графемными кластерами (grapheme clusters), что выходит за стандартную библиотеку Go и требует сторонних пакетов, например github.com/rivo/uniseg.
Ключевые выводы
rangeпо строке в Go итерируется по рунам (code points), а не по байтам — это встроенное поведение языка.len(string)возвращает количество байт, для количества рун используйтеutf8.RuneCountInString().- Для ручной побайтовой обработки используйте
[]byte(s)или индексациюs[i]. - Для обработки составных Unicode-символов (графемных кластеров) стандартных средств Go недостаточно.
Вопрос 2. Что выведит программа с функцией recover в defer, и почему сообщение 'return from panic' не появляется в выводе?
Таймкод: 00:01:50
Ответ собеседника: Правильный. Функция recover работает только если она вызвана непосредственно в той же функции, где находится defer. В данном случае recover был вызван в отдельной анонимной функции, поэтому он не отловил панику. Если бы recover был вызван напрямую в функции с defer, то он бы успешно перехватил панику и вывел 'return from panic'.
Правильный ответ:
Ответ собеседателя в целом верен, но формулировка может ввести в заблуждение. Уточним детали.
Ключевое правило: recover работаТОЛЬКО внутри defer, но не в произвольной вложенной функции
Функция recover() перехватывает панику только если она вызвана напрямую внутри функции, которая была зарегистрирована через defer. Если recover() вызвана внутри другой анонимной функции (даже если эта анонимная функция сама вызывается из defer), паника не будет перехвачена.
Неправильный код — recover в отдельной анонимной функции:
package main
import "fmt"
func main() {
defer func() {
recover() // ❌ это отдельная анонимная функция, recover здесь не сработает
fmt.Println("return from panic")
}()
panic("something went wrong")
}
Вывод:
panic: something went wrong
Паника не перехвачена, потому что recover() вызвана внутри отдельной анонимной функции, а не непосредственно в функции, переданной в defer.
Правильный код — recover вызван напрямую в defer:
package main
import "fmt"
func main() {
defer func() {
if r := recover(); r != nil { // ✅ recover вызван непосредственно в defer
fmt.Println("recovered:", r)
}
fmt.Println("return from panic")
}()
panic("something went wrong")
}
Вывод:
recovered: something went wrong
return from panic
Ещё пример — recover во вложенной функции (не работает):
package main
import "fmt"
func main() {
defer func() {
// Вызов другой функции — recover не сработает
tryRecover()
}()
panic("oops")
}
func tryRecover() {
if r := recover(); r != nil {
fmt.Println("recovered:", r)
}
}
Вывод:
panic: oops
Как правильно использовать recover
package main
import (
"fmt"
"log"
)
func main() {
defer func() {
if r := recover(); r != nil {
log.Printf("Panic recovered: %v", r)
// Здесь можно выполнить cleanup, логирование,
// отправить алерт и т.д.
}
}()
doSomethingRisky()
fmt.Println("This line will be reached if panic was recovered")
}
func doSomethingRisky() {
panic("critical failure")
}
Важные нюансы
recover()возвращаетnil, если паники нет или если она вызвана не напрямую вdefer.- После успешного перехвата паники программа продолжает выполнение после вызова
defer, а не в том месте, где была паника. recoverчасто используется в HTTP-обработчиках и воркерах, чтобы одна паничная горутина не убивала весь сервер:
func handleRequest(w http.ResponseWriter, r *http.Request) {
defer func() {
if r := recover(); r != nil {
log.Printf("Handler panic: %v\n%s", r, debug.Stack())
http.Error(w, "Internal Server Error", 500)
}
}()
// ... обработка запроса
}
Итог
recover() работает только при прямом вызове внутри функции, зарегистрированной через defer. Любая дополнительная вложенность — анонимная функция, именованная функция, метод — делает recover() бесполезным для перехвата паники.
Вопрос 3. Как написать функцию, которая возвращает слайс с заданным количеством случайных уникальных чисел?
Таймкод: 00:03:05
Ответ собеседника: Правильный. Для решения задачи нужно создать мапу для хранения уникальных чисел и результирующий слайс. В цикле генерируем случайные числа с помощью rand.Intn(1000), проверяем наличие числа в мапе. Если числа нет в мапе, добавляем его в мапу и результирующий слайс. Цикл продолжается, пока слайс не будет полностью заполнен уникальными значениями.
Правильный ответ:
Ответ собеседника описывает рабочий подход, но он неоптимален и не учитывает важные крайние случаи. Рассмотрим несколько подходов.
Подход 1: Через мапу (описан собеседником)
func uniqueRandomMap(n, max int) ([]int, error) {
if n > max {
return nil, fmt.Errorf("cannot generate %d unique numbers from range [0, %d)", n, max)
}
result := make([]int, 0, n)
seen := make(map[int]struct{}, n)
for len(result) < n {
num := rand.Intn(max)
if _, exists := seen[num]; !exists {
seen[num] = struct{}{}
result = append(result, num)
}
}
return result, nil
}
Проблема этого подхода: при малом размере диапазона и большом n цикл будет долго «биться» в уже существующие числа (birthday problem). Например, при n=999, max=1000 последние числа будут находиться очень долго.
Подход 2: Fisher-Yates shuffle (оптимальный)
Создаём слайс со всеми возможными значенияниями, перемешиваем и берём первые n элементов. Гарантированное время выполнения O(max).
func uniqueRandomShuffle(n, max int) ([]int, error) {
if n > max {
return nil, fmt.Errorf("cannot generate %d unique numbers from range [0, %d)", n, max)
}
// Создаём слайс [0, 1, 2, ..., max-1]
nums := make([]int, max)
for i := range nums {
nums[i] = i
}
// Перемешиваем (Fisher-Yates)
rand.Shuffle(max, func(i, j int) {
nums[i], nums[j] = nums[j], nums[i]
})
// Берём первые n элементов
return nums[:n], nil
}
Подход 3: Частичный Fisher-Yates (лучший при n << max)
Если n сильно меньше max (например, 10 чисел из 1000000), нет смысла создавать весь слайс. Делаем частичное перемешивание:
func uniqueRandomPartial(n, max int) ([]int, error) {
if n > max {
return nil, fmt.Errorf("cannot generate %d unique numbers from range [0, %d)", n, max)
}
// Создаём слайс только из max элементов для "виртуального" перемешивания
// Используем мапу для отслеживания "swap"-ов
nums := make([]int, max)
for i := range nums {
nums[i] = i
}
for i := 0; i < n; i++ {
j := rand.Intn(max - i)
nums[i], nums[j] = nums[j], nums[i]
}
return nums[:n], nil
}
Подход 4: Через map с гарантированным завершением (для небольших n)
func uniqueRandom(n, max int) ([]int, error) {
if n > max {
return nil, fmt.Errorf("n (%d) must be <= max (%d)", n, max)
}
m := make(map[int]bool, n)
result := make([]int, 0, n)
for len(result) < n {
v := rand.Intn(max)
if !m[v] {
m[v] = true
result = append(result, v)
}
}
return result, nil
}
Сравнение подходов
| Подход | Время работы | Память | Когда использовать |
|---|---|---|---|
| Map + генерация | O(n) средний, но неограничен в худшем случае | O(n) | n << max, простой код |
| Полный shuffle | O(max) | O(max) | n близко к max |
| Частичный shuffle | O(n) | O(max) — можно оптимизировать до O(n) с мапой | n << max, нужна гарантия времени |
Оптимальный вариант с мапой для частичного shuffle (O(n) по памяти и времени)
func uniqueRandomOptimal(n, max int) ([]int, error) {
if n > max {
return nil, fmt.Errorf("n (%d) must be <= max (%d)", n, max)
}
// Используем мапу как "виртуальный" массив для частичного shuffle
// swapped[i] хранит значение, которое оказалось на позиции i после swap
swapped := make(map[int]int, n)
result := make([]int, n)
for i := 0; i < n; i++ {
j := i + rand.Intn(max - i)
// swapped[i] — значение на позиции i (или i, если не менялось)
// swapped[j] — значение на позиции j (или j, если не менялось)
vi, oki := swapped[i]
if !oki {
vi = i
}
vj, okj := swapped[j]
if !okj {
vj = j
}
result[i] = vj
swapped[j] = vi
}
return result, nil
}
Важные edge cases, которые нужно учитывать:
n > max— невозможно сгенерировать больше уникальных чисел, чем размер диапазонаn == 0— возвращаем пустой слайсmax <= 0— невалидные входные данные- Инициализация генератора случайных чисел: в Go 1.20+
rand.Seedне нужен, генератор автоматически инициализируется случайным seed'ом. В более старых версиях нужно вызватьrand.Seed(time.Now().UnixNano())вinit()илиmain().
Рекомендуемая реализация для интервью:
func generateUniqueRandom(n, max int) ([]int, error) {
if n < 0 || max <= 0 {
return nil, fmt.Errorf("invalid parameters: n=%d, max=%d", n, max)
}
if n > max {
return nil, fmt.Errorf("cannot pick %d unique numbers from [0, %d)", n, max)
}
// Для плотного заполнения (n близко к max) используем shuffle
if n > max/2 {
nums := make([]int, max)
for i := range nums {
nums[i] = i
}
rand.Shuffle(max, func(i, j int) {
nums[i], nums[j] = nums[j], nums[i]
})
return nums[:n], nil
}
// Для разреженного заполнения (n << max) используем map
seen := make(map[int]struct{}, n)
result := make([]int, 0, n)
for len(result) < n {
v := rand.Intn(max)
if _, ok := seen[v]; !ok {
seen[v] = struct{}{}
result = append(result, v)
}
}
return result, nil
}
