Перейти к содержанию

Теория для собеса — карточки

Формат вопрос → раскрывающийся ответ. Это типовые «открывалки» для собеса по Go basics. Не зубри ответы дословно, но проговорить вслух уметь надо каждый.

Maps

M-1. Что такое мапа в Go и как она работает?

Ответ

Мапа — это hashmap: набор bucket'ов, каждый хранит до 8 пар (key, value) плюс ссылку на overflow bucket, если переполнился.

Доступ к ключу:

  1. Считается hash от ключа.
  2. Низкие биты hash определяют номер bucket'а.
  3. Внутри 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?

Ответ

Нет:

m := map[string]int{"one": 1}
p := &m["one"] // compile error: cannot take the address of m["one"]

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

Если нужен «адрес» — храни указатели как values:

m := map[string]*Item{}
m["one"] = &Item{V: 1}
m["one"].V = 42 // ок: меняем поле через указатель

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 элементов, лучше:

m := make(map[string]int, 10000)

чем 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):

  1. Если len(s) < cap(s) — пишет в конец backing-массива, возвращает slice с увеличенным len. Без аллокации.
  2. Если 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 := make([]int, 0, 100)
for i := 0; i < 100; i++ {
    s = append(s, i) // ни одной переаллокации
}

Это стандартная оптимизация, когда заранее знаешь верхнюю границу.

S-5. Какие оптимизации работы со слайсами?

Ответ
  1. Pre-allocate: make([]T, 0, expected) если знаешь размер.
  2. Reuse slices: s = s[:0] — обнуляет длину, оставляет backing. Используется в горячих циклах (особенно с пулами объектов).
  3. Избегать аллокации в горячем пути: append к большим слайсам может внезапно скопировать гигабайты.
  4. Be careful with sub-slices в долгоживущих контекстах: маленький slice держит весь backing, GC не освобождает.
  5. Для больших batch'ей часто используют bytes.Buffer или strings.Builder вместо ручного append к []byte.

S-6. Какая сложность поиска в slice/массиве?

Ответ

Линейный поиск (просто перебрать) — O(n).

Если slice отсортированsort.SearchO(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:

s := "Hello мир"
for i, r := range s {
    fmt.Printf("byte %d: %c (rune %U)\n", i, r, r)
}

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, методы прикрепляются снаружи.

type User struct {
    ID   int64
    Name string
    Age  int
}

Структура может быть пустой (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 (строчная) — нет.

📝 Подумай

  1. Почему в Go нельзя взять адрес элемента map, но можно — элемента slice?
  2. Можно ли использовать chan как ключ в map? А func()? А []int?
Ответ
  1. Slice имеет фиксированный backing-массив (внутри одного слайса). Адрес &s[0] стабилен, пока ты сам не сделал append с переаллокацией. Map же может переехать на больший массив bucket'ов (эвакуация) при любой записи — компилятор не может гарантировать, что адрес останется валиден, поэтому просто запрещает.
  2. chan — да (чан-значение это указатель внутри, comparable). func() — нет, функции не comparable (compile error). []int — нет, slice не comparable. Если очень надо — конвертируй slice в string или массив фиксированной длины и используй как ключ.