Теория для собеса — карточки¶
Формат вопрос → раскрывающийся ответ. Это типовые «открывалки» для собеса по Go basics. Не зубри ответы дословно, но проговорить вслух уметь надо каждый.
Maps¶
M-1. Что такое мапа в Go и как она работает?¶
Ответ
Мапа — это hashmap: набор bucket'ов, каждый хранит до 8 пар
(key, value) плюс ссылку на overflow bucket, если переполнился.
Доступ к ключу:
- Считается hash от ключа.
- Низкие биты hash определяют номер bucket'а.
- Внутри bucket — линейный поиск по top-hash и сравнение ключей.
Амортизированно — O(1) на чтение/запись/удаление. В худшем случае при плохой hash-функции — O(n), но Go использует hash с random seed, чтобы атаковать его извне было сложно.
M-2. Какие типы можно использовать как ключ?¶
Ответ
Только comparable types — те, для которых определён оператор ==:
- примитивы (int, string, bool, float — да, но осторожно с NaN);
- указатели и channel;
- структуры из comparable полей;
- массивы (фиксированной длины) из comparable элементов;
- интерфейсы (но runtime panic, если конкретный тип не comparable).
НЕ годятся: slice, map, function — cannot use ... as map key type.
M-3. Что такое эвакуация в map?¶
Ответ
Когда мапа разрастается (load factor превышает порог — в текущей реализации
это >6.5 элементов на bucket в среднем), runtime аллоцирует новый
массив bucket'ов в 2 раза больше и начинает incremental rehash:
при каждой операции set/delete Go переносит один-два старых bucket'а в
новый. Это размазывает стоимость рехеша по операциям, а не делает разовый
тяжёлый «STW».
Поэтому, кстати, нельзя взять адрес элемента: между двумя обращениями bucket мог переехать.
M-4. Что произойдёт при concurrent write в map?¶
Ответ
fatal error: concurrent map writes — это fatal, не recoverable
panic. Программа падает.
Detector работает упрощённо: при записи Go проверяет специальный flag «есть ли уже writer». Если есть — falla. Это сделано намеренно жёстко, чтобы баги не «прятались» в проде.
Concurrent read + write — undefined behaviour (часто тоже падает). Concurrent reads (без writers) — безопасны.
M-5. sync.Map vs map + Mutex?¶
Ответ
sync.Map устроен через два внутренних map'а: read map (read-only,
атомарный) и dirty map (под мьютексом). Чтение в первую очередь идёт
в read map без блокировки → быстро при read-heavy сценарии.
Когда sync.Map лучше:
- read-mostly: ключи редко меняются, читаются часто;
- набор ключей стабилен (например, кеш конфигов).
Когда map + sync.Mutex (или sync.RWMutex) лучше:
- write-heavy;
- типовые ключи — int (sync.Map использует interface{}, лишние allocations);
- нужен generics-typed API без
interface{}-обёрток.
Дефолт — map + Mutex. sync.Map — точечно, когда профайлер показал, что
блокировка горячая.
M-6. Какая сложность операций?¶
Ответ
- чтение, запись, удаление — амортизированно O(1);
- len(map) — O(1) (счётчик хранится в header'е);
- итерация — O(n), порядок случайный (специально randomized каждый range).
«Амортизированно» — потому что в момент роста с эвакуацией одна операция может быть медленнее, но в среднем — постоянная.
M-7. Можно ли взять адрес элемента map?¶
Ответ
Нет:
Причина — эвакуация. Bucket может переехать в новый массив при росте, адреса станут невалидными. Поэтому компилятор просто запрещает.
Если нужен «адрес» — храни указатели как values:
M-8. Что такое nil map и что с ней можно?¶
Ответ
var m map[string]int // m == nil
v, ok := m["x"] // ok=false, v=0 — НЕ panic
len(m) // 0 — НЕ panic
for k := range m {} // 0 итераций — НЕ panic
m["x"] = 1 // panic: assignment to entry in nil map
Читать из nil map — безопасно, писать — нет. Это асимметрия, на которой
ловятся новички. Перед записью либо make, либо литерал.
M-9. Зачем make-подсказка по capacity?¶
Ответ
make(map[K]V, hint) сообщает runtime ожидаемое число элементов, чтобы
он сразу аллоцировал нужное количество bucket'ов. Это избавляет от
повторных эвакуаций при росте.
Если знаешь, что в мапу пойдёт ~10 000 элементов, лучше:
чем make(map[string]int) и потом 10–11 эвакуаций по мере роста.
M-10. Как делать упорядоченную итерацию?¶
Ответ
Range по мапе — намеренно случайный, чтобы код не полагался на порядок. Если нужен порядок — собери ключи в slice, отсортируй, итерируй по нему:
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
fmt.Println(k, m[k])
}
Альтернатива — использовать структуру вроде *orderedmap.OrderedMap
из сторонних либ, но в стандартной либе её нет (Go 1.23 добавил
iter-based API, но не сам ordered map).
Slices / Arrays¶
S-1. Что такое слайс? Чем отличается от массива?¶
Ответ
Массив в Go — это значение фиксированной длины: [5]int. Длина —
часть типа. При присваивании копируется целиком.
Слайс — это header {ptr, len, cap}, ссылающийся на сегмент
backing-массива. Длина — не часть типа: []int принимает любую
длину. При присваивании копируется только header (3 машинных слова),
данные общие.
На практике массивы используются редко (хеши, IP-адреса фиксированной длины). 99% работы — со слайсами.
S-2. Как работает append?¶
Ответ
append(s, x):
- Если
len(s) < cap(s)— пишет в конец backing-массива, возвращает slice с увеличеннымlen. Без аллокации. - Если
len(s) == cap(s)— runtime аллоцирует новый массив (обычно cap × 2 для маленьких, ~× 1.25 для больших), копирует данные, возвращает новый slice с новым ptr.
Поэтому результат append всегда нужно присваивать обратно:
s = append(s, x) — иначе если backing был переаллоцирован, ты
потеряешь изменения.
S-3. Что такое shared backing array?¶
Ответ
Несколько слайсов могут указывать на один и тот же backing-массив:
a := []int{1, 2, 3, 4, 5}
b := a[:3] // ptr тот же, len=3, cap=5
b[0] = 99
fmt.Println(a) // [99 2 3 4 5] — изменилось и в a
Это самый частый источник багов на слайсах. Решения:
- явная копия
dst := make([]T, len(src)); copy(dst, src); - full slice expression
b := a[:3:3]— обрезает cap, чтобы append гарантированно сделал новый массив.
S-4. Как создать слайс с нулевой длиной, но capacity?¶
Ответ
make([]T, 0, N) — длина 0, но capacity N. Дальше можно делать N
append'ов без аллокаций.
Это стандартная оптимизация, когда заранее знаешь верхнюю границу.
S-5. Какие оптимизации работы со слайсами?¶
Ответ
- Pre-allocate:
make([]T, 0, expected)если знаешь размер. - Reuse slices:
s = s[:0]— обнуляет длину, оставляет backing. Используется в горячих циклах (особенно с пулами объектов). - Избегать аллокации в горячем пути:
appendк большим слайсам может внезапно скопировать гигабайты. - Be careful with sub-slices в долгоживущих контекстах: маленький slice держит весь backing, GC не освобождает.
- Для больших batch'ей часто используют
bytes.Bufferилиstrings.Builderвместо ручного append к[]byte.
S-6. Какая сложность поиска в slice/массиве?¶
Ответ
Линейный поиск (просто перебрать) — O(n).
Если slice отсортирован — sort.Search — O(log n) через
бинарный поиск.
Если нужен поиск множества раз — лучше переложить в map (O(1) per
lookup) или построить set: seen := map[T]struct{}{}.
Strings¶
Str-1. Что такое string в Go?¶
Ответ
string — это immutable последовательность байт. Под капотом
struct { ptr *byte; len int }. Все строковые операции, которые
«изменяют» строку, на самом деле создают новую.
string — UTF-8 encoded, но runtime не валидирует это: можно положить
туда любые байты. Если хочешь работать с символами (rune'ами), нужно
конвертировать.
Str-2. Чему равна длина строки?¶
Ответ
len(s) возвращает длину в байтах, не в символах:
s := "Привет"
len(s) // 12 (каждая кириллическая буква — 2 байта в UTF-8)
utf8.RuneCountInString(s) // 6 — реальное число символов
На собесе любят спросить «сколько байт в Hello, мир» — ответ зависит
от того, что считать. ASCII часть — 1 байт/символ, кириллица — 2 байта/символ.
Str-3. Как итерироваться по символам?¶
Ответ
Через for range:
i — байтовое смещение начала символа, r — это rune (int32),
декодированный из UTF-8. Если строка содержит невалидный UTF-8 —
вместо него подставляется RuneError (U+FFFD).
Через s[i] — это байт, не символ. Для работы с символами всегда
либо range, либо явное []rune(s).
Str-4. Чем []byte отличается от string?¶
Ответ
string— immutable;[]byte— mutable.- Конвертация
string ↔ []byteобычно копирует данные (О(n)). Компилятор делает оптимизации в редких случаях (например, дляstring(b)на ключ map). - Для частых модификаций — используй
bytes.Bufferилиstrings.Builder. Последний даётstring()без копирования благодаря internal-знанию о backing массиве.
Structs¶
St-1. Что такое структура?¶
Ответ
Структура — это композитный тип данных, объединяющий несколько
полей под одним именем. В отличие от классов: нет наследования, нет
this/self, методы прикрепляются снаружи.
Структура может быть пустой (struct{}) — занимает 0 байт, удобна
как «маркер» в каналах (chan struct{}).
St-2. Имеет ли значение порядок полей? Что такое выравнивание?¶
Ответ
Да, имеет. Поля выравниваются по своему размеру (alignment). Между полями могут быть padding-байты, чтобы каждое начиналось с адреса, кратного его выравниванию.
Пример:
type Bad struct {
A bool // 1 байт
B int64 // 8 байт (нужно выравнивание 8 → 7 байт padding после A)
C bool // 1 байт (+ 7 байт padding до конца, чтобы размер был кратен 8)
}
// Размер: 1 + 7 + 8 + 1 + 7 = 24 байта
type Good struct {
B int64 // 8
A bool // 1
C bool // 1
// 6 байт padding в конце
}
// Размер: 8 + 1 + 1 + 6 = 16 байт
Сортируй поля от больших к маленьким, чтобы минимизировать padding.
Реальный пример из задач — some1 (24 байт) vs some2 (32 байт)
при перестановке полей.
Узнать размер: unsafe.Sizeof(value).
St-3. Как сравнить структуры?¶
Ответ
Структуру можно сравнить через ==, только если все её поля
comparable:
type P struct { X, Y int }
p1 := P{1, 2}; p2 := P{1, 2}
fmt.Println(p1 == p2) // true
type WithSlice struct { S []int }
a := WithSlice{}; b := WithSlice{}
// a == b // compile error
Для несравнимых — reflect.DeepEqual (медленнее, но универсально) или
написать свой Equal.
ООП¶
O-1. Как устроено ООП в Go? Есть ли наследование?¶
Ответ
Классов нет. Есть структуры + методы + интерфейсы.
Наследования нет. Есть embedding (композиция через вложение):
type Animal struct{ Name string }
func (a Animal) Greet() { fmt.Println("Hi, I'm", a.Name) }
type Dog struct {
Animal // embedded — методы Animal доступны напрямую
Breed string
}
d := Dog{Animal: Animal{Name: "Rex"}, Breed: "Lab"}
d.Greet() // Hi, I'm Rex
Это не наследование: Dog не «является» Animal в смысле классов.
Это синтаксический сахар над dog.animal.Greet(). Можно переопределить
метод в Dog — будет вызван он, а Animal-версию вызовешь через
d.Animal.Greet().
O-2. Как делается полиморфизм?¶
Ответ
Через интерфейсы + duck-typing:
type Greeter interface {
Greet()
}
func sayHi(g Greeter) { g.Greet() }
sayHi(Dog{...}) // Dog имеет метод Greet → удовлетворяет Greeter
Тип неявно удовлетворяет интерфейсу, если у него есть нужные методы.
Не нужно писать Dog implements Greeter. Это и есть основная фишка
Go-полиморфизма.
Инкапсуляция — через case первого символа: Public (заглавная) видна
извне пакета, private (строчная) — нет.
📝 Подумай¶
- Почему в Go нельзя взять адрес элемента map, но можно — элемента slice?
- Можно ли использовать
chanкак ключ в map? Аfunc()? А[]int?
Ответ
- Slice имеет фиксированный backing-массив (внутри одного слайса).
Адрес
&s[0]стабилен, пока ты сам не сделал append с переаллокацией. Map же может переехать на больший массив bucket'ов (эвакуация) при любой записи — компилятор не может гарантировать, что адрес останется валиден, поэтому просто запрещает. chan— да (чан-значение это указатель внутри, comparable).func()— нет, функции не comparable (compile error).[]int— нет, slice не comparable. Если очень надо — конвертируй slice в string или массив фиксированной длины и используй как ключ.