Перейти к основному содержимому

Mock-собеседование по Go от Team Lead Ozon

· 47 мин. чтения

Сегодня мы разберём собеседование на позицию Go-разработчика, в ходе которого кандидат с опытом работы около 1–2 лет демонстрирует уверенное владение базовыми конструкциями языка — слайсами, мапами, интерфейсами, горутинами и каналами, однако испытывает затруднения в тонкостях работы с замыканиями, примитивами синхронизации (в частности, WaitGroup) и деталях внутреннего устройства некоторых типов. Практическая часть, посвящённая реализации функции мультиплексирования каналов, в целом выполнена верно, хотя и с подсказками интервьюера, что в итоге позволяет оценить уровень кандидата как уверенный junior+/middle с хорошим потенциалом роста.

Вопрос 1. Расскажите немного о себе, где работаете, чем занимаетесь и какой коммерческий опыт на Go?

Таймкод: 00:00:00

Ответ собеседника: Правильный. Работает в Яндексе в бизнес-юните инфраструктуры над проектом по выдаче доступа. Параллельно учится в университете Иннополис и планирует поступать в магистратуру центрального университета. Коммерческий опыт на Go — примерно 1–2 года. Также пишет проекты на Go.

Правильный ответ:

Это вводный вопрос, который задаётся, чтобы снять напряжение перед интервью и получить общее представление о кандидате. Здесь нет единственно правильного ответа — важно дать структурированную и чёткую самопрезентацию.

Что стоит рассказать:

  • Текущее место работы и роль: в какой компании, в каком подразделении, над какими задачами работаете.
  • Коммерческий опыт на Go: сколько лет, какие типы проектов (микросервисы, высоконагруженные системы, CLI-утилиты, инфраструктурные инструменты).
  • Ключевые технологии и инструменты, с которыми работали помимо Go (базы данных, брокеры сообщений, контейнеризация, мониторинг).
  • Образование и планы по развитию — это дополнительный контекст, который помогает интервьюеру лучше понять кандидата.

Пример хорошего ответа:

«Работаю в компании X на позиции Go-разработчика. Занимаюсь разработкой и поддержкой микросервисной архитектуры — в основном это сервисы обработки заказов и интеграции с внешними платёжными системами. Коммерческий опыт на Go — около 2 лет. Работал с PostgreSQL, Kafka, gRPC, Docker и Kubernetes. Также участвовал в проектировании API и оптимизации производительности критичных сервисов. Параллельно учусь в университете и планирую углубиться в распределённые системы.»

Ответ собеседника полностью соответствует ожиданиям от вводного вопроса — он кратко и по делу описал свой опыт, место работы и планы.

Вопрос 2. Что такое слайс в Go и как он устроен?

Таймкод: 00:01:10

Ответ собеседника: Правильный. Слайс — это ссылка на массив с текущей длиной и capacity (ёмкостью). Под капотом это структура, содержащая указатель на массив, длину и ёмкость.

Правильный ответ:

Слайс (slice) — это динамическая абстракция над массивом, которая предоставляет гибкий способ работы с последовательностями элементов одного типа. В отличие от массива, слайс не владеет данными напрямую, а является «окном» в нижележащий массив (backing array).

Внутреннее устройство (runtime representation)

Под капотом слайс — это структура runtime.SliceHeader (или аналогичная внутренняя структура):

type SliceHeader struct {
Data uintptr // указатель на первый элемент нижележащего массива
Len int // текущая длина (количество видимых элементов)
Cap int // ёмкость (общее количество элементов в нижележащем массиве, начиная от первого элемента слайса)
}
  • Data — указатель на область памяти, где хранятся элементы нижележащего массива.
  • Len — сколько элементов сейчас «видно» через слайс.
  • Cap — сколько элементов можно добавить до того, как потребуется выделить новый нижележащий массив.

Ключевые свойства

  • Слайс — ссылочный тип. При присваивании одного слайса другому копируется только заголовок (указатель, len, cap), а не данные. Оба слайса ссылаются на один и тот же нижележащий массив.
  • При добавлении элементов через append, если len < cap, элемент записывается в существующий массив. Если len == cap, выделяется новый массив (обычно с удвоением ёмкости), данные копируются, и слайс начинает указывать на новый массив.
  • Операция среза a[low:high] создаёт новый слайс, указывающий на ту же память, что и исходный.

Пример:

a := []int{1, 2, 3, 4, 5} // len=5, cap=5
b := a[1:3] // len=2, cap=4 (указывает на a[1])
b[0] = 99
fmt.Println(a) // [1 99 3 4 5] — изменились оба, т.к. один backing array

Распространённая ловушка:

s := make([]int, 3, 5) // len=3, cap=5
sub := s[1:2] // len=1, cap=4
sub = append(sub, 10)
sub = append(sub, 20)
// Теперь sub и s могут иметь разные backing arrays,
// если append превысил cap sub, но не cap s

Почему это важно понимать:

  • Позволяет избегать неожиданных мутаций данных при передаче слайсов между функциями.
  • Помогает контролировать аллокации памяти и оптимизировать производительность.
  • Критично при работе с большими объёмами данных — непонимание механизма capacity может приводить к утечкам памяти или лишним копированиям.

Ответ собеседника корректен и покрывает основные аспекты устройства слайса.

Вопрос 3. Как происходит вставка элемента в слайс через append? Что происходит с capacity при переполнении?

Таймкод: 00:01:40

Ответ собеседника: Правильный. Если capacity не превышена — вставка за константное время. При переполнении выделяется дополнительная память. Сначала capacity увеличивается в два раза (4→8→16...), затем коэффициент роста уменьшается, чтобы не забирать лишнюю память.

Правильный ответ:

Механизм append

Функция append — встроенная функция Go, которая добавляет элементы в конец слайса и возвращает новый слайс (возможно, с другим нижележащим массивом).

s = append(s, elem)

Важно: результат append всегда нужно присваивать обратно переменной, потому что может быть выделен новый backing array.

Два сценария при append

1. Есть свободная ёмкость (len < cap):

Элемент записывается в следующую ячейку существующего нижележащего массива. Длина увеличивается на 1. Новый массив не выделяется. Сложность — O(1).

s := make([]int, 2, 5) // len=2, cap=5
s = append(s, 42) // len=3, cap=5 — тот же backing array

2. Ёмкость исчерпана (len == cap):

  • Выделяется новый нижележащий массив с увеличенной ёмкостью.
  • Все существующие элементы копируются в новый массир.
  • Новый элемент добавляется.
  • Возвращается слайс, указывающий на новый массив.
  • Старый массив остаётся в памяти до тех пор, пока на него есть ссылки (после чего собирается GC).

Сложность этой операции — O(n) из-за копирования, но благодаря стратегии роста амортизированная стоимость остаётся O(1).

Стратегия роста capacity (growslice)

Внутри runtime функция growslice определяет новую ёмкость. Логика зависит от размера элемента и текущей ёмкости:

  • Для маленьких слайсов (cap < 256): ёмкость удваивается (newcap = 2 * oldcap).
  • Для больших слайсов (cap >= 256): рост замедляется. Формула примерно такая:
    newcap = oldcap + (oldcap + 3*threshold) / 4
    где threshold = 256. То есть рост составляет примерно 1.25x от текущей ёмкости плюс константа.

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

Пример роста:

s := make([]int, 0)
for i := 0; i < 20; i++ {
s = append(s, i)
fmt.Printf("len=%d cap=%d\n", len(s), cap(s))
}
// len=1 cap=1
// len=2 cap=2
// len=3 cap=4
// len=4 cap=4
// len=5 cap=8
// len=8 cap=8
// len=9 cap=16
// len=16 cap=16
// len=17 cap=32
// ... и т.д.

Практические рекомендации:

  • Если заранее известен примерный размер, используйте make([]T, 0, estimatedCap) — это избавит от лишних аллокаций и копирований.
  • Не забывайте присваивать результат append: s = append(s, val), а не просто append(s, val).
  • При работе с большими слайсами будьте внимательны: подслайсы могут удерживать в памяти весь большой нижележащий массив, даже если вам нужны только несколько элементов. В таких случаях используйте copy.

Ответ собеседника полностью корректен и покрывает ключевые аспекты механизма append и стратегии роста capacity.

Вопрос 4. Какой будет capacity у нулевого (nil) слайса и после добавления одного элемента в nil-слайс?

Таймкод: 00:02:28

Ответ собеседника: Правильный. У nil-слайса capacity равен нулю. После append одного элемента capacity станет 1 (аллоцируется минимальная ёмкость для размещения элемента).

Правильный ответ:

Nil-слайс

Nil-слайс — это слайс, у которого не выделен нижележащий массив. Все три поля заголовка равны нулю: Data = nil, Len = 0, Cap = 0.

var s []int
fmt.Println(s == nil) // true
fmt.Println(len(s)) // 0
fmt.Println(cap(s)) // 0

Nil-слайс — это валидное состояние. Его можно передавать в функции, проверять на len == 0, и — что важно — к нему можно применять append.

Поведение append на nil-слайсе

Когда append вызывается на nil-слайсе, runtime обнаруживает, что cap == 0, и выделяет новый нижележащий массив. Минимальная ёмкость для первого элемента — 1.

var s []int // nil slice: len=0, cap=0
s = append(s, 42) // len=1, cap=1
fmt.Println(s == nil) // false — больше не nil

Почему это работает:

Функция append специально обрабатывает nil-слайсы. Внутри runtime логика примерно такая:

// Упрощённая логика growslice
func growslice(oldCap, newLen int) int {
if oldCap == 0 {
return newLen // для nil-слайса выделяем ровно столько, сколько нужно
}
// ... обычная логика роста
}

Продолжая добавлять элементы:

var s []int
s = append(s, 1) // len=1, cap=1
s = append(s, 2) // len=2, cap=2 (удвоение)
s = append(s, 3) // len=3, cap=4 (удвоение)
s = append(s, 4) // len=4, cap=4
s = append(s, 5) // len=5, cap=8 (удвоение)

Разница между nil-слайсом и пустым слайсом:

var s1 []int // nil-слайс
s2 := []int{} // пустой, но не nil-слайс (len=0, cap=0, Data != nil)
s3 := make([]int, 0) // пустой, но не nil-слайс (len=0, cap=0, Data != nil)

fmt.Println(s1 == nil) // true
fmt.Println(s2 == nil) // false
fmt.Println(s3 == nil) // false

Оба варианта (nil и пустой) имеют len == 0 и безопасны для большинства операций, но ведут себя по-разнпри сериализации (JSON: nil → null, пустой → []) и при сравнении с nil.

Практический совет:

В Go принято возвращать nil-слайс (а не пустой слайс) при отсутствии результатов — это идиоматично и экономит аллокацию:

func findItems() []Item {
// ничего не нашли
return nil // а не return []Item{}
}

Ответ собеседника полностью верен — он правильно описал поведение nil-слайса и результат первого append.

Вопрос 5. Что выведет программа с подслайсом и append? Почему изменение подслайса влияет на исходный слайс?

Таймкод: 00:03:01

Ответ собеседника: Правильный. Подслайс ссылается на ту же область памяти, что и исходный. При append элемента в подслайс значение записывается в общую память, поэтому исходный слайс тоже изменяется. Результат — оба слайса содержат [1, 2, 3, 4].

Правильный ответ:

Рассмотрим типичный пример, который может быть на интервью:

package main

import "fmt"

func main() {
s := []int{1, 2, 3, 4, 5} // len=5, cap=5
sub := s[1:3] // len=2, cap=4 → элементы [2, 3]
sub = append(sub, 99) // cap=4, len был 2 → есть место
fmt.Println("s:", s) // [1 2 3 99 5]
fmt.Println("sub:", sub) // [2 3 99]
}

Почему так происходит:

  1. s создан с len=5, cap=5. Его нижележащий массив: [1, 2, 3, 4, 5].
  2. sub := s[1:3] создаёт новый слайс-заголовок, указывающий на тот же массив, но со смещением. sub видит элементы с индексами 1 и 2 исходного массива ([2, 3]), а его cap=4, потому что от позиции 1 до конца массива ещё 4 ячейки.
  3. sub = append(sub, 99) — у sub len=2, cap=4, свободная ёмкость есть. Элемент 99 записывается в ячейку с индексом 3 исходного массива (это s[3], который был 4).
  4. Исходный слайс s всё ещё имеет len=5 и видит все 5 элементов массива, включая изменённый s[3].

Визуализация памяти:

Backing array: [1, 2, 3, 4, 5]
↑ ↑
s (len=5): [1, 2, 3, 4, 5]

sub (len=2, cap=4): ↑ ↑ ↑ ↑
[2, 3, 4, 5]
0 1 2 3 (индексы в sub)

После append(sub, 99):
sub (len=3, cap=4): [2, 3, 99, _]
Backing array: [1, 2, 3, 99, 5]
s (len=5): [1, 2, 3, 99, 5]

Когда append НЕ влияет на исходный слайс:

Если при append ёмкость подслайса исчерпывается, выделяется новый нижележащий массив, и связь с исходным слайсом теряется:

s := []int{1, 2, 3, 4, 5} // len=5, cap=5
sub := s[1:5] // len=4, cap=4 → [2, 3, 4, 5]
sub = append(sub, 99) // cap исчерпан → новый массив!
fmt.Println("s:", s) // [1 2 3 4 5] — не изменился
fmt.Println("sub:", sub) // [2 3 4 5 99]

Как избежать неожиданных мутаций:

Если нужна независимая копия слайса, используйте copy:

sub := make([]int, len(s[1:3]))
copy(sub, s[1:3])
sub = append(sub, 99) // не влияет на s

Или ограничьте ёмкость исходного слайса при создании подслайса (так называемый «полный срез»):

sub := s[1:3:3] // len=2, cap=2 — cap ограничен до len
sub = append(sub, 99) // cap исчерпан → новый массив, s не изменится

Синтаксис s[low:high:max] задаёт cap = max - low, что гарантирует, что append выделит новый массив.

Ответ собеседника корректен — он правильно объяснил причину мутации исходного слайса при append к подслайсу.

Вопрос 6. Как избежать проблемы совместного использования памяти при работе с подслайсами? В чём особенность функции copy?

Таймкод: 00:04:19

Ответ собеседника: Правильный. Для создания независимой копии слайса можно использовать функцию copy или создать новый слайс и скопировать элементы в цикле. Функция copy копирует элементы из одного буфера в другой буфер, сама не выделяет память — нужно заранее создать целевой слайс нужного размера через make.

Правильный ответ:

Способы создания независимой копии слайса

1. Использование copy

Функция copy(dst, src) копирует элементы из исходного слайса в целевой. Она не выделяет память — целевой слайс должен быть заранее создан с достаточной ёмкостью. Возвращает количество скопированных элементов (минимум из len(dst) и len(src)).

original := []int{1, 2, 3, 4, 5}
copySlice := make([]int, len(original))
copy(copySlice, original)

copySlice[0] = 99
fmt.Println(original) // [1 2 3 4 5] — не изменился
fmt.Println(copySlice) // [99 2 3 4 5]

Важная особенность copy: если целевой слайс короче исходного, скопируется только то количество элементов, которое помещается:

src := []int{1, 2, 3, 4, 5}
dst := make([]int, 3)
n := copy(dst, src) // n = 3, dst = [1, 2, 3]

2. Использование append к nil-слайсу

Идиоматичный способ, который создаёт новый нилежайший массир:

original := []int{1, 2, 3, 4, 5}
copySlice := append([]int(nil), original...)
// или
copySlice := append([]int{}, original...)

Это создаёт новый слайс с собственным backing array и копирует туда все элементы. Немного менее эффективно, чем copy с заранее аллоцированным слайсом (потому что append может вызвать несколько реаллокаций при росте), но код короче.

3. Полный срез (three-index slice expression)

При создании подслайса можно ограничить ёмкость, чтобы любой последующий append гарантированно выделил новый массив:

s := []int{1, 2, 3, 4, 5}
sub := s[1:3:3] // len=2, cap=2 (cap = max - low = 3 - 1 = 2)
sub = append(sub, 99) // cap исчерпан → новый массив
fmt.Println(s) // [1 2 3 4 5] — не изменился

Это самый элегантный способ, когда вам нужен подслайс, который гарантированно не будет «портить» исходный при append.

4. Копирование через цикл (ручной способ)

original := []int{1, 2, 3, 4, 5}
copySlice := make([]int, len(original))
for i, v := range original {
copySlice[i] = v
}

Работает, но идиоматичнее использовать copy.

Поверхностное vs глубокое копирование

Важно понимать, что copy и все перечисленные способы выполняют поверхностное копирование (shallow copy). Если слайс содержит ссылочные типы (указатели, слайсы, мапы, структуры с полями-ссылками), то копируются только сами ссылки, а не данные, на которые они указывают:

type Item struct {
Data []int
}

original := []Item{{Data: []int{1, 2, 3}}}
copySlice := make([]Item, len(original))
copy(copySlice, original)

copySlice[0].Data[0] = 99
fmt.Println(original[0].Data) // [99 2 3] — изменился!

Для полного отделения нужна глубокая копия с рекурсивным копированием вложенных структур.

Практическая рекомендация:

При написании функций, которые принимают слайсы и могут их модифицировать, явно документе, изменяет ли функция входной слайс или возвращает новый. Если входные данные не должны меняться — делайте копию в начале функции.

Ответ собеседника корректен и покрывает ключевые аспекты работы с copy и создания независимых копий слайсов.

Вопрос 7. Что такое мапа в Go и как она устроена под капотом?

Таймкод: 00:06:35

Ответ собеседния: Правильный. Мапа — это хеш-таблица, разделённая на бакеты под капотом. Бакеты помогают правильно распределять значения и избегать большого количества коллизий в одном месте.

Правильный ответ:

Мапа (map) — это встроенный тип данных Go, представляющий собой хеш-таблицу (hash map), которая хранит пары ключ-значение и обеспечивает среднюю сложность O(1) для операций вставки, поиска и удаления.

Внутренняя структура (runtime.hmap)

Под капотом мапа — это структура runtime.hmap, содержащая:

type hmap struct {
count int // количество пар ключ-значение
flags uint8 // флаги состояния (итерация, рост и т.д.)
B uint8 // логарифм количества бакетов (buckets = 2^B)
noverflow uint16 // количество переполненных бакетов
hash0 uint32 // seed для хеш-функции (рандомизация)
buckets unsafe.Pointer // указатель на массив бакетов
oldbuckets unsafe.Pointer // указатель на старый массив бакетов (при росте)
nevacuate uintptr // прогресс эвакуации при росте
extra *mapextra // дополнительные данные (overflow-указатели)
}

Структура бакета (runtime.bmap)

Каждый бакет — это структура, способная хранить до 8 пар ключ-значение. Внутри бакет организован так:

type bmap struct {
tophash [8]uint8 // старшие биты хеша для каждого слота
// далее следуют ключи и значения (упакованы в памяти)
// keys: key0, key1, ..., key7
// values: val0, val1, ..., val7
// overflow *bmap — указатель на следующий бакет при переполнении
}
  • tophash — массив из 8 байт, содержащих старшие биты хеша соответствующего ключа. Используется для быстрого поиска внутри бакета — сначала сравниваются tophash, и только потом, при совпадении, сравниваются ключи целиком.
  • overflow — указатель на следующий бакет в цепочке при переполнении.

Механизм работы

1. Вычисление хеша:

При обращении к мапе ключ хешируется с помощью хеш-функции, зависящей от типа ключа. Результат XOR-ится с hash0 (случайный seed, чтобы защитить от Hash-DoS атак).

hash := typehash(key) ^ h.hash0

2. Определение бакета:

Младшие B биты хеша определяют, в каком бакете хранится пара:

bucketIndex := hash & ((1 << h.B) - 1)

3. Поиск внутри бакета:

Старшие 8 бит хеша (tophash = hash >> 56) сравниваются с tophash[i] каждого слота. При совпадении проверяется равенство ключей.

4. Обработка коллизий:

Если бакет заполнен (все 8 слотов заняты), создаётся overflow-бакет, связанный цепочкой.

Рост мапы (growing)

Когда load factor (количество элементов / количество бакетов) превышает порог (~6.5 элементов на бакет), мапа растёт:

  • Количество бакетов удваивается (B += 1).
  • Элементы постепенно переносятся в новые бакеты (инкрементальная эвакуация), а не все сразу — это предотвращает большие задержки.
  • Во время роста oldbuckets указывает на старый массив, buckets — на новый.

Важные свойства

  • Порядок итерации по мапе не определён и может меняться между вызовами (намеренно рандомизируется).
  • Мапа — ссылочный тип. При передаче в функции копируется только указатель.
  • Нулевое значение мапы — nil. Чтение из nil-мапы возвращает zero value, а запись вызывает panic.
  • Мапы не потокобезопасны. Параллельная запись и чтение приводят к панике (или гонкам данных без -race). Для конкурентного доступа используйте sync.Mutex, sync.RWMutex или sync.Map.

Пример:

m := make(map[string]int, 100) // hint на ~100 элементов
m["key"] = 42
v, ok := m["key"] // v=42, ok=true
v, ok = m["missing"] // v=0, ok=false
delete(m, "key")

Ответ собеседника корректен — он описал ключевую идею о бакетах и их роли в распределении значений.

Вопрос 8. Как записать элемент в мапу? Что будет при записи и чтении из неинициализированной мапы?

Таймкод: 00:07:20

Ответ собеседника: Правильный. Для записи мапу нужно предварительно инициализировать через make или литерал. При записи в nil-мапу возникнет паника. При чтении из nil-мапы вернётся нулевое значение типа без ошибки.

Правильный ответ:

Инициализация мапы

Есть два основных способа создать мапу:

// Через make
m1 := make(map[string]int)
m2 := make(map[string]int, 100) // hint на количество элементов (не ёмкость в привычном смысле)

// Через литерал
m3 := map[string]int{}
m4 := map[string]int{"a": 1, "b": 2}

Разница между var m map[string]int (nil-мапа) и m := map[string]int{} (пустая, но инициализированная мапа) критична:

var nilMap map[string]int
emptyMap := map[string]int{}

fmt.Println(nilMap == nil) // true
fmt.Println(emptyMap == nil) // false

Запись элемента

m := make(map[string]int)
m["key"] = 42

При записи Go вычисляет хеш ключа, определяет бакет, ищет свободный слот (или существующий ключ для перезаписи) и записывает пару ключ-значение.

Поведение с nil-мапой

Чтение из nil-мапы — безопасно, возвращает zero value типа значения:

var m map[string]int
v := m["missing"] // v = 0, паники нет
v, ok := m["missing"] // v = 0, ok = false

Это поведение согласовано с тем, как работают другие типы Go: чтение из неинициализированной переменной возвращает zero value.

Запись в nil-мапу — вызывает панику в runtime:

var m map[string]int
m["key"] = 42 // panic: assignment to entry in nil map

Это происходит потому, что у nil-мапы нет нижележащей структуры hmap и бакетов — записывать некуда.

Проверка наличия ключа

m := map[string]int{"a": 1}

// Двухзначная форма — идиоматичный способ проверки
v, ok := m["a"]
if ok {
fmt.Println("key exists:", v)
}

// Однозначная форма — если ключ отсутствует, вернётся zero value
v := m["nonexistent"] // v = 0

Удаление элемента

delete(m, "key")

delete из nil-мапы безопасен — это операция no-op, паники не будет.

Итерация

for key, value := range m {
fmt.Println(key, value)
}

Итерация по nil-мапе безопасна — цикл просто не выполнится ни разу.

Сводная таблица поведения nil-мапы:

ОперацияПоведение
Чтение m[key]Zero value, без паники
Проверка v, ok := m[key]v = zero, ok = false
Запись m[key] = valPanic
delete(m, key)No-op, без паники
len(m)0
for range mЦикл не выполняется

Практический совет:

Если мапа может быть nil (например, поле структуры или параметр функции), перед записью проверяйте и инициализируйте:

func addToMap(m map[string]int, key string, val int) {
if m == nil {
m = make(map[string]int)
}
m[key] = val
}

Ответ собеседника полностью верен — он точно описал поведение nil-мапы при чтении и записи.

Вопрос 9. Как проверить наличие ключа в мапе?

Таймкод: 00:09:29

Ответ собеседника: Правильный. При чтении из мапы можно использовать двухзначный синтаксис: val, ok := m[key]. Если ключ существует — ok будет true, иначе false. При отсутствии ключа val получит нулевое значение типа.

Правильный ответ:

Двухзначная форма обращения к мапе (comma ok idiom)

Это идиоматичный и единственный надёжный способ проверить наличие ключа:

m := map[string]int{"alice": 30, "bob": 25}

age, ok := m["alice"]
if ok {
fmt.Println("alice found, age:", age) // alice found, age: 30
}

age, ok = m["charlie"]
if !ok {
fmt.Println("charlie not found") // charlie not found
}

Почему нельзя проверять по значению:

Однозначная форма v := m[key] возвращает zero value при отсутствии ключа, что неотличимо от ситуации, когда ключ существует, но его значение — zero value:

m := map[string]int{"zero_key": 0}

v := m["zero_key"] // v = 0 — ключ существует
v2 := m["missing"] // v2 = 0 — ключа нет

// Невозможно отличить эти два случая без проверки ok

Использование в условиях:

// Тернарного оператора в Go нет, поэтому используют if с comma ok
if val, ok := m["key"]; ok {
// ключ существует, можно использовать val
process(val)
} else {
// ключа нет
}

Проверка только наличия ключа (без значения):

Если значение не нужно, используйте _:

if _, ok := m["key"]; ok {
fmt.Println("key exists")
}

Важно для булевых мап:

Для map[string]bool особенно важно использовать comma ok, потому что false — это и zero value, и потенциально валидное значение:

flags := map[string]bool{"debug": false}

// Неправильно:
if flags["debug"] {
// не выполнится, даже если ключ существует!
}

// Правильно:
if ok := flags["debug"]; ok {
// выполнится, если ключ "debug" есть в мапе
}

Ответ собеседника полностью корректен — он точно описал механизм comma ok idiom и поведение при отсутствии ключа.

Вопрос 10. Какие типы могут использоваться в качестве ключа мапы?

Таймкод: 00:10:05

Ответ собеседника: Правильный. В качестве ключа могут использоваться сравнимые типы: строки, числа, структуры (если все их поля сравнимы). Несравнимые типы (слайсы, мапы) не могут быть ключами.

Правильный ответ:

Правило: ключ мапы должен быть сравнимым (comparable)

Спецификация Go требует, чтобы тип ключа мапы поддерживал операторы == и !=. Это необходимо, потому что при коллизии хешей внутри бакета нужно точно сравнить ключи.

Сравнимые типы (могут быть ключами):

  • Базовые типы: int, uint, float64, complex128, string, bool, byte, rune
  • Указатели: *T — сравниваются по адресу в памяти
  • Каналы: chan T — сравниваются по идентификатору канала
  • Интерфейсы: динамический тип и значение должны быть сравнимыми
  • Массивы: [N]T — если тип элемента T сравним (размер массива является частью типа)
  • Структуры: если все поля структуры сравнимы
// Валидные ключи
m1 := map[int]string{1: "one"}
m2 := map[string]int{"key": 1}
m3 := map[[3]int]string{{1,2,3}: "array key"}
m4 := map[struct{ x, y int }]string{{1, 2}: "struct key"}
m5 := map[*int]string{}
m6 := map[chan bool]int{}

Несравнимые типы (НЕ могут быть ключами):

  • Слайсы ([]T) — не поддерживают ==
  • Мапы (map[K]V) — не поддерживают ==
  • Функции (func(...)) — не поддерживают ==
  • Структуры, содержащие несравнимые поля
// Невалидно — ошибка компиляции
// m1 := map[[]int]string{} // slice как ключ
// m2 := map[map[string]int]int{} // map как ключ
// m3 := map[func()]int{} // func как ключ

type BadStruct struct {
data []int
}
// m4 := map[BadStruct]int{} // структура с slice-полем

Обход ограничения для слайсов:

Если нужна семантика «ключ — последовательность», можно:

  1. Использовать массив фиксированного размера: map[[N]T]V
  2. Конвертировать слайс в строку (для строковых ключей): map[string]V, где ключ — string(bytes)
  3. Использовать собственную хеш-таблицу или структуру данных
  4. Использовать строковое представление слайса как ключ
// Пример: использование строкового представления
m := make(map[string]int)
key := []byte{1, 2, 3}
m[string(key)] = 42

Указатели как ключи:

Указатели сравниваются по адресу, а не по значению. Два указателя на разные переменные с одинаковым содержимым — разные ключи:

a := 42
b := 42
m := map[*int]string{}
m[&a] = "first"
m[&b] = "second"
fmt.Println(len(m)) // 2 — два разных ключа

Ответ собеседника полностью корректен — он точно описал требование сравнимости и привёл примеры допустимых и недопустимых типов ключей.

Вопрос 11. Проанализируйте код с интерфейсами: скомпилируется ли присваивание структуры переменной типа интерфейса? Что происходит при приведении интерфейса к другому типу?

Таймкод: 00:10:44

Ответ собеседника: Правильный. Присваивание структуры, реализующей интерфейс, переменной этого интерфейса — корректно. Приведение интерфейса к несовместимому типу вызовет ошибку компиляции или панику при использовании type assertion без проверки.

Правильный ответ:

Присваивание конкретного типа интерфейсной переменной

В Go используется уткиная типизация (duck typing) для интерфейсов: тип реализует интерфейс неявно, если он имеет все методы этого интерфейса. Никакого явного объявления «implements» не требуется.

type Speaker interface {
Speak() string
}

type Dog struct{}

func (d Dog) Speak() string {
return "Woof!"
}

// Корректно — Dog реализует Speaker
var s Speaker = Dog{}
fmt.Println(s.Speak()) // "Woof!"

Внутреннее устройство интерфейса

Под капотом интерфейс — это структура из двух указателей (runtime.iface):

type iface struct {
tab *itab // информация о типе и таблица методов
data unsafe.Pointer // указатель на данные (значение конкретного типа)
}
  • tab — содержит метаданные о конкретном типе и таблицу указателей на методы.
  • data — указатель на значение конкретного типа.

При присваивании var s Speaker = Dog{} компилятор создаёт iface, где tab указывает на информацию о типе Dog и его методах, а data указывает на значение Dog{}.

Nil-интерфейс vs интерфейс с nil-значением

Это одна из самых коварных тем:

var p *int // nil-указатель
var i interface{} = p // интерфейс НЕ nil — он содержит тип *int и значение nil

fmt.Println(i == nil) // false!

Интерфейс равен nil только когда и тип, и значение внутри него — nil:

var i interface{}
fmt.Println(i == nil) // true — тип и значение оба nil

Type assertion (приведение типа)

Механизм извлечения конкретного типа из интерфейса:

var s Speaker = Dog{}

// Однозначная форма — panic при несовпадении типа
d := s.(Dog) // OK

// Двузначная форма — безопасная проверка
d, ok := s.(Dog)
if ok {
fmt.Println("это Dog")
}

// Panic — Cat не совпадает с фактическим типом Dog
// c := s.(Cat) // panic: interface conversion: Speaker is Dog, not Cat

Type switch

Для проверки нескольких возможных типов:

func describe(s Speaker) {
switch v := s.(type) {
case Dog:
fmt.Println("Dog says:", v.Speak())
case Cat:
fmt.Println("Cat says:", v.Speak())
default:
fmt.Println("Unknown speaker")
}
}

Ошибка компиляции vs паника в runtime:

  • Если тип заведомо не реализует интерфейс — ошибка компиляции:
type Foo struct{}
var s Speaker = Foo{} // compilation error: Foo does not implement Speaker (missing Speak method)
  • Если тип реализует интерфейс, но type assertion к другому типу — паника в runtime:
var s Speaker = Dog{}
_ = s.(Cat) // runtime panic

Практический совет:

Всегда используйте двузначную форму v, ok := i.(T) при работе с интерфейсами, если не уверены на 100% в типе. Это предотвратит неожиданные паники в production.

Ответ собеседника корректен — он верно описал и присваивание, и поведение type assertion с проверкой и без.

Вопрос 12. Для чего используются интерфейсы в Go? Для чего нужен пустой интерфейс (interface{})?

Таймкод: 00:13:12

Ответ собеседника: Правильный. Интерфейсы используются для абстракции и возможности бесшовной замены реализаций (например, при смене базы данных). Пустой интерфейс interface{} может принимать любой тип и использовался до появления дженериков для создания универсальных функций, принимающих параметры любого типа.

Правильный ответ:

Назначение интерфейсов

Интерфейсы в Go — это основной механизм полиморфизма и абстракции. Они позволяют:

1. Определять поведение вместо реализации

Код зависит от контракта (набора методов), а не от конкретного типа:

type Storage interface {
Get(ctx context.Context, key string) ([]byte, error)
Set(ctx context.Context, key string, val []byte) error
}

// Функция зависит от абстракции, а не от PostgreSQL или Redis
func ProcessData(s Storage, key string) error {
data, err := s.Get(context.Background(), key)
// ...
}

2. Упрощать тестирование (mocking)

Легко подменить реализацию для тестов:

type MockStorage struct {
data map[string][]byte
}

func (m *MockStorage) Get(ctx context.Context, key string) ([]byte, error) {
val, ok := m.data[key]
if !ok {
return nil, errors.New("not found")
}
return val, nil
}

func (m *MockStorage) Set(ctx context.Context, key string, val []byte) error {
m.data[key] = val
return nil
}

func TestProcessData(t *testing.T) {
mock := &MockStorage{data: map[string][]byte{"key": []byte("value")}}
err := ProcessData(mock, "key")
// тестируем без реальной БД
}

3. Соблюдать принцип инверсии зависимостей (DIP)

Модули верхнего уровня зависят от абстракций, а не от конкретных модулей нижнего уровня.

Пустой интерфейс (interface{} / any)

Пустой интерфейс не содержит ни одного метода. Поскольку любой тип реализует ноль методов, любое значение может быть присвоено переменной типа interface{}:

var v interface{}
v = 42
v = "hello"
v = struct{ Name string }{"Alice"}

Начиная с Go 1.18, any является алиасом для interface{}:

type any = interface{}

Типичные применения пустого интерфейса:

// fmt.Println принимает аргументы любого типа
func Println(a ...any) (n int, err error)

// Контейнеры разнородных данных
data := []interface{}{1, "hello", true, 3.14}

// Функции, работающие с произвольными данными (логирование, сериализация)
func LogFields(fields map[string]interface{}) {
for k, v := range fields {
fmt.Printf("%s: %v\n", k, v)
}
}

Ограничения пустого интерфейса:

  • Теряется информация о типе — компилятор не может проверить корректность операций.
  • Требуется type assertion или reflection для работы с конкретным типом — это потенциальный источник паник.
  • Нет гарантий типобезопасности.

Эволюция: дженерики vs interface{}

С появлением дженериков в Go 1.18 многие случаи использования interface{} заменены параметризованными типами:

// До дженериков
func FirstOrDefault(slice []interface{}, defaultVal interface{}) interface{} {
if len(slice) == 0 {
return defaultVal
}
return slice[0]
}

// С дженериками
func FirstOrDefault[T any](slice []T, defaultVal T) T {
if len(slice) == 0 {
return defaultVal
}
return slice[0]
}

Однако interface{} по-прежнему необходим, когда типы действительно неизвестны на этапе компиляции (например, десериализация JSON в map[string]interface{}).

Идиоматический подход в Go:

Предпочитайте маленькие, сфокусированные интерфейсы (1-2 метода). Стандартная библиотека — хороший пример:

type Reader interface {
Read(p []byte) (n int, err error)
}

type Writer interface {
Write(p []byte) (n int, err error)
}

type Closer interface {
Close() error
}

Такие интерфейсы легко реализовать и комбинировать.

Ответ собеседника корректен — он точно описал назначение интерфейсов и роль пустого интерфейса до появления дженериков.

Вопрос 13. В чём главное отличие интерфейсов в Go от интерфейсов в классических языках (Java, C#, C++)?

Таймкод: 00:15:20

Ответ собеседника: Правильный. В Go используется неявная реализация интерфейсов — структура не обязана явно указывать, что реализует интерфейс. Достаточно реализовать все методы интерфейса. В отличие от Java/C#, где нужно явно декларировать реализацию интерфейса.

Правильный ответ:

Неявная реализация (structural typing / duck typing)

Это ключевое отличие. В Go тип автоматически реализует интерфейс, если он имеет все необходимые методы. Никакого ключевого слова implements не требуется.

// Go
type Writer interface {
Write(p []byte) (n int, err error)
}

type File struct{}

func (f File) Write(p []byte) (n int, err error) {
// ...
return len(p), nil
}

// File реализует Writer автоматически — без явного объявления
var w Writer = File{}
// Java — явное указание
interface Writer {
int write(byte[] p) throws IOException;
}

class File implements Writer { // явное implements
public int write(byte[] p) throws IOException {
// ...
}
}

Последствия неявной реализации:

1. Интерфейсы можно определять после типов

В Go интерфейс можно объявить в одном пакете, а типы, его реализующие — в другом. Это позволяет создавать абстракции «на месте», без необходимости менять существующий код:

// Пакет А — определяет интерфейс
type Stringer interface {
String() string
}

// Пакет Б — определяет тип (возможно, написанный кем-то другим)
type MyType struct{}

func (m MyType) String() string {
return "MyType"
}

// MyType автоматически реализует Stringer, даже не зная о его существовании

2. Принцип «accept interfaces, return structs»

Идиоматичный подход в Go: функции принимают интерфейсы (чтобы быть гибкими), а возвращают конкретные типы (чтобы вызывающий код знал, с чем работает).

3. Отсутствие иерархии наследования

В Go нет наследования в привычном смысле. Нет ключевого слова extends. Вместо этого — композиция и встраивание (embedding):

type ReadWriter interface {
Reader
Writer
}

Это не наследование, а встраивание интерфейсов — объединение наборов методов.

4. Маленькие интерфейсы

В Go принято создавать интерфейсы с 1-2 методами. Стандартная библиотека полна примеров:

type Reader interface { Read(p []byte) (n int, err error) }
type Writer interface { Write(p []byte) (n int, err error) }
type Closer interface { Close() error }
type Stringer interface { String() string }

В Java/C# интерфейсы часто содержат десятки методов, что затрудняет их реализацию и тестирование.

5. Отсутствие исключений и checked exceptions

Интерфейсы в Go описывают только сигнатуры методов, без исключений в сигнатуре (как в Java).

Сравнительная таблица:

АспектGoJava / C#
РеализацияНеявная (structural)Явная (implements)
НаследованиеНет (есть embedding)Есть (extends, implements)
Размер интерфейсовМаленькие (1-2 метода)Часто большие
ОпределениеМожно после типовОбычно до типов
GenericsС Go 1.18С Java 5 / C# 2.0

Ответ собеседника полностью корректен — он точно описал главное отличие: неявную реализацию интерфейсов в Go.

Вопрос 14. Что представляет собой тип error в Go? Приведите пример реализации кастомной ошибки.

Таймкод: 00:16:47

Ответ собеседника: Правильный. Error — это интерфейс с методом Error() string. Можно создать кастомную ошибку, определив структуру с нужными полями (например, код ошибки) и реализовав метод Error() string, возвращающий строковое описание ошибки.

Правильный ответ:

Тип error

error — это встроенный интерфейс с единственным методом:

type error interface {
Error() string
}

Любой тип, реализующий метод Error() string, автоматически реализует интерфейс error. Это один из самых маленьких и часто используемых интерфейсов в Go.

Стандартные способы создания ошибок

// errors.New — простая ошибка с текстом
err := errors.New("something went wrong")

// fmt.Errorf — форматированная ошибка
err := fmt.Errorf("user %d not found", userID)

// fmt.Errorf с %w — обёртывание ошибки (Go 1.13+)
if err != nil {
return fmt.Errorf("failed to process user: %w", err)
}

Кастомная ошибка

Для создания кастомной ошибки достаточно определить структуру и реализовать метод Error() string:

type NotFoundError struct {
Resource string
ID int
}

func (e *NotFoundError) Error() string {
return fmt.Sprintf("%s with ID %d not found", e.Resource, e.ID)
}

// Использование
func FindUser(id int) (*User, error) {
// ...
return nil, &NotFoundError{Resource: "User", ID: id}
}

// Обработка
user, err := FindUser(42)
if err != nil {
var notFound *NotFoundError
if errors.As(err, &notFound) {
log.Printf("Resource: %s, ID: %d", notFound.Resource, notFound.ID)
}
}

Кастомная ошибка с кодом и дополнительными данными:

type AppError struct {
Code int
Message string
Err error // исходная ошибка (cause)
}

func (e *AppError) Error() string {
if e.Err != nil {
return fmt.Sprintf("[%d] %s: %v", e.Code, e.Message, e.Err)
}
return fmt.Sprintf("[%d] %s", e.Code, e.Message)
}

// Реализация Unwrap для поддержки errors.Is / errors.As
func (e *AppError) Unwrap() error {
return e.Err
}

// Конструкторы-помощники
func NewNotFound(resource string, id int) error {
return &AppError{
Code: 404,
Message: fmt.Sprintf("%s with ID %d not found", resource, id),
}
}

func NewInternal(err error) error {
return &AppError{
Code: 500,
Message: "internal server error",
Err: err,
}
}

Обёртывание ошибок (error wrapping)

Начиная с Go 1.13, стандартный подход — оборачивать ошибки с помощью %w:

func processFile(path string) error {
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("reading file %s: %w", path, err)
}
// ...
}

Для проверки обёрнутых ошибок используются errors.Is и errors.As:

// errors.Is — проверяет наличие конкретной ошибки в цепочке
if errors.Is(err, os.ErrNotExist) {
// файл не найден
}

// errors.As — извлекает ошибку конкретного типа из цепочки
var pathErr *os.PathError
if errors.As(err, &pathErr) {
fmt.Println("Path:", pathErr.Path)
}

Рекомендации:

  • Используйте errors.New и fmt.Errorf для простых ошибок.
  • Создавайте кастомные типы ошибок, когда нужно передать дополнительный контекст (код ошибки, параметры).
  • Реализуйте метод Unwrap() error для кастомных ошибок, чтобы поддержать errors.Is и errors.As.
  • Оборачивайте ошибки с %w при пробросе наверх, чтобы сохранить цепочку.

Ответ собеседника корректен — он точно описал интерфейс error и принцип создания кастомных ошибок.

Вопрос 15. Что такое конструкция defer в Go? Приведите примеры использования.

Таймкод: 00:19:16

Ответ собеседника: Правильный. Defer — это отложенный вызов функции, который выполняется при завершении текущей функции. Типичные случаи использования: закрытие клиента базы данных (client.Close()), закрытие каналов, освобождение ресурсов после завершения работы программы.

Правильный ответ:

Что такое defer

defer откладывает выполнение функции до момента возврата из текущей функции (через return, панику или нормальное завершение). Аргументы отложенной функции вычисляются сразу при вызове defer, а сама функция выполняется позже.

Ключевые свойства:

1. Выполняется при любом выходе из функции

func example() {
defer fmt.Println("deferred")
fmt.Println("normal")
return
// Вывод:
// normal
// deferred
}

2. Несколько defer выполняются в порядке LIFO (Last In, First Out)

func example() {
defer fmt.Println("1")
defer fmt.Println("2")
defer fmt.Println("3")
fmt.Println("done")
// Вывод:
// done
// 3
// 2
// 1
}

Это аналогично стеку — последний добавленный defer выполнится первым.

3. Аргументы вычисляются сразу

func example() {
i := 0
defer fmt.Println(i) // напечатает 0, а не 1
i++
return
}

Типичные примеры использования:

Закрытие ресурсов:

func readFile(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close() // гарантированно закроется при выходе

data, err := io.ReadAll(f)
if err != nil {
return "", err
}
return string(data), nil
}

Unlock мьютекса:

func (s *SafeCounter) Inc() {
s.mu.Lock()
defer s.mu.Unlock() // гарантированный Unlock даже при панике
s.count++
}

Recover от паники:

func safeHandler() {
defer func() {
if r := recover(); r != nil {
log.Printf("recovered from panic: %v", r)
}
}()
// код, который может вызвать панику
}

Замеры времени:

func timedOperation() {
start := time.Now()
defer func() {
log.Printf("operation took %v", time.Since(start))
}()
// длительная операция
}

Работа с транзакциями:

func doInTransaction(db *sql.DB) error {
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback() // если Commit не вызван — откат

// ... операции с tx ...

return tx.Commit() // при успехе Commit перезапишет результат
}

Важная особенность с именованными возвращаемыми значениями:

Defer может изменять именованные возвращаемые значения:

func example() (result int) {
defer func() {
result++ // изменит возвращаемое значение
}()
return 41 // result = 41, defer увеличит до 42
// функция вернёт 42
}

Распространённая ловушка с циклом:

// Неправильно — все defer выполнятся после цикла, файлы могут закончиться
for _, path := range paths {
f, err := os.Open(path)
if err != nil {
continue
}
defer f.Close() // все Close вызовутся только при выходе из функции
}

// Правильно — оборачиваем в функцию
for _, path := range paths {
func() {
f, err := os.Open(path)
if err != nil {
return
}
defer f.Close() // закроется при выходе из этой итерации
// работа с файлом
}()
}

Ответ собеседника корректен — он описал базовое назначение defer и привёл типичные примеры использования.

Вопрос 16. Что выведет программа с defer и замыканием, захватывающим переменную по ссылке? Как исправить, чтобы выводились актуальные значения?

Таймкод: 00:20:07

Ответ собеседния: Правильный. Программа выведет 1 2 3, потому что замыкание захватило ссылку на слайс, а defer выполнится после изменения слайса. Чтобы исправить и получить 4 5 6, нужно обернуть вызов в анонимную функцию, передавая текущее значение как аргумент — тогда замыкание захватит копию значения на момент вызова.

Правильный ответ:

Рассмотрим типичный пример с замыканием и defer:

package main

import "fmt"

func main() {
s := []int{1, 2, 3}
defer func() {
fmt.Println(s) // что будет выведено?
}()
s[0] = 4
s[1] = 5
s[2] = 6
// Вывод: [4 5 6]
}

Здесь замыкание захватывает переменную s по ссылке (точнее, слайс — это уже ссылочный тип, и замыкание захватывает переменную-заголовок слайса). К моменту выполнения defer слайс уже изменён, поэтому выводится [4 5 6].

Более коварный пример с циклом:

func main() {
for i := 0; i < 3; i++ {
defer func() {
fmt.Print(i, " ")
}()
}
// Вывод: 3 3 3 — а не 0 1 2!
}

Все три замыкания захватывают одну и ту же переменную i. К моменту выполнения defer (после завершения цикла) i == 3.

Способы исправления:

1. Передача значения как аргумент анонимной функции:

func main() {
for i := 0; i < 3; i++ {
defer func(val int) {
fmt.Print(val, " ")
}(i) // значение i копируется в val на каждой итерации
}
// Вывод: 2 1 0 (в обратном порядке из-за LIFO)
}

2. Создание локальной переменной внутри цикла:

func main() {
for i := 0; i < 3; i++ {
v := i // новая переменная на каждой итерации
defer func() {
fmt.Print(v, " ")
}()
}
// Вывод: 2 1 0
}

3. Для слайса — создание копии перед defer:

func main() {
s := []int{1, 2, 3}
defer func(copy []int) {
fmt.Println(copy) // [1 2 3] — значение на момент вызова defer
}(append([]int(nil), s...)) // копия слайса
s[0] = 4
s[1] = 5
s[2] = 6
}

Общий принцип:

Замыкания в Go захватывают переменные по ссылке, а не по значению. Если переменная изменяется после создания замыкания, замыкание увидит последнее значение. Чтобы зафиксировать значение на момент создания замыкания, нужно либо передать его как аргумент функции, либо создать локальную копию переменной.

Ответ собеседника верен в целом, однако формулировка ответа немного запутана — в примере со слайсом [1,2,3] замыкание как раз выведет [4,5,6] (текущее значение на момент выполнения defer), а не [1,2,3]. Исправление нужно как раз для того, чтобы получить значение на момент вызова defer, а не «актуальное» значение.

Вопрос 17. Что такое замыкание в Go?

Таймкод: 00:22:00

Ответ собеседника: Неполный. Кандидат затруднился с точной формулировкой, но попытался объяснить, что замыкание — это функция, которая захватывает и сохраняет ссылку на внешние переменные, даже если они уже вышли из области видимости.

Правильный ответ:

Замыкание (closure) — это функция-значение, которая ссылается на переменные из внешней области видимости (объемлющей функции). Замыкание «захватывает» эти переменные, и они продолжают существовать столько, сколько существует само замыкание, даже после завершения функции, в которой они были объявлены.

Простой пример:

func counter() func() int {
count := 0 // переменная из внешней области
return func() int {
count++ // замыкание захватывает count
return count
}
}

func main() {
c := counter()
fmt.Println(c()) // 1
fmt.Println(c()) // 2
fmt.Println(c()) // 3
// count продолжает жить, потому что на него ссылается замыкание
}

Переменная count выделена в куче (heap), а не в стеке, потому что на неё ссылается возвращаемое замыкание. Это пример escape analysis — компилятор Go определяет, что переменная «убегает» из текущего стекового фрейма.

Замыкание захватывает переменную по ссылке:

func main() {
x := 10
fn := func() {
fmt.Println(x) // захватывает x по ссылке
}
x = 20
fn() // выведет 20, а не 10
}

Практические применения:

1. Фабрики функций:

func multiplier(factor int) func(int) int {
return func(x int) int {
return x * factor
}
}

double := multiplier(2)
triple := multiplier(3)
fmt.Println(double(5)) // 10
fmt.Println(triple(5)) // 15

2. Обработчики с контекстом:

func withTimeout(timeout time.Duration) func() {
start := time.Now()
return func() {
elapsed := time.Since(start)
if elapsed > timeout {
log.Printf("exceeded timeout: %v", elapsed)
}
}
}

3. Сортировка с кастомным компаратором:

sort.Slice(users, func(i, j int) bool {
return users[i].Age < users[j].Age
})

4. Middleware в HTTP-обработчиках:

func authMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("Authorization")
if token == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
next(w, r)
}
}

Ключевые моменты:

  • Замыкания захватывают переменные по ссылке, а не по значению.
  • Захваченные переменные живут в куче, а в стеке — только указатели на них.
  • В циклах все замыкания захватывают одну и ту же переменную итерации, что приводит к неожиданному поведению (см. предыдущий вопрос).
  • Замыкания — это основа функционального программирования в Go и широко используются в стандартной библиотеке.

Ответ собеседника в целом верен по сути, но был неуверенным и неполным — не хватало примеров и понимания того, что захват происходит именно по ссылке.

Вопрос 18. Что такое горутины в Go? Чем они отличаются от потоков ОС? Сколько горутин можно создать?

Таймкод: 00:23:05

Ответ собеседника: Правильный. Горутины — легковесные потоки, реализованные поверх потоков ОС. Они дешевле в создании и управлении, так как управляются шедулером Go, а не ОС. Количество одновременно исполняющихся горутин зависит от числа ядер. Общее количество горутин ограничено только доступной памятью — программа упадёт при исчерпании памяти.

Правильный ответ:

Горутина — это легковесный поток выполнения, управляемый рантаймом Go (Go scheduler), а не операционной системой. Запускается ключевым словом go:

go func() {
fmt.Println("hello from goroutine")
}()

Отличия от потоков ОС:

ХарактеристикаГорутинаПоток ОС
Размер стекаНачинается с ~2-8 КБ, растёт динамическиОбычно 1-8 МБ (фиксирован)
Создание~200 нс~10-100 мкс
Переключение контекстаКооперативное (user-space), дешёвоеПревентивное (kernel-space), дорогое
УправлениеGo scheduler (M:N модель)Ядро ОС (1:1 модель)
Максимальное количествоСотни тысяч — миллионыТысячи (ограничено памятью и лимитами ОС)

M:N модель планирования

Go scheduler использует модель M:N: M горутин распределяются по N потокам ОС (где N по умолчанию равно GOMAXPROCS, обычно числу ядер CPU).

runtime.GOMAXPROCS(4) // ограничить число потоков ОС до 4

Три ключевые сущности планировщика:

  • G (Goroutine) — горутина со своим стеком и состоянием.
  • M (Machine) — поток ОС, на котором исполняются горутины.
  • P (Processor) — логический процессор, владеющий локальной очередью горутин. Количество P равно GOMAXPROCS.

Сколько горутин можно создать?

Теоретически — сотни тысяч или даже миллионы. Ограничение — доступная память. Каждая горутина начинается со стека ~2-8 КБ (в зависимости от версии Go), и стек растёт по мере необходимости (до ~1 ГБ максимум).

// Пример: создание 100 000 горутин — абсолютно нормально
for i := 0; i < 100_000; i++ {
go func(id int) {
// работа
}(i)
}

Кооперативная многозадачность с вытеснением:

До Go 1.13 горутины переключались только в точках кооперативной уступки (channel operations, syscalls, function calls). Начиная с Go 1.14, используется вытесняющая многозадачность (preemptive scheduling) на основе сигналов — горутина не может монополизировать поток дольше ~10 мс.

Практические рекомендации:

  • Не создавайте неограниченное количество горутин — используйте worker pools или semaphore-паттерн для ограничения параллелизма.
  • Используйте sync.WaitGroup для ожидания завершения горутин.
  • Всегда передавайте переменные цикла как аргументы горутине, а не захватывайте замыканием.
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
// работа с id
}(i)
}
wg.Wait()

Ответ собеседника корректен — он верно описал легковесность горутин, управление шедулером Go и ограничение памятью.

Вопрос 19. Что выведет программа с конкурентным чтением из мапы без синхронизации? В чём опасность параллельной записи в мапу?

Таймкод: 00:26:03

Ответ собеседника: Правильный. Программа скомпилируется и запустится без ошибок, но нет гарантии порядка вывода — горутины выполняются в случайном порядке. Параллельная запись в мапу опасна неопределённым состоянием программы — Go обнаруживает конкурентную запись в мапу и выбрасывает панику. Для синхронизации нужно использовать мьютексы.

Правильный ответ:

Конкурентный доступ к мапе

Мапы в Go не потокобезопасны. Это одно из самых опасных мест в Go, потому что компилятор не предупреждает об этом — проблема проявляется только в runtime.

Что происходит при конкурентной записи:

m := make(map[string]int)

// Запуск двух горутин, пишущих в одну мапу
go func() {
for i := 0; i < 1000; i++ {
m["key"] = i
}
}()
go func() {
for i := 0; i < 1000; i++ {
m["key"] = i
}
}()

Runtime Go обнаруживает конкурентную запись и вызывает fatal error (не panic, а именно fatal — его нельзя перехватить через recover):

fatal error: concurrent map writes

Если одна горутина пишет, а другая читает — тоже fatal error:

fatal error: concurrent map read and map write

Почему это опасно (даже без обнаружения runtime):

Внутренняя структура мапы (bакеты, tophash, overflow-цепочки) может быть в неконсистентном состоянии при параллельной записи. Это может привести к:

  • Потере данных
  • Повреждению внутренних структур
  • Бесконечным циклам при итерации
  • Паникам в самых неожиданных местах

Способы синхронизации:

1. sync.Mutex / sync.RWMutex:

type SafeMap struct {
mu sync.RWMutex
m map[string]int
}

func (s *SafeMap) Set(key string, val int) {
s.mu.Lock()
defer s.mu.Unlock()
s.m[key] = val
}

func (s *SafeMap) Get(key string) (int, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
val, ok := s.m[key]
return val, ok
}

RWMutex предпочтительнее, когда много чтений и мало записей — несколько читателей могут работать параллельно.

2. sync.Map:

Специализированная потокобезопасная мапа из стандартной библиотеки, оптимизированная для двух сценариев:

  • Когда запись происходит редко, а чтение часто (кэши)
  • Когда разные горутины работают с непересекающимися наборами ключей
var m sync.Map

m.Store("key", 42)
val, ok := m.Load("key")
m.Delete("key")
m.Range(func(key, value any) bool {
fmt.Println(key, value)
return true // продолжить итерацию
})

3. Каналы (share by communicating):

type Request struct {
Key string
Value int
Resp chan int
}

func mapWorker(requests chan Request) {
m := make(map[string]int)
for req := range requests {
if req.Value == 0 {
req.Resp <- m[req.Key] // чтение
} else {
m[req.Key] = req.Value // запись
req.Resp <- 0
}
}
}

Когда что использовать:

  • Mutex — общий случай, когда нужна полная контролируемость.
  • sync.Map — кэши, редкие записи, непересекающиеся ключи.
  • Каналы — когда хотите следовать идиоме «share by communicating».

Обнаружение гонок данных:

Флаг -race компилятора Go добавляет runtime-проверки и обнаруживает гонки данных:

go run -race main.go
go test -race ./...

Всегда используйте -race при тестировании конкурентного кода.

Ответ собеседника корректен — он верно описал неопределённость порядка выполнения, панику при конкурентной записи и необходимость мьютексов.

Вопрос 20. Практическая задача: реализовать функцию, которая принимает несколько каналов и объединяет все записи из них в один выходной канал (fan-in pattern) с корректным закрытием выходного канала после завершения всех входных.

Таймкод: 00:28:06

Ответ собеседника: Правильный. Кандидат реализовал функцию fan-in: создал выходной канал, запустил горутину для каждого входного канала, читает из него и пишет в выходной канал. Использовал WaitGroup для отслеживания завершения всех горутин и закрытия выходного канала после их завершения. Реализация корректная, хотя потребовалась подсказка о необходимости закрытия выходного канала.

Правильный ответ:

Fan-in pattern — один из ключевых паттернов конкурентного программирования в Go. Он объединяет несколько входных каналов в один выходной.

Реализация:

package main

import (
"fmt"
"sync"
)

// FanIn объединяет несколько входных каналов в один выходной.
// Выходной канал закрывается, когда все входные каналы закрыты.
func FanIn[T any](channels ...<-chan T) <-chan T {
out := make(chan T)
var wg sync.WaitGroup

// Для каждого входного канала запускаем горутину-читателя
for _, ch := range channels {
wg.Add(1)
go func(c <-chan T) {
defer wg.Done()
for val := range c {
out <- val
}
}(ch)
}

// Горутина, закрывающая выходной канал после завершения всех читателей
go func() {
wg.Wait()
close(out)
}()

return out
}

Пример использования:

func main() {
ch1 := make(chan int)
ch2 := make(chan int)
ch3 := make(chan int)

// Отправляем данные в каналы
go func() {
defer close(ch1)
for i := 1; i <= 3; i++ {
ch1 <- i
}
}()
go func() {
defer close(ch2)
for i := 10; i <= 30; i += 10 {
ch2 <- i
}
}()
go func() {
defer close(ch3)
for i := 100; i <= 300; i += 100 {
ch3 <- i
}
}()

// Объединяем и читаем
merged := FanIn(ch1, ch2, ch3)
for val := range merged {
fmt.Println(val)
}
// Вывод: 1, 10, 100, 2, 20, 200, 3, 30, 300 (порядок не гарантирован)
}

Ключевые моменты реализации:

1. WaitGroup для отслеживания завершения:

Каждая горутина-читатель вызывает wg.Done() при завершении (когда входной канал закрыт). Отдельная горутина ждёт wg.Wait() и закрывает выходной канал.

2. Закрытие выходного канала в отдельной горутине:

Это важно — если бы мы делали wg.Wait(); close(out) в основной горутине FanIn, функция бы заблокировалась, потому что wg.Wait() ждёт завершения читателей, а читатели ждут, пока кто-то читает из out (небуферизованный канал).

3. Передача канала как аргумента горутине:

go func(c <-chan T) { ... }(ch) — это предотвращает проблему захвата переменной цикла замыканием.

4. Использование дженериков:

С Go 1.18 функция может быть параметризована типом T any, что делает её универсальной.

Вариант с контекстом для отмены:

func FanInWithContext[T any](ctx context.Context, channels ...<-chan T) <-chan T {
out := make(chan T)
var wg sync.WaitGroup

for _, ch := range channels {
wg.Add(1)
go func(c <-chan T) {
defer wg.Done()
for {
select {
case val, ok := <-c:
if !ok {
return
}
select {
case out <- val:
case <-ctx.Done():
return
}
case <-ctx.Done():
return
}
}
}(ch)
}

go func() {
wg.Wait()
close(out)
}()

return out
}

Fan-in vs Fan-out:

  • Fan-out — один канал распределяется по нескольким горутинам-обработчикам (worker pool).
  • Fan-in — несколько каналов объединяются в один.

Эти паттерны часто используются вместе в конвейерах (pipelines).

Ответ собеседника корректен — он реализовал рабочее решение с WaitGroup и правильным закрытием канала.

Вопрос 21. Что такое канал в Go и как он устроен под капотом?

Таймкод: 00:30:49

Ответ собеседника: Правильный. Каналы бывают буферизированные и небуферизированные. Небуферизированный канал имеет ёмкость 1 элемент. Буферизированный использует циклическую очередь. Под капотом канал имеет мьютекс для блокировки при записи и чтении, а также очереди для ожидающих горутин (отправителей и получателей). Когда ресивер читает данные, он освобождает место и может разблокировать ожидающую горутину-отправителя.

Правильный ответ:

Канал (channel) — это типизированная труба для обмена данными между горутинами. Каналы обеспечивают безопасную коммуникацию и синхронизацию без явных блокировок.

Создание каналов:

ch := make(chan int) // небуферизированный канал
ch := make(chan int, 10) // буферизированный канал ёмкостью 10

Внутреннее устройство (runtime.hchan)

Под капотом канал — это структура runtime.hchan:

type hchan struct {
qcount uint // текущее количество элементов в буфере
dataqsiz uint // размер буфера (ёмкость)
buf unsafe.Pointer // указатель на циклический буфер (ring buffer)
sendx uint // индекс для следующей записи
recvx uint // индекс для следующего чтения
recvq waitq // очередь горутин, ожидающих чтения
sendq waitq // очередь горутин, ожидающих записи
lock mutex // мьютекс для защиты всех полей
}

Ключевые компоненты:

  • buf — кольцевой буфер фиксированного размера. Для небуферизированных каналов dataqsiz = 0.
  • sendx / recvx — индексы в кольцевом буфере для записи и чтения.
  • recvq — связанный список горутин, заблокированных на чтение (ждут данных).
  • sendq — связанный список горутин, заблокированных на запись (ждут свободного места или получателя).
  • lock — мьютекс, защищающий все операции с каналом.

Поведение небуферизированного канала:

Небуферизированный канал (make(chan T)) обеспечивает синхронизацию «рукопожатие» (rendezvous):

  • Запись блокируется, пока кто-то не прочитает.
  • Чтение блокируется, пока кто-то не запишет.
ch := make(chan int)

go func() {
ch <- 42 // заблокируется, пока main не прочитает
}()

val := <-ch // заблокируется, пока горутина не запишет

Поведение буферизированного канала:

Буферизированный канал (make(chan T, n)) имеет внутренний буфер:

  • Запись блокируется, только когда буфер полон.
  • Чтение блокируется, только когда буфер пуст.
ch := make(chan int, 2)
ch <- 1 // не блокируется
ch <- 2 // не блокируется
ch <- 3 // ЗАБЛОКИРУЕТСЯ — буфер полон

Закрытие канала:

close(ch) // только отправитель должен закрывать канал
  • Чтение из закрытого канала возвращает zero value и ok = false.
  • Запись в закрытый канал вызывает panic.
  • Закрытие уже закрытого канала вызывает panic.
val, ok := <-ch // ok == false, если канал закрыт и пуст

Операция select:

select {
case v := <-ch1:
fmt.Println("ch1:", v)
case v := <-ch2:
fmt.Println("ch2:", v)
case ch3 <- 42:
fmt.Println("sent to ch3")
case <-time.After(time.Second):
fmt.Println("timeout")
default:
fmt.Println("no channel ready")
}

Направленность каналов:

func send(ch chan<- int) { /* только запись */ }
func recv(ch <-chan int) { /* только чтение */ }

Это обеспечивает безопасность на уровне компилятора — нельзя случайно прочитать из канала, который предназначен только для записи.

Ответ собеседника в целом корректен, но стоит уточнить: небуферизированный канал имеет ёмкость 0, а не 1 — данные передаются напрямую от отправителя к получателю без промежуточного хранения.

Вопрос 22. Какие примитивы синхронизации в Go вы знаете? Расскажите про WaitGroup, RWMutex и другие.

Таймкод: 00:40:35

Ответ собеседника: Правильный. Кандидат назвал мьютексы, WaitGroup и RWMutex. Пояснил, что RWMutex позволяет множеству горутин читать одновременно, но запись эксклюзивна. WaitGroup — это счётчик, который ждёт завершения определённого количества горутин через методы Add и Done. Также упомянул Condition и семафоры как дополнительные примитивы.

Правильный ответ:

Основные примитивы синхронизации в Go:

1. sync.Mutex — взаимоисключающая блокировка

Самый базовый примитив. Гарантирует, что только одна горутина имеет доступ к критической секции:

var mu sync.Mutex
var counter int

func increment() {
mu.Lock()
defer mu.Unlock()
counter++
}

2. sync.RWMutex — блокировка чтение/запись

Оптимизация для сценариев «много чтений, мало записей»:

var mu sync.RWMutex
var cache map[string]string

func get(key string) string {
mu.RLock() // множество читателей одновременно
defer mu.RUnlock()
return cache[key]
}

func set(key, val string) {
mu.Lock() // эксклюзивный доступ для записи
defer mu.Unlock()
cache[key] = val
}
  • Несколько горутин могут удерживать RLock одновременно.
  • Lock (запись) ждёт, пока все RLock будут освобождены, и блокирует новые RLock.
  • Если писателей много, читатели могут голодать (writer-preferring).

3. sync.WaitGroup — ожидание завершения горутин

Счётчик горутин, блокирующий до тех пор, пока счётчик не станет нулём:

var wg sync.WaitGroup

for i := 0; i < 10; i++ {
wg.Add(1) // увеличить счётчик
go func(id int) {
defer wg.Done() // уменьшить счётчик при завершении
// работа
}(i)
}

wg.Wait() // заблокироваться, пока счётчик != 0

Правила использования WaitGroup:

  • Add должен быть вызван до запуска горутины (или внутри, но до того, как Wait может вернуться).
  • Done вызывается внутри горутины при завершении.
  • WaitGroup нельзя копировать после первого использования — передавайте по указателю.

4. sync.Cond — условная переменная

Позволяет горутинам ждать наступления определённого условия:

var mu sync.Mutex
cond := sync.NewCond(&mu)
ready := false

// Горутина-ожидатель
go func() {
mu.Lock()
for !ready { // всегда проверять в цикле!
cond.Wait() // атомарно разблокирует mu и заснёт
}
// ready == true, можно продолжать
mu.Unlock()
}()

// Горутина-сигнализатор
mu.Lock()
ready = true
cond.Signal() // разбудить одну ожидающую горутину
// cond.Broadcast() // разбудить все ожидающие горутины
mu.Unlock()

Важно: cond.Wait() всегда вызывается внутри цикла for !condition, потому что возможны spurious wakeups (ложные пробуждения).

5. sync.Once — однократное выполнение

Гарантирует, что функция будет выполнена ровно один раз, независимо от количества горутин:

var once sync.Once
var instance *Singleton

func GetInstance() *Singleton {
once.Do(func() {
instance = &Singleton{}
})
return instance
}

6. sync.Map — потокобезопасная мапа

Обсуждалась ранее. Оптимизирована для кэшей и непересекающихся ключей.

7. sync/atomic — атомарные операции

Блокировочные атомарные операции для базовых типов:

var counter atomic.Int64

counter.Add(1)
val := counter.Load()
counter.Store(42)
old := counter.Swap(100) // атомарно заменить и вернуть старое значение

8. Каналы как примитивы синхронизации

Каналы — не просто передача данных, но и мощный инструмент синхронизации:

// Семафор через буферизированный канал
sem := make(chan struct{}, 10) // максимум 10 параллельных горутин

func limitedWork() {
sem <- struct{}{} // захват
defer func() { <-sem }() // освобождение
// работа
}

// Сигнал завершения
done := make(chan struct{})
go func() {
// работа
close(done)
}()
<-done // ожидание завершения

9. context.Context — отмена и таймауты

Хотя это не примитив синхронизации в классическом смысле, context — ключевой механизм управления жизненным циклом горутин:

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

go func() {
select {
case <-ctx.Done():
return // отмена
case result := <-workCh:
// обработка
}
}()

Когда что использовать:

ПримитивСценарий
MutexЗащита общего состояния
RWMutexМного чтений, мало записей
WaitGroupОжидание завершения группы горутин
CondОжидание наступления условия
OnceЛенивая инициализация, singleton
atomicПростые счётчики без блокировок
ChannelКоммуникация между горутинами, семафоры
ContextОтмена, таймауты, передача значений

Ответ собеседника корректен — он назвал основные примитивы и правильно объяснил их назначение.