voidlearn
 

1.4Задачи «что выведет код»

Разберёшь короткие фрагменты Go по времени вычисления, копированию значений и общим ссылкам. Отделишь гарантированный результат от поведения, которое язык оставляет недетерминированным.

Любую задачу «что выведет» разбирают пятью вопросами подряд
  1. Что здесь значение, а что ссылка? Слайс, мапа, канал, указатель, функция и интерфейс содержат внутри указатели; массив, структура и строка копируются целиком. На этом построена половина задач.
  2. Когда это вычисляется? Аргументы defer вычисляются в момент объявления, а тело defer выполняется при выходе. Условие range считается один раз, до цикла.
  3. Кто на что смотрит, на переменную или на копию? Замыкание захватывает переменную, а в аргумент функции попадает копия значения.
  4. Есть ли тут недетерминированность? Порядок обхода мапы, выбор ветки select, порядок выполнения горутин язык не определяет. Если задача про них, правильный ответ звучит как «не определено», и он же самый ценный.
  5. Что произойдёт с памятью? append может переиспользовать массив, а может аллоцировать новый, и от этого зависит, увидят ли изменения соседи.
Как отвечать вслух, если не уверен

Никогда не молчи и не гадай наугад. Рассуждай по шагам: «так, тут именованный результат, значит return сначала присваивает… defer сработает после присваивания, но до фактического возврата… значит он видит уже присвоенное значение… получается 10». Даже если в итоге ошибёшься, интервьюер увидит рабочую модель, а её он и оценивает. Отдельный приём: если ответ «не определено», скажи это прямо и объясни почему. Кандидаты, уверенно называющие конкретный порядок обхода мапы, теряют больше, чем те, кто честно говорит «порядок рандомизирован намеренно».

Вопросы

18

Код

func f1() (result int) {          // именованный результат
    defer func() { result *= 2 }()
    return 5
}

func f2() int {                   // анонимный результат
    result := 5
    defer func() { result *= 2 }()
    return result
}

func f3() (result int, err error) {
    defer func() {
        if r := recover(); r != nil {
            err = fmt.Errorf("recovered: %v", r)
            result = -1
        }
    }()
    panic("boom")
}

func f4() (n int) {
    defer func() { n++ }()
    defer func() { n *= 10 }()
    return 1
}

func main() {
    fmt.Println(f1())
    fmt.Println(f2())
    fmt.Println(f3())
    fmt.Println(f4())
}
Вывод:
10
5
-1 recovered: boom
11

Почему: что на самом деле делает return

return X в Go распадается на три операции, строго в этом порядке:

  1. присвоить X переменной-результату;
  2. выполнить отложенные вызовы в порядке LIFO;
  3. фактически вернуть управление, отдав то, что лежит в переменной-результате сейчас.

Вся разница между f1 и f2 сводится к одному: существует ли эта переменная как именованная. В f1 результат назван result, и defer модифицирует ровно ту ячейку, из которой на шаге 3 будет взято значение: result = 5 → result *= 2 → вернули 10.

В f2 результат анонимен. return result копирует значение локальной переменной result в невидимый слот результата, и defer, изменяя локальную result, к этому слоту уже никак не относится. Функция возвращает 5, а result становится 10 в никуда.

f3 применяет ту же механику на практике и остаётся единственным способом вернуть ошибку из recover. Без именованных результатов перехваченная паника не превращается в error: писать некуда.

f4 проверяет порядок: отложенные вызовы выполняются LIFO, поэтому сначала n *= 10 (1 → 10), затем n++ (10 → 11). Порядок объявления обратен порядку выполнения.

Тот же механизм, но с ловушкой

Именованные результаты «протекают» и там, где их не ждут: defer file.Close() в функции func read() (err error) теряет ошибку закрытия. Писать надо так: defer func(){ if cerr := f.Close(); cerr != nil && err == nil { err = cerr } }(). Для файла на запись это не педантизм: Close сбрасывает буферы, и именно там обнаруживается «диск переполнен». Назвать этот приём выгодно: он показывает, что механику ты умеешь применять, а не только помнишь.

Как звучит правильное объяснение на собесе

«return сначала присваивает результату, потом идёт defer, потом собственно возврат. Если результат именованный, defer видит ту же переменную и может её поменять, поэтому f1 вернёт 10. Если анонимный, значение уже скопировано, и defer меняет локальную переменную впустую, так что f2 вернёт 5. На этом же механизме держится превращение паники в ошибку через recover, и на нём же стоит правильный defer Close(), который не теряет ошибку закрытия.»

Код

type T struct {
    n int
}

func (t T) Show()   { fmt.Println("Show (значение):", t.n) }
func (t *T) ShowP() { fmt.Println("ShowP (указатель):", t.n) }

func main() {
    i := 0
    defer fmt.Println("A defer со значением:", i)
    defer func() { fmt.Println("B defer с замыканием:", i) }()

    t := T{n: 1}
    defer t.Show()      // получатель-значение
    defer t.ShowP()     // получатель-указатель

    i = 3
    t.n = 2
    fmt.Println("конец main:", i, t.n)
}
Вывод:
конец main: 3 2
ShowP (указатель): 2
Show (значение): 1
B defer с замыканием: 3
A defer со значением: 0

Почему

Правило одно, но у него четыре следствия: defer вычисляет функцию и все её аргументы немедленно, а вызывает при выходе.

  • A. i стоит аргументом fmt.Println, поэтому вычислен и скопирован в момент defer, когда i был равен 0. Последующее i = 3 ни на что не влияет.
  • B. Аргументов у func(){...}() нет вообще. Тело работает как замыкание, захватывает переменную i, а не её значение, и читает её при выполнении: 3.
  • t.Show(). Получатель тоже считается аргументом, просто неявным. Метод объявлен на значении, поэтому t копируется в момент defer: копия помнит n = 1.
  • t.ShowP(). Метод на указателе, значит вычисляется и копируется &t, то есть сам указатель, а не то, на что он смотрит. Через него виден актуальный n = 2.

Порядок вывода подчиняется LIFO: последний объявленный defer выполняется первым.

Практическое следствие, которое приносит баллы

Вот почему defer trace(time.Now()) и defer func(){ log.Println(time.Since(start)) }() делают разное. И поэтому измерение времени пишут так: start := time.Now(); defer func(){ metrics.Observe(time.Since(start)) }(). А вот defer metrics.Observe(time.Since(start)) зафиксирует нулевую длительность, потому что time.Since вычислится немедленно. Эту ошибку регулярно допускают в реальном коде, и на собесе её стоит назвать вслух.

Как звучит правильное объяснение на собесе

«defer значит "запомнить вызов и его аргументы сейчас, выполнить потом". Аргументы, включая получателя метода, копируются в момент объявления. Поэтому defer f(x) замораживает x, а defer func(){ f(x) }() не замораживает: замыкание держит саму переменную. Если метод на значении, заморозится копия структуры; если на указателе, заморозится указатель, и изменения будут видны. Выполняются в порядке LIFO.»

Код

func main() {
    for i := 0; i < 3; i++ {
        defer fmt.Println("defer", i)
    }
    fmt.Println("конец цикла")

    for i := 0; i < 3; i++ {
        func() {
            defer fmt.Println("вложенный defer", i)
        }()
    }
    fmt.Println("конец main")
}
Вывод:
конец цикла
вложенный defer 0
вложенный defer 1
вложенный defer 2
конец main
defer 2
defer 1
defer 0

Почему

defer привязан к функции, а не к блоку. Ни фигурные скобки цикла, ни if, ни switch не создают точку выполнения отложенных вызовов; её создаёт только возврат из функции (или паника). Поэтому три defer из первого цикла копятся в списке текущей функции и разряжаются в самом конце main, в порядке LIFO.

Второй цикл показывает стандартное лекарство: обернуть тело итерации в немедленно вызываемую анонимную функцию. Теперь у каждой итерации своя функция, и defer срабатывает на её выходе сразу, в прямом порядке.

Почему это настоящий баг, а не эстетика
// Открывает 10 000 файлов и НИ ОДНОГО не закрывает до конца функции.
for _, name := range names {
    f, err := os.Open(name)
    if err != nil {
        return err
    }
    defer f.Close()          // ← накопится 10 000 отложенных Close
    process(f)
}

На длинном списке это упрётся в лимит дескрипторов процесса и вернёт «too many open files». Мягкий лимит (ulimit -n, часто 1024) Go с версии 1.19 при старте сам поднимает до жёсткого, так что на тысяче файлов ошибки обычно не будет, но жёсткий лимит тоже конечен, а все файлы висят открытыми до конца функции. То же самое с rows.Close() в цикле по запросам к БД: соединения не возвращаются в пул, и пул исчерпывается. Лечится либо анонимной функцией, либо явным f.Close() в конце итерации (но тогда придётся аккуратно закрывать и на путях с ошибкой, так что обычно проще функция).

Отдельно про производительность: с Go 1.14 defer в большинстве случаев инлайнится и стоит порядка одной-двух наносекунд. Это уже не тот дорогой defer, о котором писали в 2017-м. Но defer в цикле остаётся дорогим по другой причине: он не выполняется, а накапливается в списке, то есть расходует память пропорционально числу итераций.

Как звучит правильное объяснение на собесе

«defer относится к функции, а не к блоку, поэтому в цикле он не выполняется на каждой итерации, а копится и разряжается в конце функции в порядке LIFO. Для ресурсов внутри цикла это утечка дескрипторов, поэтому тело итерации заворачивают в анонимную функцию, чтобы у каждой итерации была своя точка выхода. В main из примера сначала отработают все вложенные defer по ходу второго цикла, а три "цикловых" сработают только после конец main, в обратном порядке.»

Код

func main() {
    var wg sync.WaitGroup
    for i := 0; i < 3; i++ {
        wg.Add(1)
        go func() { defer wg.Done(); fmt.Print(i, " ") }()
    }
    wg.Wait()
    fmt.Println()

    var fns []func()
    for _, s := range []string{"a", "b", "c"} {
        fns = append(fns, func() { fmt.Print(s, " ") })
    }
    for _, f := range fns {
        f()
    }
    fmt.Println()

    ptrs := []*int{}
    for i := 0; i < 3; i++ {
        ptrs = append(ptrs, &i)
    }
    for _, p := range ptrs {
        fmt.Print(*p, " ")
    }
    fmt.Println()
}
Вывод до Go 1.22 (go 1.21 и ниже в go.mod):
3 3 3
c c c
3 3 3
Пять прогонов подряд - все пять одинаковые: старая семантика детерминирована, потому что все горутины читают одну ячейку уже после цикла.
Вывод начиная с Go 1.22:
0 1 2 - но в произвольном порядке; восемь прогонов подряд дали 2 1 0 пять раз и 2 0 1 три раза
a b c
0 1 2
Вторая и третья строки, наоборот, детерминированы: там нет горутин, замыкания вызываются последовательно.

Почему: что именно изменили в Go 1.22

До Go 1.22 переменная цикла объявлялась один раз на весь цикл и переиспользовалась на каждой итерации. Замыкание захватывает переменную, а не её значение, поэтому все три горутины ссылались на одну и ту же ячейку памяти. К моменту их запуска цикл обычно уже завершался, и в ячейке лежало финальное значение, 3 для счётчика и "c" для range. Отсюда и &i: три указателя на одну ячейку.

Начиная с Go 1.22 переменная цикла объявляется заново на каждой итерации: формально компилятор создаёт новую переменную и копирует в неё значение перед телом итерации. Каждое замыкание получает свою ячейку, и задача перестаёт быть задачей.

Деталь про версию. Поведение определяется не версией установленного компилятора, а строкой go 1.xx в go.mod модуля. Собранный Go 1.27 модуль с go 1.21 получит старую семантику. Именно поэтому вопрос всё ещё живой: легаси-модулей с go 1.19 вокруг очень много, а миграция сводится к правке одной строки, которую боятся трогать ровно из-за этого изменения.

for i := 0; i < 3; i++ { go func(){ print(i) }() } до Go 1.22: одна переменная i на весь цикл i (одна ячейка) после цикла = 3 горутина 0 горутина 1 горутина 2 все три читают ОДНУ ячейку и видят её финальное значение вывод: 3 3 3 Go 1.22 и новее: своя переменная на каждую итерацию i₀ = 0 i₁ = 1 i₂ = 2 горутина 0 горутина 1 горутина 2 каждая горутина держит свою ячейку вывод: 0 1 2 - но порядок произвольный! значения правильные, порядок - на усмотрение планировщика
Захват переменной цикла. Замыкание всегда захватывает переменную, а не значение, и это правило не менялось. Изменилось другое: сколько переменных создаёт цикл. Раньше одну на весь цикл, теперь по одной на итерацию.
Даже на Go 1.22+ выведется «0, 1, 2 в любом порядке», а не «0 1 2»

Это вторая половина вопроса, и на ней срезаются те, кто выучил только про 1.22. Значения теперь правильные, но порядок выполнения горутин языком не определён: планировщик волен запустить их как угодно. На практике вывод часто получается 2 1 0: у планировщика Go есть оптимизация «только что созданную горутину запускаем следующей» (она кладётся в отдельную приоритетную ячейку, а не в хвост очереди), поэтому последняя созданная часто и оказывается первой напечатавшей. Но это деталь реализации, а не обещание языка, и опираться на неё нельзя. Правильный ответ звучит как «выведет 0, 1 и 2 в произвольном порядке; если нужен детерминированный порядок, надо писать в out[i] и печатать после wg.Wait()».

Как писали до 1.22 - и почему это до сих пор встречается

// Способ 1: параметр функции, значение копируется при вызове go.
for i := 0; i < 3; i++ {
    go func(i int) { fmt.Print(i, " ") }(i)
}

// Способ 2: затенение, новая переменная на итерацию вручную.
for i := 0; i < 3; i++ {
    i := i                    // да, это легально и было идиомой
    go func() { fmt.Print(i, " ") }()
}
// Оба способа под go 1.21 в go.mod печатают 0, 1 и 2, но в произвольном порядке.
// Шесть прогонов подряд дали: 2 0 1 / 2 1 0 / 2 1 0 / 2 1 0 / 2 1 0 / 2 0 1.
// (В самом фрагменте нет wg.Wait, так что для проверки его надо дописать,
//  иначе main завершится раньше, чем горутины успеют напечатать хоть что-то.)

Оба приёма продолжают работать и на 1.22+, просто перестали быть обязательными. В новом коде строка i := i теперь избыточна, и линтеры (copyloopvar) на неё ругаются.

Как звучит правильное объяснение на собесе

«Замыкание захватывает переменную, а не значение. До Go 1.22 переменная цикла была одна на весь цикл, поэтому все горутины видели её финальное значение, отсюда 3 3 3. С Go 1.22 на каждой итерации создаётся своя переменная, и выведется 0, 1, 2, но в произвольном порядке, потому что порядок выполнения горутин не гарантирован. Семантику задаёт строка go в go.mod, а не версия компилятора, так что в старом модуле, собранном новым Go, поведение останется старым.»

Код

func main() {
    a := []int{1, 2, 3, 4, 5}
    b := a[1:3]
    fmt.Println(len(b), cap(b))

    b = append(b, 99)
    fmt.Println(a)
    fmt.Println(b)
}
Вывод:
2 4
[1 2 3 99 5] ← четвёртый элемент a затёрт
[2 3 99]

Почему

Слайс устроен как заголовок из трёх полей: {ptr, len, cap}. Операция a[1:3] не копирует данные, она делает новый заголовок на тот же массив: указатель сдвигается на элемент 1, длина становится 2, а ёмкость считается до конца массива: cap(b) = cap(a) - 1 = 4. Именно эта «лишняя» ёмкость и есть корень проблемы.

append(b, 99) видит len(b) < cap(b) и понимает, что место есть, а новый массив выделять не нужно. Он записывает 99 в следующую свободную ячейку того же массива, а это физически a[3]. Значение 4 уничтожено. Возвращается заголовок с len = 3, указывающий туда же.

Один массив, два заголовка: append пишет в чужие данные общий underlying array 1 2 3 4 5 [0][1] [2][3] [4] a = {ptr, 5, 5} видит весь массив b = a[1:3] {ptr+1, len 2, cap 4} len(b) = 2 cap тянется до конца массива → append(b, 99): len < cap, значит места хватает - новый массив НЕ выделяется, 99 пишется в a[3] 99 значение 4 потеряно навсегда Лечение: b := a[1:3:3] - full slice expression обрезает cap, и append будет вынужден скопировать.
append и underlying array. Решает один признак: len < cap. Если места хватает, запись идёт в существующий массив и видна всем, кто на него смотрит. Если не хватает, выделяется новый массив, и слайсы «расходятся». Поэтому результат append зависит не от кода рядом, а от ёмкости, которую в коде обычно не видно.

Где это встречается в реальном коде

// Популярный приём «отфильтровать без аллокации» легко превращается в баг,
// если вызывающий продолжает пользоваться исходным слайсом.
func filterInPlace(s []int, keep func(int) bool) []int {
    out := s[:0]                 // тот же массив, len 0, cap полный
    for _, v := range s {
        if keep(v) {
            out = append(out, v) // пишем поверх s
        }
    }
    return out
}

a := []int{1, 2, 3, 4, 5}
b := filterInPlace(a, func(v int) bool { return v%2 == 0 })
// b = [2 4], но a = [2 4 3 4 5] - исходный слайс изуродован

Как звучит правильное объяснение на собесе

«Срез не копирует данные, он делает новый заголовок на тот же массив, и cap считается до конца массива, а не до конца среза. У b получилось len 2, cap 4, поэтому append нашёл свободное место в том же массиве и записал 99 в ячейку, которая для a служит четвёртым элементом. Если нужно гарантировать независимость, надо либо явно копировать через slices.Clone, либо ограничить ёмкость трёхиндексным срезом a[1:3:3].»

Код

func main() {
    x := make([]int, 3, 4)        // len 3, cap 4, есть одна свободная ячейка
    copy(x, []int{1, 2, 3})

    y := append(x, 4)
    z := append(x, 5)
    fmt.Println(x, y, z)

    p := []int{1, 2, 3}           // len 3, cap 3, свободных ячеек нет
    q := append(p, 4)
    q[0] = 99
    fmt.Println(p, q)
}
Вывод:
[1 2 3] [1 2 3 5] [1 2 3 5] ← y «потерял» четвёрку
[1 2 3] [99 2 3 4] ← а тут слайсы независимы

Почему

Это самая коварная пара примеров во всей теме, потому что они выглядят одинаково, а ведут себя противоположно. Разница ровно одна: хватило ли ёмкости.

Первый случай. При cap(x) = 4 и len(x) = 3 свободная ячейка есть. append(x, 4) записывает 4 в array[3] и возвращает заголовок {ptr, 4, 4}. Затем append(x, 5) смотрит на тот же x, у которого по-прежнему len 3, и записывает 5 в ту же array[3], затирая четвёрку. Теперь y и z держат два заголовка на один массив, и оба показывают [1 2 3 5]. Сам x остаётся [1 2 3], потому что его len никто не менял: append не модифицирует исходный заголовок, он возвращает новый.

Второй случай. cap(p) = 3, места нет. append выделяет новый массив (обычно вдвое больше), копирует туда три элемента, дописывает четвёртый. Слайс q смотрит на новую память, поэтому q[0] = 99 никак не задевает p.

Почему это опасно именно на практике

Поведение append зависит от cap, а его в коде обычно не видно. Слайс, пришедший из make([]T, 0, 100), из json.Unmarshal или из другой функции, имеет непредсказуемую ёмкость. Отсюда железное правило: всегда присваивай результат append обратно (s = append(s, v)) и никогда не считай, что два слайса независимы, если ты сам их не скопировал. Если функция принимает слайс и хочет его сохранить у себя, она обязана сделать slices.Clone. Это ровно та причина, по которой в net/http и в драйверах БД так много защитных копий.

Как растёт ёмкость

Точные коэффициенты остаются деталью реализации и менялись между версиями, но модель такая: для маленьких слайсов ёмкость примерно удваивается, а после порога (около 256 элементов с Go 1.18) рост становится плавнее и с ростом длины сходится к ×1.25, чтобы не тратить память на больших объёмах: сразу за порогом коэффициент ещё около двух и снижается постепенно. Поэтому append работает амортизированно за O(1): отдельная операция может стоить O(n) на копировании, но суммарно за n добавлений копирований будет O(n). Заранее известный размер стоит задавать: make([]T, 0, n) убирает все реаллокации.

var s []int
prev := cap(s)
for i := 0; i < 2000; i++ {
    s = append(s, i)
    if cap(s) != prev {
        fmt.Printf("len=%d cap=%d\n", len(s), cap(s))
        prev = cap(s)
    }
}
// len=1 cap=4, len=5 cap=8, len=9 cap=16, len=17 cap=32, ... len=257 cap=512,
// а дальше шаг роста уменьшается: 512 -> 848 -> 1280 -> 1792 -> 2560

Как звучит правильное объяснение на собесе

«append смотрит на len и cap. Если места хватает, он пишет в существующий массив и возвращает заголовок с увеличенной длиной; исходный слайс при этом не меняется, но данные общие. Если места нет, выделяется новый массив, и слайсы становятся независимыми. В первом примере ёмкости хватило, поэтому оба append писали в одну и ту же ячейку, и второй затёр первый. Во втором не хватило, поэтому q уехал на новую память. Из этого следует правило: не полагаться на то, скопировал append данные или нет, а копировать явно, когда независимость нужна.»

Код

func main() {
    m := []int{1, 2, 3, 4, 5}
    n := m[1:3:3]                 // low : high : max
    fmt.Println(len(n), cap(n))

    n = append(n, 99)
    fmt.Println(m)
    fmt.Println(n)
}
Вывод:
2 2 ← cap обрезан до max - low = 3 - 1 = 2
[1 2 3 4 5] ← исходный слайс цел
[2 3 99]

Почему

Full slice expression a[low:high:max] задаёт все три поля заголовка явно: ptr = &a[low], len = high - low, cap = max - low. Действует ограничение low ≤ high ≤ max ≤ cap(a).

В примере cap(n) == len(n) == 2, свободного места нет вообще. Поэтому append обязан выделить новый массив и скопировать туда данные, а исходный m остаётся физически недосягаем. Ровно этого мы и добивались.

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

type Buffer struct {
    data []byte
}

// Плохо: вызывающий может append'нуть и затереть наши данные за границей len.
func (b *Buffer) Head(n int) []byte { return b.data[:n] }

// Хорошо: cap обрезан, любой append у вызывающего сделает копию.
func (b *Buffer) HeadSafe(n int) []byte { return b.data[:n:n] }

Идиома s[:n:n] (или s[:len(s):len(s)]) читается как «отдать read-append-safe вид на слайс». Ровно в таком виде она встречается в стандартной библиотеке и в хорошем чужом коде.

Глубже, чем спросят: обрезка cap ≠ защита данных

a[:n:n] защищает только от append за границей длины. Прямая запись s[0] = 42 по-прежнему меняет исходный массив, ёмкость тут ни при чём. Если нужна настоящая изоляция, остаётся только копия: slices.Clone(s) или append([]T(nil), s...). Кто разводит эти два уровня вслух, сразу выделяется: большинство кандидатов считают, что [:n:n] «делает слайс безопасным».

Как звучит правильное объяснение на собесе

«Третий индекс задаёт cap: a[low:high:max] даёт cap = max - low. Когда я пишу a[1:3:3], ёмкость становится равной длине, свободного места нет, и любой append вынужден скопировать данные в новый массив. Это стандартный приём, когда отдаёшь наружу часть своего буфера: сосед не сможет незаметно записать поверх твоих данных через append. От прямой записи по индексу это не защищает, там нужна копия.»

Код

func mod(s []int) {
    s[0] = 100                    // запись по индексу
    s = append(s, 4)              // может переаллоцировать
    s[1] = 200                    // запись, но куда?
}

func main() {
    a := []int{1, 2, 3}           // len 3, cap 3
    mod(a)
    fmt.Println(a, len(a), cap(a))

    b := make([]int, 3, 4)        // len 3, cap 4
    copy(b, []int{1, 2, 3})
    mod(b)
    fmt.Println(b, len(b), cap(b))
}
Вывод:
[100 2 3] 3 3 ← append переаллоцировал, s[1]=200 ушло в новый массив
[100 200 3] 3 4 ← ёмкости хватило, писали в тот же массив

Почему

В Go всё передаётся по значению, включая слайс. Функция получает копию заголовка {ptr, len, cap}. Отсюда два разных эффекта, которые часто путают в один:

  • Запись по индексу проходит через указатель, который в копии заголовка тот же. Поэтому s[0] = 100 меняет данные, видимые снаружи. Работает всегда.
  • append меняет поля len/cap копии заголовка, а копия локальна. Даже если данные записались в общий массив, снаружи len остался старым, и новый элемент не виден. Именно поэтому обязательно писать s = append(s, v) и возвращать результат.

Первый случай: cap(a) = 3, места нет, append выделил новый массив. Локальный s теперь смотрит туда, и s[1] = 200 пишет в новую память, которую снаружи никто не видит. На выходе [100 2 3].

Второй случай: cap(b) = 4, место есть, append дописал в тот же массив. Локальный s всё ещё указывает на массив b, поэтому s[1] = 200 меняет b[1]. Получается [100 200 3], а четвёрка лежит в b[3], за границей длины, и снаружи не видна.

Почему это самая «дорогая» задача в разделе

Она объединяет три механики сразу: передачу заголовка по значению, зависимость append от cap и разницу между «изменить данные» и «изменить заголовок». Кандидат, который разложил её по этим трём уровням, фактически закрыл всю тему слайсов одним ответом. Формулировка-эталон: «слайс это значение, которое ссылается на общие данные; функция может менять данные, но не может поменять мой заголовок». Если нужно, чтобы функция могла, передавай *[]int или возвращай новый слайс.

Как надо было написать

// Вариант 1: вернуть результат, так идиоматичнее.
func mod(s []int) []int {
    s[0] = 100
    s = append(s, 4)
    s[1] = 200
    return s
}
a = mod(a)

// Вариант 2: указатель на слайс, когда возврат неудобен (редко).
func modP(s *[]int) {
    (*s)[0] = 100
    *s = append(*s, 4)
}

Как звучит правильное объяснение на собесе

«Слайс передаётся по значению, функция получает копию заголовка, но указатель внутри тот же. Поэтому запись по индексу видна снаружи всегда, а append снаружи не виден: он меняет len локальной копии. Дальше всё зависит от ёмкости: если append переаллоцировал, локальный слайс уехал на новую память и последующие записи наружу не видны; если места хватило, они по-прежнему идут в исходный массив. Поэтому в Go и пишут s = append(s, v), а функции, которые дописывают в слайс, возвращают его.»

Код

type P struct {
    Name string
    Age  int
}

func main() {
    ps := []P{{"Аня", 30}, {"Боря", 40}}
    for _, p := range ps {
        p.Age++                       // меняем копию
    }
    fmt.Println(ps)

    for i := range ps {
        ps[i].Age++                   // меняем оригинал
    }
    fmt.Println(ps)

    arr := [3]int{1, 2, 3}            // МАССИВ
    fmt.Print("массив: ")
    for i, v := range arr {
        if i == 0 {
            arr[1] = 100
        }
        fmt.Print(v, " ")
    }
    fmt.Println(arr)

    sl := []int{1, 2, 3}              // СЛАЙС
    fmt.Print("слайс:  ")
    for i, v := range sl {
        if i == 0 {
            sl[1] = 100
        }
        fmt.Print(v, " ")
    }
    fmt.Println(sl)
}
Вывод:
[{Аня 30} {Боря 40}] ← ничего не изменилось
[{Аня 31} {Боря 41}]
массив: 1 2 3 [1 100 3] ← цикл не увидел записи
слайс: 1 100 3 [1 100 3] ← цикл увидел запись

Почему часть первая: переменная цикла - копия

for _, p := range ps на каждой итерации копирует элемент в переменную p. Копия структуры полностью независима, p.Age++ инкрементирует её и выбрасывает при следующей итерации. Чтобы изменить оригинал, нужно обращаться по индексу: ps[i].Age++ (или брать &ps[i]).

Побочный эффект, о котором стоит знать: копирование не бесплатно. Если структура большая - скажем, 200 байт, - цикл for _, v := range bigStructs копирует по 200 байт на итерацию. Для горячего кода это заметно, и там пишут for i := range s с обращением &s[i].

Почему часть вторая: range вычисляет выражение один раз

Перед началом цикла Go один раз вычисляет выражение справа от range и работает с этой копией значения. И вот тут массив и слайс расходятся радикально:

  • Массив - это значение целиком, все элементы. Копируется весь массив. Цикл идёт по копии, поэтому arr[1] = 100 внутри тела на итерации не влияет: копия сделана до цикла. Печатается 1 2 3, хотя сам arr уже [1 100 3].
  • Слайс - это заголовок {ptr, len, cap}. Копируется заголовок, но данные общие. Поэтому sl[1] = 100 видно на следующей итерации: печатается 1 100 3.

Отсюда же следует ещё одно свойство: число итераций фиксируется до цикла. Если внутри range sl делать sl = append(sl, x), цикл всё равно пройдёт исходное число раз - бесконечного цикла не будет, потому что len взят один раз. Это отличает Go от языков, где такой код зацикливается.

Глубже, чем спросят: range по указателю на массив

for i, v := range &arr - легально, и массив в этом случае не копируется: итерация идёт по оригиналу, изменения видны. Это редкий, но настоящий приём для больших массивов в горячем коде. Заодно он хорошо иллюстрирует главный тезис: разница в поведении не в слове range, а в том, что именно копируется - значение или ссылка на него.

Как звучит правильное объяснение на собесе

«range делает две вещи, о которых надо помнить. Первое: он один раз вычисляет выражение справа. Для массива это копия всех элементов, поэтому изменения внутри цикла не видны; для слайса - копия заголовка, а данные общие, поэтому видны. Второе: переменная значения - это копия элемента, менять её бессмысленно, надо идти через индекс. Оба факта сводятся к одному: в Go всё копируется по значению, а разница между массивом и слайсом - в том, что именно лежит в этом значении.»

Код

func main() {
    m := map[string]int{"a": 1, "b": 2, "c": 3, "d": 4, "e": 5, "f": 6}
    for r := 0; r < 3; r++ {
        fmt.Print("проход ", r, ": ")
        for k := range m {
            fmt.Print(k, " ")
        }
        fmt.Println()
    }

    m2 := map[int]int{1: 1, 2: 2, 3: 3, 4: 4}
    seen := 0
    for k := range m2 {
        seen++
        if k == 1 {
            m2[100] = 100          // добавляем во время обхода
            delete(m2, 4)          // и удаляем
        }
    }
    fmt.Println("итераций:", seen, "итог:", len(m2))
}
Вывод: не определён. Порядок обхода разный на каждом проходе и в каждом запуске. Два реальных прогона подряд:
проход 0: a b c d e f / проход 1: e f a b c d / проход 2: b c d e f a
проход 0: a b c d e f / проход 1: a b c d e f / проход 2: d e f a b c ← первые два прохода случайно совпали, это тоже бывает
А число итераций во втором цикле - 3 или 4 в зависимости от запуска: на 300 прогонах вышло 4 итерации 267 раз и 3 итерации 33 раза. Итоговый len(m2) при этом всегда 4.

Почему порядок случаен - и почему это сделано специально

Рантайм Go намеренно рандомизирует стартовую позицию обхода мапы. Это не побочный эффект хеширования, а сознательное решение: чтобы никто не начал полагаться на порядок, который остаётся деталью устройства мапы. Иначе любая переделка внутренностей (а их переписывали: в Go 1.24 мапу перевели на SwissTable) ломала бы код по всей экосистеме. Плюс защита от HashDoS: подобрать ключи с одинаковым хешем не выйдет, потому что сид у каждой мапы свой и берётся при её создании.

Практическое следствие: если порядок нужен, его надо создать явно.

keys := make([]string, 0, len(m))
for k := range m {
    keys = append(keys, k)
}
sort.Strings(keys)               // или slices.Sort(keys)
for _, k := range keys {
    fmt.Println(k, m[k])
}
// Для m := map[string]int{"b": 2, "a": 1, "c": 3} выведет всегда одно и то же:
// a 1
// b 2
// c 3

Почему модификация во время обхода даёт разное число итераций

Спецификация Go описывает это буквально в двух правилах:

  • Удаление безопасно и предсказуемо. Если ключ удалён во время обхода, а цикл до него ещё не дошёл, он не будет выдан. Поэтому for k := range m { delete(m, k) } законно очищает мапу (хотя clear(m) с Go 1.21 лучше).
  • Добавление недетерминированно. Новый ключ может быть выдан в этом же обходе, а может и нет. Зависит от того, попал он в уже пройденную область таблицы или в ещё не пройденную и вызвала ли вставка рост таблицы.

В примере: 4 ключа, а на итерации с ключом 1 один удаляется (4) и один добавляется (100). Если удалённый ключ ещё не был выдан, а так выходит в большинстве запусков (ключ 1 должен выпасть первым), получается минус одна итерация. Новый попал в непройденную часть, значит плюс одна. Комбинации дают 3 или 4. Итоговый len при этом всегда 4: было 4, минус один, плюс один.

А вот это уже не «недетерминированно», а падение

Всё сказанное относится к обходу и модификации из одной горутины. Одновременный доступ из разных горутин, где хотя бы одна пишет, даёт не гонку данных «в общем смысле», а конкретное падение: рантайм ловит его и завершает процесс с fatal error: concurrent map read and map write. Это не паника, её нельзя перехватить через recover, процесс умирает целиком. Проверку встроили в рантайм именно потому, что молчаливое повреждение хеш-таблицы отлаживать невозможно. Лечится мьютексом, sync.Map или шардированием (см. вопрос 5 в главе 1.3).

Как звучит правильное объяснение на собесе

«Порядок обхода мапы в Go рандомизирован намеренно, чтобы на него не полагались. От HashDoS защищает другое: случайный сид хеша у каждой мапы. Для стабильного порядка надо собрать ключи в слайс и отсортировать. Модификация во время обхода из одной горутины легальна, но с оговорками: удалённый ключ гарантированно не будет выдан, а добавленный может быть выдан, а может нет. Поэтому число итераций тут не определено, 3 или 4. А конкурентная модификация из другой горутины даёт fatal error от рантайма, который не ловится через recover.»

Код

func main() {
    var m map[string]int           // объявлена, но НЕ создана - это nil

    fmt.Println(m == nil, len(m), m["x"])
    v, ok := m["x"]
    fmt.Println("чтение:", v, ok)

    delete(m, "x")                 // без паники
    for range m {                  // 0 итераций, без паники
        fmt.Println("не будет")
    }
    fmt.Println("до записи всё ок")

    defer func() { fmt.Println("recover:", recover()) }()
    m["x"] = 1                     // ← вот тут
}
Вывод:
true 0 0
чтение: 0 false
до записи всё ок
recover: assignment to entry in nil map

Почему

Nil-мапа хранит нулевой указатель на хеш-таблицу. Всё, что не требует самой таблицы, работает корректно и без специальных проверок в твоём коде:

ОперацияНа nil-мапеПочему
len(m)0нечего считать
m["x"]нулевое значение типачтение из nil-мапы определено как «ключ не найден»
v, ok := m["x"]0, falseто же самое, с явным флагом
delete(m, "x")no-opс Go 1.0 удаление из nil-мапы легально
for range m0 итерацийобходить нечего
m == niltrueмапу можно сравнивать только с nil
m["x"] = 1паникаписать некуда: таблицы не существует

Асимметрию сделали осознанно: nil-мапа ведёт себя как пустая мапа только для чтения. Поэтому функция спокойно возвращает nil вместо пустой мапы, а вызывающий её читает и обходит, не проверяя на nil. Так же устроен nil-слайс: len, range и append на нём работают.

Где на это реально напарываются
type Config struct {
    Name string
    Tags map[string]string          // забыли инициализировать
}

c := Config{Name: "svc"}
fmt.Println(c.Tags["env"])          // "" - работает, никаких подозрений
c.Tags["env"] = "prod"              // panic: assignment to entry in nil map

Мапа-поле структуры даёт эту панику чаще всего: нулевое значение структуры выглядит рабочим, читается без ошибок, и падает только на первой записи, возможно, спустя месяцы после релиза. Лечится конструктором NewConfig(), который создаёт все мапы и каналы, или ленивой инициализацией в методе-сеттере (if c.Tags == nil { c.Tags = make(map[string]string) }). Та же история с json.Unmarshal: если в JSON поля не было, мапа останется nil.

Как звучит правильное объяснение на собесе

«Nil-мапа ведёт себя как пустая мапа, доступная только для чтения. Чтение, len, range и delete на ней работают и возвращают нулевые значения: так сделано, чтобы функции могли возвращать nil вместо пустой мапы. Паникует только запись, потому что писать физически некуда. Чаще всего на это напарываются с мапой-полем структуры: нулевое значение структуры содержит nil-мапу, и падение приходит на первой же записи. Отсюда правило: мапы и каналы инициализируются в конструкторе.»

Код

type MyErr struct {
    Code int
}

func (e *MyErr) Error() string { return fmt.Sprintf("my error %d", e.Code) }

func doWork(fail bool) *MyErr {      // ← конкретный тип, не error
    if fail {
        return &MyErr{Code: 500}
    }
    return nil                       // честный nil типа *MyErr
}

func wrap(fail bool) error {         // ← а тут уже интерфейс
    return doWork(fail)
}

func main() {
    err := wrap(false)
    fmt.Println("err == nil:", err == nil)
    fmt.Println("err печатается как:", err)
    fmt.Printf("%v | %T\n", err, err)

    var p *MyErr
    var i any = p
    fmt.Println("any(nil-указатель) == nil:", i == nil)
}
Вывод:
err == nil: false ← вот она, ловушка
err печатается как: <nil> ← и это добивает окончательно
<nil> | *main.MyErr ← а тип-то есть
any(nil-указатель) == nil: false

Почему

Внутри значения интерфейса лежит пара (тип, значение). Интерфейс равен nil тогда и только тогда, когда обе части пусты.

ВыражениеДинамический типЗначение== nil
var e errornilniltrue
var p *MyErr; var e error = p*MyErrnilfalse
e = &MyErr{}*MyErrадресfalse

На return doWork(fail) срабатывает неявное преобразование *MyErr → error. Компилятор упаковывает пару: тип *MyErr есть, значение указателя равно nil. Интерфейс получился «непустым», хотя внутри пусто.

Больнее всего делает баг строка err печатается как: <nil>. fmt видит nil-указатель внутри и печатает <nil>, так что в логах всё выглядит нормально, а if err != nil при этом срабатывает. Классический сценарий: сервис отвечает «ошибка», в логе стоит <nil>, и несколько часов уходит на поиск.

Как чинить

// Вариант 1 (лучший): функция сразу возвращает интерфейс error.
func doWork(fail bool) error {
    if fail {
        return &MyErr{Code: 500}
    }
    return nil                       // теперь это настоящий nil-интерфейс
}

// Вариант 2: если сигнатуру менять нельзя - явная проверка при конвертации.
func wrap(fail bool) error {
    if e := doWork(fail); e != nil {  // сравниваем УКАЗАТЕЛЬ с nil, до упаковки
        return e
    }
    return nil
}

Правило, которое стоит произнести: «функции всегда возвращают error, а не конкретный тип ошибки». Это официальная рекомендация из Go FAQ, а вот линтера, который ловил бы этот случай, нет: nilnil проверяет другое - возврат nil, nil, - а nilness из x/tools в go vet не входит и ищет разыменование заведомо нулевого указателя.

Глубже, чем спросят: то же самое с любым интерфейсом

Ловушка не про error, а про интерфейсы вообще. var w io.Writer = (*os.File)(nil) тоже не равен nil, и вызов w.Write(...) уйдёт в метод с nil-получателем. Иногда это даже работает: метод на указателе с nil-получателем вызывается нормально, пока не разыменовывает поля. На этом построены, например, «нулевые» реализации деревьев, где func (t *Tree) Size() int { if t == nil { return 0 }; ... }. Так что typed nil не всегда баг, но всегда требует осознанности.

Как звучит правильное объяснение на собесе

«Интерфейс хранит пару тип-значение и равен nil только когда обе части пусты. Здесь функция вернула nil типа *MyErr; при упаковке в error тип сохранился, поэтому интерфейс не nil, хотя указатель внутри nil. Печатается он при этом как <nil>, что делает баг особенно неприятным. Правило простое: возвращать из функций error, а не конкретный тип ошибки; если сигнатуру не поменять, сравнить конкретный указатель с nil до того, как он попадёт в интерфейс.»

Код

func main() {
    defer func() {
        if r := recover(); r != nil {
            fmt.Println("main recover:", r)
        }
        fmt.Println("defer в main")
    }()

    go func() {
        defer fmt.Println("defer в горутине")
        panic("boom")
    }()

    time.Sleep(100 * time.Millisecond)
    fmt.Println("сюда не дойдём")
}
Вывод:
defer в горутине
panic: boom + стек, и процесс падает с кодом выхода 2.
Ни main recover, ни defer в main, ни сюда не дойдём не напечатаются.

Почему

recover работает только внутри той же горутины, где случилась паника, и только в отложенной функции, вызванной непосредственно паникующей цепочкой. У каждой горутины свой стек и свой список defer; main о панике в другой горутине ничего не знает: для неё эта паника не «происходит».

Механика раскрутки такая: паника поднимается по стеку своей горутины, выполняя её отложенные вызовы, поэтому defer в горутине и печатается. Если ни один из них не сделал recover, паника доходит до вершины стека горутины, и рантайм печатает сообщение с трассировкой и завершает весь процесс. Отложенные вызовы остальных горутин, включая main, при этом не выполняются вовсе.

Практическое следствие: recover в каждой горутине
// Обёртка, которую стоит завести в любом сервисе с фоновыми горутинами.
func Go(fn func()) {
    go func() {
        defer func() {
            if r := recover(); r != nil {
                log.Printf("паника в горутине: %v\n%s", r, debug.Stack())
                metrics.PanicTotal.Inc()
            }
        }()
        fn()
    }()
}

Именно поэтому http.Server оборачивает каждый обработчик своим recover: один кривой запрос не должен ронять весь сервис. По той же причине воркер в пуле (см. главу 1.3) обязан иметь recover, иначе одна плохая задача убивает процесс со всеми остальными.

Go 1.27: из трейсбека паники видно, какой запрос упал

Раньше в логе паники была только трассировка стека, и связать её с конкретным запросом можно было лишь по соседним строкам лога. В Go 1.26 это добавили под GODEBUG=tracebacklabels=1, а с Go 1.27 оно включено по умолчанию: рантайм печатает в заголовке горутины её метки, те самые, что ставятся через pprof.Labels и pprof.SetGoroutineLabels для разрезания CPU-профиля:

goroutine 1 [running] {request_id: "abc-123", route: "/users/{id}"}:
main.main()

Проставить метки на входе в HTTP-обработчик занимает три строки, а в разборе инцидента это разница между «где-то упало» и «упало на запросе abc-123». Отключается GODEBUG=tracebacklabels=0.

Что recover не ловит вообще

  • Панику из другой горутины: разобрано выше.
  • fatal error от рантайма. «concurrent map writes», «all goroutines are asleep - deadlock», «out of memory», переполнение стека. Это не паники, а фатальные состояния рантайма: их не перехватить в принципе.
  • os.Exit(). Он не разматывает стек и не выполняет defer.
  • recover не в defer. Вызов recover() прямо в коде функции всегда вернёт nil: он работает только из отложенной функции. И только из непосредственно отложенной: defer func(){ helper() }(), где helper вызывает recover, тоже не сработает.

Как звучит правильное объяснение на собесе

«recover действует только в своей горутине: у каждой горутины свой стек и свой список defer. Паника раскручивает стек только этой горутины, поэтому её собственный defer отработает, а потом рантайм печатает трассировку и убивает процесс целиком. defer в main и в других горутинах при этом не выполняется. Отсюда практическое правило: каждая горутина, которую ты запускаешь, обязана иметь свой recover, иначе любая ошибка в фоновой задаче роняет весь сервис.»

Код

func main() {
    a := make(chan int, 1)
    b := make(chan int, 1)
    counts := map[string]int{}
    for i := 0; i < 10000; i++ {
        a <- 1
        b <- 2
        select {                       // ОБА готовы
        case <-a:
            counts["a"]++
            <-b
        case <-b:
            counts["b"]++
            <-a
        }
    }
    fmt.Println("случайный выбор:", counts)

    c := make(chan int)
    close(c)
    for i := 0; i < 3; i++ {
        select {
        case v, ok := <-c:
            fmt.Println("из закрытого:", v, ok)
        }
    }

    var nilCh chan int
    select {
    case <-nilCh:
        fmt.Println("никогда")
    default:
        fmt.Println("nil-канал не готов никогда - сработал default")
    }
}
Вывод:
случайный выбор: map[a:5067 b:4933] ← примерно 50/50, числа пляшут от запуска к запуску: шесть прогонов подряд дали a = 5053, 4979, 4905, 4958, 5054, 5013 из 10 000. Один прогон тут ничего не доказывает - статистика на 10 000 итераций доказывает
из закрытого: 0 false - три раза подряд, без блокировки
nil-канал не готов никогда - сработал default

Почему

Правил у select четыре, и все четыре стоит назвать:

  1. Если готова ровно одна ветка, выполняется она.
  2. Если готовы несколько, выбирается равновероятно случайная. Не первая, не последняя, не по порядку в коде. Это гарантия языка, и держится она на перестановке порядка опроса. Сделано, чтобы select не голодал ни по одной ветке: иначе быстрый канал навсегда заглушил бы медленный.
  3. Если не готова ни одна и есть default, выполняется default, и select становится неблокирующим.
  4. Если не готова ни одна и default нет, горутина блокируется, пока какая-нибудь не станет готова. select{} без веток блокируется навсегда.

Закрытый канал всегда готов на чтение и мгновенно отдаёт нулевое значение с ok == false. Отсюда две вещи. Хорошая: на этом построены done-каналы и ctx.Done(): закрытие будит всех ожидающих сразу. Плохая: если забыть про ok, select в цикле по закрытому каналу начнёт крутиться на 100% CPU, бесконечно получая нули. Лечит обнуление канала:

for a != nil || b != nil {
    select {
    case v, ok := <-a:
        if !ok {
            a = nil                    // выключаем ветку навсегда
            continue
        }
        use(v)
    case v, ok := <-b:
        if !ok {
            b = nil
            continue
        }
        use(v)
    }
}

Nil-канал в select не готов никогда, ни на чтение, ни на запись, и просто «выключает» свою ветку. Это не костыль, а официальная идиома: приём с ch = nil живёт в стандартной библиотеке и в любом нетривиальном pipeline. Вне select операция с nil-каналом блокирует навсегда, и отсюда же берётся её роль «выключателя».

Ловушка с time.After в цикле
for {
    select {
    case v := <-ch:
        handle(v)
    case <-time.After(time.Second):    // НОВЫЙ таймер на каждой итерации
        return
    }
}

На каждой итерации создаётся новый Timer, и до Go 1.23 старые оставались в куче до срабатывания: на быстром канале это заметная нагрузка на память и GC. Начиная с Go 1.23 неиспользуемые таймеры собираются GC сразу, так что утечки больше нет, но лишние аллокации остались. Правильнее завести один time.NewTimer вне цикла с Reset, или context.WithTimeout. Само изменение из 1.23 давно не новость, с тех пор вышли 1.24–1.27. Но на собесе оно до сих пор играет в твою пользу: многие помнят старое правило «time.After в цикле течёт» и не знают, что утечку починили. Сильный ответ называет обе половины и добавляет, что таймер всё равно стоит переиспользовать.

Как звучит правильное объяснение на собесе

«Если в select готовы несколько веток, выбирается случайная. Это гарантия языка, а не деталь реализации, и сделана она против голодания. Закрытый канал всегда готов и отдаёт нулевое значение с ok = false, поэтому в цикле такой select закрутится, если не проверять ok и не обнулять канал. Nil-канал, наоборот, не готов никогда, и присваивание ch = nil служит стандартным способом выключить ветку select. default делает select неблокирующим.»

Код

type S struct {
    mu sync.Mutex
    n  int
    sl []int
    m  map[string]int
    p  *int
}

func main() {
    one := 1
    a := S{n: 1, sl: []int{1, 2, 3}, m: map[string]int{"x": 1}, p: &one}
    b := a                             // копирование структуры

    b.n = 100
    b.sl[0] = 100
    b.m["x"] = 100
    *b.p = 100
    b.sl = append(b.sl, 4)

    fmt.Println("a:", a.n, a.sl, a.m, *a.p)
    fmt.Println("b:", b.n, b.sl, b.m, *b.p)
}
Вывод:
a: 1 [100 2 3] map[x:100] 100
b: 100 [100 2 3 4] map[x:100] 100
Плюс go vet ругается: assignment copies lock value to b: main.S contains sync.Mutex

Почему

Структура копируется поверхностно: побайтово копируются все поля. Дальше всё решает тип поля.

ПолеЧто скопировалосьДанные общие?
n intсамо значениенет - независимы
sl []intзаголовок {ptr,len,cap}да - массив общий, но len у каждого свой
m map[string]intуказатель на хеш-таблицуда - полностью общая
p *intсам указательда - смотрят в одно место
mu sync.Mutexсостояние мьютексанет - и это баг

Тонкий момент со слайсом: b.sl[0] = 100 видно в a, потому что массив общий. А b.sl = append(b.sl, 4) уже не видно: append при cap 3 переаллоцировал, и b.sl уехал на новый массив. Даже если бы ёмкости хватило, a.sl всё равно остался бы длиной 3, заголовки-то разные. Одна строка кода показывает обе половины природы слайса.

Самая опасная строка в примере копирует мьютекс

sync.Mutex внутри устроен как обычная структура с полями state и sema. При копировании b получает свой собственный мьютекс: две горутины одновременно «захватывают блокировку», каждая свою, и спокойно пишут в общую мапу. Взаимоисключения нет, а выглядит код совершенно корректно. Хуже: если копировать структуру в момент, когда мьютекс захвачен, копия родится в состоянии «заблокировано», и первый же Lock на ней повиснет навсегда. Именно поэтому у go vet есть отдельная проверка copylocks, а все типы с мьютексом внутри держат методы на указателе. Та же проверка ловит sync.WaitGroup, sync.Once, sync.Cond и atomic.Int64. А вот strings.Builder она пропускает: у него внутри нет ни лока, ни noCopy, и копия падает уже в рантайме с illegal use of non-zero Builder copied by value.

Как сделать настоящую независимую копию

func (s *S) Clone() *S {
    s.mu.Lock()
    defer s.mu.Unlock()

    c := &S{n: s.n}                    // новый мьютекс в нулевом состоянии
    c.sl = slices.Clone(s.sl)          // копия слайса
    c.m = maps.Clone(s.m)              // копия мапы (тоже поверхностная!)
    if s.p != nil {
        v := *s.p
        c.p = &v                       // копия значения по указателю
    }
    return c
}

Оговорка: slices.Clone и maps.Clone тоже поверхностные. Если внутри слайса лежат структуры со слайсами, их придётся копировать рекурсивно. Настоящего глубокого копирования в стандартной библиотеке нет, и на добивку правильно ответить: «сделал бы явный Clone, потому что автоматического deep copy в Go нет».

Как звучит правильное объяснение на собесе

«Копирование структуры в Go поверхностное: копируются поля как есть. int скопировался по-настоящему. Слайс скопировал заголовок, массив остался общий, но длина у каждого своя, поэтому запись по индексу видна, а append уже нет. Мапа и указатель сами по себе указатели, данные полностью общие. А мьютекс скопировался вместе со своим состоянием, и это баг: у копии свой мьютекс, взаимоисключение сломано. go vet ловит это правилом copylocks, поэтому типы с мьютексом всегда используются через указатель.»

Код

package main

import "fmt"

var a = b + 1                     // объявлены в «неправильном» порядке
var b = c + 1
var c = f()

func f() int { fmt.Println("1. f() - вычисляется c"); return 1 }

func init() { fmt.Println("2. init A:", a, b, c) }
func init() { fmt.Println("3. init B") }

const (
    A = iota                      // 0
    B                             // 1
    _                             // 2 - пропущен
    D                             // 3
)

const (
    _  = iota
    KB = 1 << (10 * iota)          // 1 << 10
    MB                            // повтор выражения с iota = 2
    GB
)

type Weekday int

const (
    Mon Weekday = iota + 1
    Tue
    Wed
)

func main() {
    fmt.Println("4. main:", a, b, c)
    fmt.Println("iota:", A, B, D)
    fmt.Println("размеры:", KB, MB, GB)
    fmt.Println("дни:", Mon, Tue, Wed)
}
Вывод:
1. f() - вычисляется c
2. init A: 3 2 1
3. init B
4. main: 3 2 1
iota: 0 1 3
размеры: 1024 1048576 1073741824
дни: 1 2 3

Почему: переменные пакета инициализируются по зависимостям, а не по порядку

Спецификация задаёт алгоритм явно: компилятор строит граф зависимостей между переменными пакета и раз за разом выбирает переменную, которая (а) ещё не инициализирована и (б) все зависимости которой уже готовы. Если таких несколько, берётся та, что раньше по порядку объявления в файле (файлы обрабатываются в порядке, в котором их передали компилятору, обычно лексикографическом по имени).

Здесь a зависит от b, b зависит от c, а c не зависит ни от чего. Поэтому реальный порядок: c = f() = 1, затем b = 2, затем a = 3. Текстовый порядок объявления полностью игнорируется. Циклическая зависимость (var x = y; var y = x) даёт ошибку компиляции, а не бесконечный цикл.

Полный порядок запуска программы: константы пакета → переменные пакета в порядке зависимостей → все функции init() в порядке объявления (их может быть сколько угодно, в том числе несколько в одном файле) → main(). Пакеты при этом обрабатываются снизу вверх по дереву импортов: импортируемый пакет полностью инициализируется раньше импортирующего, и каждый проходит это ровно один раз, даже если импортирован многократно.

Почему iota ведёт себя именно так

  • iota хранит порядковый номер ConstSpec в блоке const, считая с нуля. Не «счётчик констант», а именно номер строки спецификации. Поэтому пустая строка _ тоже увеличивает iota: A=0, B=1, _=2, D=3.
  • Пропущенное выражение повторяет предыдущее. MB и GB не «наследуют значение», а повторяют формулу 1 << (10 * iota) со своим iota: 2 и 3. Отсюда 1<<20 и 1<<30. Тут спотыкаются чаще всего.
  • iota обнуляется в каждом новом блоке const, а внутри блока считает строки, а не константы: строка X, Y = iota, iota*2 даёт одну спецификацию и один iota.
  • Тип наследуется вместе с выражением. Tue и Wed получают тип Weekday, потому что повторяется вся спецификация целиком, включая тип.
Глубже, чем спросят: нетипизированные константы

Константы в Go вычисляются на этапе компиляции и по умолчанию нетипизированы, с произвольной точностью. const Big = 1 << 62 компилируется, а var x int32 = Big уже нет: константа не помещается в тип. Именно поэтому const Pi = 3.14159... в math хранит больше знаков, чем влезает в float64: обрезка происходит только в момент присваивания конкретному типу. Побочный эффект: у константы нет адреса, на неё нельзя взять указатель, и она не может быть результатом вызова функции (кроме len/cap от константного выражения и подобных).

Как звучит правильное объяснение на собесе

«Переменные пакета инициализируются не сверху вниз, а по графу зависимостей: сначала то, у чего нет неготовых зависимостей. Здесь это c, потом b, потом a, получится 3, 2, 1. Дальше выполняются все init() в порядке объявления, потом main. Пакеты инициализируются снизу вверх по импортам и по одному разу. iota означает номер строки внутри блока const, поэтому пропуск через _ его увеличивает, а пустая строка повторяет предыдущее выражение целиком, включая тип, но уже с новым значением iota.»

Код

func main() {
    var x any = 1
    var y any = 1.0
    fmt.Println("1)", x == y)

    var s1 any = "go"
    var s2 any = "go"
    fmt.Println("2)", s1 == s2)

    type Pt struct {
        X, Y int
    }
    var p1 any = Pt{1, 2}
    var p2 any = Pt{1, 2}
    fmt.Println("3)", p1 == p2)

    defer func() { fmt.Println("recover:", recover()) }()
    var b1 any = []int{1, 2}
    var b2 any = []int{1, 2}
    fmt.Println("4)", b1 == b2)       // ← компилируется, но падает
}
Вывод:
1) false ← int и float64 - разные динамические типы
2) true
3) true ← структуры из сравнимых полей сравнимы
recover: runtime error: comparing uncomparable type []int

Почему

Два значения интерфейса равны, если совпадают их динамические типы И равны их динамические значения. Отсюда первый результат: 1 и 1.0 - числа одинаковые, но типы int и float64 разные, значит false. Никакого неявного приведения при сравнении интерфейсов не происходит.

Второй шаг - сравнимость самого динамического типа. Go делит типы на две категории:

Сравнимые (== работает)Несравнимые (== запрещён)
числа, строки, bool, указатели, каналы, интерфейсы,
массивы из сравнимых элементов,
структуры, все поля которых сравнимы
слайсы, мапы, функции
(и любая структура/массив, содержащие их)

Если несравнимый тип написан явно - []int{1} == []int{2}, - это ошибка компиляции: компилятор видит типы и запрещает. Но когда значение спрятано в интерфейсе, статически тип неизвестен: any == any компилируется всегда. Проверка переезжает в рантайм, и там она превращается в панику comparing uncomparable type []int. Именно этот переход «из compile-time в runtime» и есть содержание вопроса.

Та же паника приходит из мапы - и это неприятнее
m := map[any]int{}
m["строка"] = 1                    // ок
m[42] = 2                          // ок
m[[2]int{1, 2}] = 3                // ок: массив сравним
m[[]int{1, 2}] = 4                 // panic: runtime error: hash of unhashable type []int

Ключ мапы обязан быть сравнимым. С map[any]V компилятор снова не может проверить это статически, и падение приходит в рантайме - часто на редких данных, спустя месяцы. Со структурой-ключом так не выйдет: добавишь в Key поле-слайс, и map[Key]V перестанет компилироваться. Опасен именно any в ключе. Правило: не используй any как ключ мапы, если не контролируешь, что туда кладут.

Чем сравнивать несравнимое

slices.Equal(a, b)                 // слайсы, поэлементно, быстро - Go 1.21+
maps.Equal(m1, m2)                 // мапы
reflect.DeepEqual(x, y)            // что угодно, но медленно и с сюрпризами

reflect.DeepEqual стоит упомянуть вместе с его подвохом: он считает []int(nil) и []int{} разными, хотя обе длины нулевые. В тестах это регулярно приводит к ложным падениям - поэтому в новом коде предпочитают slices.Equal (который их как раз уравнивает) или google/go-cmp.

Как звучит правильное объяснение на собесе

«Интерфейсы равны, когда совпадают и динамический тип, и значение. Поэтому any(1) и any(1.0) не равны - типы разные. А если динамический тип несравним - слайс, мапа или функция, - то сравнение компилируется (статически тип неизвестен), но падает в рантайме с comparing uncomparable type. Та же паника прилетает при использовании такого значения как ключа мапы. Для слайсов и мап есть slices.Equal и maps.Equal; reflect.DeepEqual тоже работает, но медленный и по-разному трактует nil и пустой слайс.»

Код

func main() {
    s := "Привет, Go!"
    fmt.Println("len:", len(s))
    fmt.Println("рун:", utf8.RuneCountInString(s))
    fmt.Printf("s[0] = %v, %c, %q\n", s[0], s[0], s[0])
    fmt.Printf("s[:3] = %q\n", s[:3])

    fmt.Print("range: ")
    for i, r := range s[:12] {
        fmt.Printf("(%d:%c) ", i, r)
    }
    fmt.Println()

    fmt.Print("byte-loop: ")
    for i := 0; i < 6; i++ {
        fmt.Printf("%d ", s[i])
    }
    fmt.Println()

    r := []rune(s)
    fmt.Println("[]rune len:", len(r), "первый:", string(r[0]))
}
Вывод:
len: 17 ← байт, а не символов
рун: 11
s[0] = 208, Ð, 'Ð' ← половина буквы «П»
s[:3] = "П\xd1" ← руна разрезана пополам
range: (0:П) (2:р) (4:и) (6:в) (8:е) (10:т) ← индексы через один
byte-loop: 208 159 209 128 208 184
[]rune len: 11 первый: П

Почему

Строка в Go - это неизменяемая последовательность байт, а не символов. Литералы в исходнике хранятся в UTF-8, где кириллическая буква занимает 2 байта, латиница и цифры - 1, а эмодзи - 4. «Привет, Go!» - это 6 кириллических букв (12 байт) + запятая, пробел, G, o, ! (5 байт) = 17 байт при 11 рунах.

  • s[i] возвращает byte (uint8), а не символ. s[0] = 208 - это первый из двух байт буквы «П» (0xD0 0x9F). %c честно печатает символ с кодом 208 - Ð.
  • Срез строки режет байты. s[:3] захватывает «П» целиком и первый байт от «р» - получается невалидный UTF-8, который %q показывает как "П\xd1".
  • range по строке декодирует UTF-8 на лету и выдаёт пары (байтовое смещение, руна). Именно поэтому индексы идут 0, 2, 4, 6… - это не порядковые номера символов, а смещения в байтах. Ловушка: если использовать этот i как «номер символа», всё сломается на первой же кириллице.
  • []rune(s) декодирует всю строку в слайс int32 - по 4 байта на руну. Даёт случайный доступ и корректный len, но стоит O(n) времени и O(n) памяти.

Как делать правильно

ЗадачаПравильноНеправильно
число символовutf8.RuneCountInString(s)len(s)
перебрать символыfor _, r := range sfor i := 0; i < len(s); i++
i-й символ[]rune(s)[i] (один раз конвертировать)s[i]
первые n символовсчитать руны в range и резать по смещениюs[:n]
развернуть строкучерез []runeчерез байты - сломает UTF-8
сравнить без регистраstrings.EqualFold(a, b)strings.ToUpper(a) == strings.ToUpper(b)

Когда индексный цикл по байтам уместен: если ты осознанно работаешь с байтами - парсишь ASCII-протокол, ищешь разделитель, считаешь размер для буфера. Тогда это не ошибка, а оптимизация, и об этом стоит сказать вслух: «здесь я иду по байтам намеренно, потому что разделитель - ASCII, а ASCII-байт не может встретиться внутри многобайтовой руны в UTF-8». Последнее - самосинхронизирующееся свойство UTF-8, и знание про него ценится отдельно.

Глубже, чем спросят: руна - тоже не «символ»

Руна - это code point Unicode, и она не всегда совпадает с тем, что пользователь видит как один символ. «é» может быть одной руной U+00E9 или двумя (e + U+0301). Флаг «🇷🇺» - две руны. Эмодзи с модификатором тона кожи - тоже несколько. То, что видит человек, называется графемным кластером, и в стандартной библиотеке его нет - нужна внешняя библиотека (rivo/uniseg). Поэтому строго корректный ответ на «обрежь строку до 20 символов» - «до 20 рун; если нужны именно видимые символы, это графемные кластеры, и нужна сторонняя библиотека».

Как звучит правильное объяснение на собесе

«Строка в Go - это байты в UTF-8, поэтому len даёт длину в байтах, а s[i] - байт, а не символ: для кириллицы это половина буквы. range по строке декодирует UTF-8 и выдаёт руну и её байтовое смещение - поэтому индексы идут через один, и использовать их как номер символа нельзя. Если нужен случайный доступ к символам - конвертирую в []rune один раз, помня, что это O(n) памяти. Резать строку можно только по границам рун, иначе получится невалидный UTF-8.»

Что стоит запомнить

  • Аргументы defer вычисляются при выполнении defer statement, тело - при выходе из функции.
  • Named result является переменной, которую может изменить deferred closure.
  • Обход map и выбор готовой ветки select не задают стабильный порядок.

Частые ошибки

  • Порядок запуска горутин нельзя выводить из порядка их создания.
  • Typed nil может храниться в интерфейсе как ненулевое значение интерфейса.

Самопроверка

Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.

Чтение кода

1. Посчитай именованный результат

Что выведет программа и в какой момент defer меняет n?

package main
import "fmt"
func value() (n int) {
 defer func(){ n++ }()
 return 5
}
func main(){ fmt.Println(value()) }
Ответ и разбор

Программа выведет 6. Перед выполнением defer return присваивает named result значение 5; затем deferred closure меняет ту же переменную n на 6; после этого значение возвращается вызывающему коду.

Код-ревью

2. Верни nil вместо typed nil

Функция ниже возвращает nil, если ошибки нет, но вызов всё равно проходит проверку err != nil. Объясни причину и предложи исправление. Сохрани Error() string, чтобы тип действительно реализовывал интерфейс error.

type ParseError struct{}
func (ParseError) Error() string { return "parse failed" }
func parse() error {
 var e *ParseError
 return e
}
Ответ и разбор

Интерфейс состоит из динамического типа и значения. Возврат переменной типа *ParseError с nil создаёт интерфейс с типом *ParseError, поэтому сам интерфейс не равен nil; добавленный метод Error делает этот указатель допустимым значением интерфейса error. В ветке успеха возвращай literal nil, а указатель на ParseError только при реальной ошибке.

Применение

3. Отдели гарантии от порядка map

Программа обходит map и печатает ключи. После двух запусков порядок разный. Можно ли выбрать «правильный» порядок из спецификации Go и как получить стабильный вывод?

Ответ и разбор

Спецификация не обещает порядок итерации map, поэтому оба порядка допустимы. Собери ключи в слайс, отсортируй через sort.Ints или подходящую сортировку для типа ключа и печатай после этого.

Применение

4. Объясни alias после append

a и b := a[:2] делят backing array. После b = append(b, 9) почему значение a[2] может измениться, и что гарантирует ограничение capacity?

Ответ и разбор

Если у b есть свободная capacity, append записывает 9 в общий backing array, поэтому a[2] изменится. Трёхиндексный срез a[:2:2] ограничит capacity до длины; следующий append должен выделить отдельный массив. Не делай вывод о точном коэффициенте роста capacity: он не является фиксированной языковой гарантией.