Подготовка к собеседованию по Go

Вопросы на собеседовании Go-разработчика

Подборка вопросов по Go для backend-разработчиков, сгруппированная по темам и связанная с тем же каталогом, который используется в практике EngineerSpeak.

Начать ИИ-собеседование по GoКредитная карта не требуется. Доступна 1 бесплатная сессия.
Практика технических собеседований на английскомРежим, в котором не носители языка могут потренироваться проходить интервью.

Система типов

1Объясните, как в Go работают нулевые значения для встроенных и ссылкоподобных типов и почему они важны при объявлении переменных без явной инициализации.

В Go переменная, объявленная без явного инициализатора, автоматически инициализируется нулевым значением своего типа. Числовые типы получают 0, bool — false, string — "", а массивы и struct обнуляются поэлементно или по полям. Для pointer-подобных или ссылкоподобных типов, таких как pointers, slices, maps, channels, functions и interfaces, нулевым значением является nil. Это важно, потому что переменные Go и пропущенные поля struct начинают жизнь в детерминированном состоянии, а не содержат мусорные значения, и многие API спроектированы так, что нулевое значение является полезным значением по умолчанию, хотя некоторые nil-значения всё же требуют инициализации перед определёнными операциями.

package main

import "fmt"

type Config struct {
	Port    int
	Debug   bool
	Name    string
	Tags    []string
	Options map[string]string
}

func main() {
	var c Config
	fmt.Printf("port=%d debug=%v name=%q tags==nil:%v options==nil:%v\n",
		c.Port, c.Debug, c.Name, c.Tags == nil, c.Options == nil)
}
Попробовать ответить на вопрос AI-тренеру

2Как Go обрабатывает равенство структур и что происходит, если структура содержит несравнимые поля?

Значения структур в Go можно сравнивать с помощью `==` и `!=` только когда каждое поле структуры comparable. Равенство сравнивает соответствующие поля по правилам равенства каждого поля. Если структура содержит неcomparable-поле, например срез, map или функцию, тип структуры не comparable, и сравнение двух значений такого типа через `==` — ошибка компиляции. Для таких структур используйте пользовательскую логику сравнения или подходящий помощник глубокого равенства, особенно в тестах.

package main

import "fmt"

type UserID struct {
    Name string
    Age  int
}

type UserProfile struct {
    Name string
    Tags []string
}

func main() {
    a := UserID{"Ann", 30}
    b := UserID{"Ann", 30}
    fmt.Println(a == b)

    x := UserProfile{Name: "Ann", Tags: []string{"go"}}
    y := UserProfile{Name: "Ann", Tags: []string{"go"}}
    _, _ = x, y
    // fmt.Println(x == y) // compile error: struct containing []string cannot be compared
}
Попробовать ответить на вопрос AI-тренеру

3Объясните неизменяемость строк в Go и связь между string, []byte, bytes.Buffer и strings.Builder.

В Go `string` — это неизменяемая последовательность байтов, часто UTF-8 текст, но не обязательно валидный UTF-8. Строку нельзя изменить на месте; чтобы изменить содержимое, обычно конвертируют в `[]byte` для правок на уровне байтов или в `[]rune` для правок на уровне code point, а затем конвертируют обратно. Обычные преобразования между `string` и `[]byte` копируют данные и могут выделять память, поэтому повторные преобразования или повторная конкатенация в циклах могут быть дорогими. `strings.Builder` оптимизирован для эффективной сборки строк, а `bytes.Buffer` — изменяемый байтовый буфер, полезный для байтовых данных и I/O, и тоже может дать строку.

package main

import (
    "bytes"
    "fmt"
    "strings"
)

func main() {
    var sb strings.Builder
    sb.WriteString("hello")
    sb.WriteByte(' ')
    sb.WriteString("world")
    fmt.Println(sb.String())

    var buf bytes.Buffer
    buf.Write([]byte{0x48, 0x69})
    buf.WriteByte('!')
    fmt.Println(buf.String())

    s := "cat"
    b := []byte(s) // normally copies
    b[0] = 'b'
    fmt.Println(s, string(b))
}
Попробовать ответить на вопрос AI-тренеру

4Объясните, чем поведение nil отличается для указателей, срезов, map, каналов, функций и интерфейсов в Go.

В Go nil — это нулевое значение для указателей, срезов, map, каналов, функций и интерфейсов, но операции с такими nil-значениями зависят от типа. Nil-указатель можно сравнить с nil, но разыменование вызывает panic. У nil-среза length и capacity равны 0; по нему можно итерироваться и к нему можно делать append. Из nil-map можно читать и по ней можно range, но запись в неё вызывает panic. Отправка в nil-канал или получение из него блокируется навсегда, а закрытие nil-канала вызывает panic. Вызов nil-функции вызывает panic. Интерфейс равен nil только когда у него нет ни динамического типа, ни динамического значения; интерфейс, содержащий типизированное nil-значение (например, nil-указатель), сам по себе не равен nil.

package main

import "fmt"

type MyErr struct{}

func (*MyErr) Error() string { return "my error" }

func returnsTypedNil() error {
	var e *MyErr = nil
	return e
}

func main() {
	var p *int = nil
	var s []int = nil
	var m map[string]int = nil
	var err error = returnsTypedNil()

	fmt.Println(p == nil)
	fmt.Println(len(s), cap(s), s == nil)
	fmt.Println(m["missing"])
	fmt.Println(err == nil)
}
Попробовать ответить на вопрос AI-тренеру

5Что такое comparable-типы в Go и как правила сравнимости влияют на ключи map, равенство и ограничения в дженериках?

Comparable-типы в Go — это типы, значения которых можно сравнивать с помощью `==` и `!=`. Базовые типы, указатели, каналы, интерфейсы, а также структуры/массивы, у которых все поля или элементы comparable, являются comparable; срезы, map и функции не comparable, за исключением сравнения с `nil`. Ключи map должны быть comparable. Равенство следует правилам сравнения типа, а сравнение интерфейсов зависит от динамических конкретных значений; если сравниваемый интерфейс содержит неcomparable динамическое значение, сравнение вызывает panic. В дженериках предопределённое ограничение `comparable` позволяет параметрам типа сравниваться через `==`/`!=` и использоваться как ключи map.

package main

import "fmt"

type Point struct{ X, Y int }      // comparable
type Bag struct{ Items []string }  // not comparable because of slice field

func Contains[T comparable](xs []T, target T) bool {
    for _, x := range xs {
        if x == target {
            return true
        }
    }
    return false
}

func main() {
    p1, p2 := Point{1, 2}, Point{1, 2}
    fmt.Println(p1 == p2)

    m := map[Point]string{p1: "value"}
    fmt.Println(m[p2])

    fmt.Println(Contains([]string{"a", "b"}, "b"))

    var a any = []int{1}
    var b any = []int{1}
    _, _ = a, b
    // fmt.Println(a == b) // panic: comparing uncomparable type []int
}
Попробовать ответить на вопрос AI-тренеру

6Как Go представляет байты, руны и UTF-8 текст и почему len(s) может отличаться от числа видимых пользователю символов?

В Go `byte` — это псевдоним `uint8` и обозначает один «сырой» байт, а `rune` — псевдоним `int32` и обозначает Unicode code point. `string` — это read-only последовательность байтов, обычно UTF-8 текст, но может содержать произвольные байты. `len(s)` возвращает число байтов, а не рун или видимых пользователю символов. Индексация строки возвращает байт; range по строке декодирует UTF-8 и даёт байтовые индексы плюс руны. `len(s)` может отличаться от числа видимых символов, потому что UTF-8 может использовать несколько байтов на code point и потому что один видимый пользователю символ может состоять из нескольких code point, например combining marks или emoji-последовательности.

package main

import "fmt"

func main() {
    s := "é🙂"

    fmt.Println(len(s))         // bytes: é is 2 bytes, 🙂 is 4 bytes
    fmt.Println(len([]rune(s))) // Unicode code points

    fmt.Printf("first byte: %x\n", s[0])

    for i, r := range s {
        fmt.Printf("byte index %d: %q U+%04X\n", i, r, r)
    }
}
Попробовать ответить на вопрос AI-тренеру

Структуры данных

7Опишите разницу между массивами и срезами в Go, включая поведение length, capacity и нижележащего хранилища.

Массив в Go имеет фиксированную длину, которая является частью его типа, например [3]int; он хранит элементы непосредственно, а присваивание или передача массива копирует всё значение массива. Срез, например []int, — это небольшой дескриптор над нижележащим массивом: концептуально он содержит указатель на элементы, length и capacity. Length среза — число видимых элементов; capacity — сколько элементов можно использовать от начала среза до конца backing-массива. Срезы гибкие: reslicing меняет дескриптор, а append может повторно использовать тот же нижележащий массив, если позволяет capacity, или выделить новый, если нет.

package main

import "fmt"

func main() {
	a := [3]int{1, 2, 3}
	b := a
	b[0] = 99
	fmt.Println(a, b)

	s := []int{1, 2, 3}
	t := s
	t[0] = 99
	fmt.Println(s, t)
	fmt.Println(len(s), cap(s))
}
Попробовать ответить на вопрос AI-тренеру

8Как ведёт себя тип map в Go в отношении типов ключей, отсутствующих ключей, nil-map и порядка итерации?

Типы ключей map в Go должны быть comparable; срезы, map и функции нельзя напрямую использовать как ключи. Поиск ключа, которого нет, возвращает нулевое значение типа элемента, поэтому форму comma-ok (`v, ok := m[k]`) используют, чтобы отличить отсутствие от присутствующего нулевого значения. Из nil-map можно читать и по ней можно range, но запись в неё вызывает panic; перед записью её нужно инициализировать. Порядок итерации по map не специфицирован, и код не должен от него зависеть.

package main

import "fmt"

func main() {
    counts := map[string]int{"a": 0, "b": 2}

    fmt.Println(counts["missing"]) // zero value for int

    v, ok := counts["a"]
    fmt.Println(v, ok) // present even though value is zero

    var m map[string]int
    fmt.Println(m["x"]) // read from nil map is OK
    // m["x"] = 1     // panic: assignment to entry in nil map

    m = make(map[string]int)
    m["x"] = 1

    for k, v := range counts {
        fmt.Println(k, v) // order is not guaranteed
    }
}
Попробовать ответить на вопрос AI-тренеру

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

Значение среза — это заголовок, указывающий внутрь нижележащего массива. Присваивание среза или передача его в функцию копирует только этот заголовок, а не элементы. Reslicing создаёт другой заголовок, указывающий на диапазон того же backing-массива. Поэтому несколько срезов могут быть алиасами одного хранилища: изменение элемента через один срез может быть видно через другой, а append к одному срезу может перезаписать данные, видимые другому, если ещё есть запас capacity. Баги включают неожиданные мутации, испорченные результаты, удержание больших backing-массивов через маленькие подсрезы и data race при конкурентном использовании алиасов. Чтобы избежать непреднамеренного разделения, сделайте защитную копию через copy или append([]T(nil), s...), либо ограничьте capacity полным выражением среза перед append.

package main

import "fmt"

func main() {
	base := []int{1, 2, 3, 4}
	a := base[:2] // len 2, cap 4
	b := base[2:] // len 2, cap 2

	a[0] = 99
	fmt.Println(base, a, b)

	a = append(a, 77) // reuses base's backing array, overwrites base[2]
	fmt.Println(base, a, b)

	c := append([]int(nil), base[:2]...) // defensive copy
	c[0] = 42
	fmt.Println(base, c)
}
Попробовать ответить на вопрос AI-тренеру

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

Когда append добавляет элементы в срез, он записывает их в существующий backing-массив, если у среза достаточно capacity. Если capacity недостаточна, Go выделяет больший backing-массив, копирует существующие элементы, записывает новые и возвращает заголовок среза, указывающий на новое хранилище. Точная политика роста зависит от реализации, но концептуально capacity растёт достаточно, чтобы повторные append были амортизированно эффективны. Повторные перевыделения всё равно стоят CPU на копирование, создают аллокации, увеличивают давление на GC и могут разорвать разделение со старыми алиасами срезов. Если ожидаемый размер известен, предварительно выделяйте с make([]T, 0, n) при построении через append или make([]T, n) при заполнении по индексу, чтобы уменьшить перевыделения.

package main

func collectNoPrealloc(input []int) []int {
	var out []int
	for _, v := range input {
		out = append(out, v*2)
	}
	return out
}

func collectPrealloc(input []int) []int {
	out := make([]int, 0, len(input))
	for _, v := range input {
		out = append(out, v*2)
	}
	return out
}
Попробовать ответить на вопрос AI-тренеру

Семантика языка

11Как в Go работает blank identifier для неиспользуемых значений, импортов и compile-time проверок интерфейсов?

Blank identifier `_` — это write-only placeholder. Присваивание ему отбрасывает значение и не создаёт usable-переменную. Его используют, чтобы игнорировать ненужные возвращаемые значения или переменные цикла, чтобы импортировать пакет только ради side effects через `import _ "pkg"`, и чтобы делать compile-time проверки реализации интерфейса вроде `var _ io.Reader = (*MyReader)(nil)`. Blank import всё равно запускает инициализацию импортированного пакета. Присваивание для проверки интерфейса не компилируется, если method set конкретного типа не удовлетворяет интерфейсу.

package main

import "fmt"

func lookup() (string, bool) {
    return "gopher", true
}

func main() {
    name, _ := lookup() // ignore the bool
    fmt.Println(name)

    for i, _ := range []int{10, 20} {
        fmt.Println(i)
    }
}
Попробовать ответить на вопрос AI-тренеру

Пакеты

12Как в Go работает порядок инициализации пакетов, включая функции init и импортированные зависимости?

Go инициализирует пакеты в порядке зависимостей. Импортированные зависимости пакета инициализируются до импортирующего пакета. Внутри пакета package-level переменные инициализируются до любых функций `init`, причём инициализация переменных упорядочена по зависимостям и порядку объявления, как определено языком. Затем автоматически выполняются функции `init` пакета; в пакете может быть несколько `init`, и их нельзя вызывать напрямую. Каждый пакет инициализируется один раз. Для исполняемого файла сначала инициализируется граф импортов, затем инициализируется пакет `main`, и наконец вызывается `main.main`.

// a/a.go
package a

import "fmt"

var V = func() int {
    fmt.Println("a var")
    return 1
}()

func init() { fmt.Println("a init") }

// main.go
package main

import (
    "fmt"
    "example/a"
)

var M = func() int {
    fmt.Println("main var", a.V)
    return 2
}()

func init() { fmt.Println("main init") }

func main() { fmt.Println("main") }
Попробовать ответить на вопрос AI-тренеру

13Объясните правила видимости пакетов в Go, включая экспортируемые идентификаторы и соглашение о каталоге internal/.

В Go видимость пакета управляется именованием идентификаторов, а не ключевыми словами доступа. Идентификатор, имя которого начинается с заглавной Unicode-буквы, экспортируется и может быть использован из других пакетов; остальные идентификаторы неэкспортируемы и доступны только внутри того же пакета. Это относится к функциям, типам, методам, переменным, константам и полям структур. Пакеты используют экспортируемые идентификаторы, чтобы определить свой public API, и оставляют детали реализации неэкспортируемыми. Отдельно пакет, расположенный под каталогом `internal/`, может импортироваться только кодом, чей import path находится внутри родительского дерева этого каталога `internal`; это enforced toolchain'ом Go.

// module example.com/app

// internal/store/store.go
package store

type Client struct { // exported type
    dsn string // unexported field
}

func New(dsn string) *Client { return &Client{dsn: dsn} } // exported
func parseDSN(s string) string { return s }                // unexported

// cmd/api/main.go -- allowed: inside example.com/app tree
package main

import "example.com/app/internal/store"

func main() {
    c := store.New("db")
    _ = c
    // c.dsn is not accessible here: field is unexported.
}

// Code outside example.com/app cannot import example.com/app/internal/store.
Попробовать ответить на вопрос AI-тренеру

Управление потоком

14Как работает defer в Go, включая порядок выполнения, момент вычисления аргументов и взаимодействие с возвращаемыми значениями?

`defer` планирует вызов функции на момент выхода из окружающей функции — как при обычном return, так и при раскрутке panic. Несколько отложенных вызовов выполняются в порядке last-in, first-out. Значение отложенной функции и её аргументы вычисляются сразу при выполнении оператора `defer`, а сам вызов происходит позже. При именованных возвращаемых значениях оператор return сначала присваивает возвращаемые значения, затем выполняются отложенные функции, поэтому отложенное замыкание может наблюдать или изменять именованные result-переменные до того, как их получит вызывающий код. Поэтому `defer` удобен для cleanup: закрытия файлов, Unlock мьютексов и освобождения ресурсов.

package main

import "fmt"

func f() (result int) {
    x := 1
    defer fmt.Println("arg evaluated now:", x)
    defer func() {
        result++
        fmt.Println("deferred closure sees x later:", x)
    }()
    x = 2
    return 10
}

func main() {
    fmt.Println("return:", f())
}
Попробовать ответить на вопрос AI-тренеру

Обработка ошибок

15Объясните модель обработки ошибок в Go и общепринятые способы создания, возврата и проверки ошибок.

Go рассматривает ошибки как обычные значения, а не как исключения. Встроенный интерфейс `error` удовлетворяется любым типом с методом `Error() string`. По соглашению функции возвращают `error` последним результатом: `nil` означает успех, а ненулевая ошибка — что вызывающий должен обработать или пробросить сбой. Простые ошибки обычно создают через `errors.New`, форматированные — через `fmt.Errorf`, а вызывающий обычно проверяет `if err != nil { ... }`.

package main

import (
	"errors"
	"fmt"
)

func findUser(id int) (string, error) {
	if id <= 0 {
		return "", errors.New("invalid user id")
	}
	if id == 42 {
		return "", fmt.Errorf("user %d not found", id)
	}
	return "alice", nil
}

func handler(id int) error {
	name, err := findUser(id)
	if err != nil {
		return fmt.Errorf("find user: %w", err)
	}
	fmt.Println(name)
	return nil
}
Попробовать ответить на вопрос AI-тренеру

16Как следует обрабатывать восстановление после паники в бэкенд-сервисах на Go, включая то, что происходит при возникновении паники в горутине, и в каких случаях процесс должен восстанавливаться, а когда — аварийно завершать работу?

Паника выполняет раскрутку стека текущей горутины, вызывая ее отложенные функции (`defer`). Функция `recover` работает только тогда, когда она вызвана внутри отложенной функции в той же самой горутине; одна горутина не может перехватить панику другой горутины. Если паника не перехвачена, процесс аварийно завершается. В бэкенд-сервисах восстановление обычно размещают на границах изоляции, таких как обработчики запросов, промежуточные слои RPC или точки входа рабочих горутин (worker goroutines), чтобы сбой одного запроса или фоновой задачи не приводил к падению всего сервиса. Однако если паника могла повредить разделяемое состояние или нарушить целостность процесса, безопаснее позволить процессу упасть и перезапуститься, чем восстановиться и продолжать работу в неопределенном состоянии.

func Recover(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if rec := recover(); rec != nil {
                log.Printf("panic: %v\n%s", rec, debug.Stack())
                http.Error(w, "internal server error", http.StatusInternalServerError)
            }
        }()
        next.ServeHTTP(w, r)
    })
}
Попробовать ответить на вопрос AI-тренеру

Конкурентность

17Что такое nil channels в Go и как они могут случайно ломать код или намеренно отключать select cases?

Nil channel — это переменная channel со значением nil, часто потому что её не инициализировали через make или явно присвоили nil. Send в nil channel или receive из него блокируются навсегда. В select case с nil channel никогда не готов, поэтому присвоение переменной channel значения nil может намеренно отключить этот case. Случайное использование nil channel может завесить goroutines или заставить select-логику перестать обрабатывать ожидаемые события.

var in <-chan int = source
for in != nil {
    select {
    case v, ok := <-in:
        if !ok {
            in = nil // disables this receive case
            continue
        }
        fmt.Println(v)
    case <-ctx.Done():
        return
    }
}
Попробовать ответить на вопрос AI-тренеру

18Чем атомарные операции в sync/atomic отличаются от синхронизации на mutex и когда они уместны?

sync/atomic предоставляет неделимые операции над отдельными ячейками памяти, такие как load, store, add, swap и compare-and-swap, с гарантиями синхронизации/memory-ordering. Mutex защищает критическую секцию, поэтому может охранять произвольный код и инварианты, затрагивающие несколько чтений, записей или полей. Atomics уместны для простого независимого состояния вроде счётчиков, флагов, sequence numbers или тщательно спроектированных lock-free структур. Предпочитайте mutex, когда операции составные, несколько значений должны оставаться согласованными, или атомарная версия была бы трудна для рассуждений и доказательства корректности.

package main

import (
    "sync"
    "sync/atomic"
)

var requests atomic.Int64

func recordRequest() {
    requests.Add(1) // one independent counter update
}

type Account struct {
    mu      sync.Mutex
    balance int
    limit   int
}

func (a *Account) Withdraw(n int) bool {
    a.mu.Lock()
    defer a.mu.Unlock()

    // The check and update must be one protected invariant.
    if a.balance-n < -a.limit {
        return false
    }
    a.balance -= n
    return true
}
Попробовать ответить на вопрос AI-тренеру

19Как следует проектировать владение channel и жизненный цикл горутин, чтобы избегать goroutine leaks?

Проектируйте горутины с явным владельцем, ясным сигналом shutdown и гарантированным путём выхода. Сторона-producer обычно владеет закрытием channel, особенно output channel; receivers не должны закрывать channel, пока senders ещё могут быть активны. Каждая блокирующая send, receive, loop, timer или внешний вызов должны либо гарантированно завершаться, либо уметь разблокироваться по cancellation, обычно через context.Context или done channel. Используйте WaitGroup, errgroup или похожую координацию, чтобы дожидаться workers и закрывать channels только после выхода senders.

package worker

import (
    "context"
    "sync"
)

type Job int
type Result int

func StartWorkers(ctx context.Context, jobs <-chan Job, n int) <-chan Result {
    results := make(chan Result)

    var wg sync.WaitGroup
    wg.Add(n)
    for i := 0; i < n; i++ {
        go func() {
            defer wg.Done()
            for {
                select {
                case <-ctx.Done():
                    return
                case j, ok := <-jobs:
                    if !ok {
                        return
                    }
                    r := Result(j * 2)
                    select {
                    case results <- r:
                    case <-ctx.Done():
                        return
                    }
                }
            }
        }()
    }

    go func() {
        wg.Wait()
        close(results) // close after all senders are done
    }()

    return results
}
Попробовать ответить на вопрос AI-тренеру

20Каковы частые причины утечек горутин в сервисах на Go и как их обнаруживать и устранять в продакшене?

Частые причины утечек горутин в сервисах на Go включают бесконечную блокировку при отправке или чтении из каналов, ожидание других блокирующих операций без механизма отмены, зависшие операции ввода-вывода без таймаутов, фоновые циклы или тикеры, которые никогда не останавливаются, а также привязанные к контексту запроса горутины, переживающие сам запрос. В продакшене признаком проблемы служит непрерывный рост количества горутин; для выявления зависших горутин анализируют дампы горутин или профили `pprof`. Устранение утечки заключается в изменении кода для гарантированного завершения горутин: добавление отмены и таймаутов, остановка тикеров, корректное закрытие каналов, отказ от нескоординированных горутин запроса и ограничение уровня параллелизма там, где это необходимо.

func handler(w http.ResponseWriter, r *http.Request) {
    go func() {
        result := <-slowCh // may block forever
        _ = result
    }()
}
Попробовать ответить на вопрос AI-тренеру