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

Задачи «что выведет» (puzzles)

Почти все Go-паззлы на собеседованиях крутятся вокруг очень небольшого набора мест, где интуиция расходится с языком: копирование значений (структуры, аргументы, range), slice header и общий backing array, момент вычисления аргументов и время исполнения defer, именованные результаты функции, интерфейс как пара (тип, значение) и потому «ненулевой nil», недетерминированный порядок итерации map, а также порядок событий в конкурентном коде — планировщик, каналы, WaitGroup, select. Если держать в голове эти шесть-семь механизмов, 90% задач решаются без запуска компилятора.

Полезно отвечать по одной и той же схеме: сначала сказать, что именно копируется и что делится по ссылке; потом — когда вычисляются выражения (аргументы defer/go вычисляются в момент постановки, тело — позже); потом — детерминирован ли вывод вообще. Последний пункт критичен: для map-итерации, для гонок и для нескольких готовых веток select правильный ответ звучит как «порядок не определён спецификацией», а не как конкретная строка. Кандидата, который уверенно называет «случайный» вывод конкретным, обычно поправляют.

Отдельная большая группа — задачи про слайсы: append либо пишет в существующий массив (если хватает cap), либо аллоцирует новый и разрывает связь между слайсами. Отсюда и «магия» с неожиданно изменившимися соседними элементами, и вопросы про рост cap. Рост не является частью спецификации: сейчас (Go 1.18+) при cap < 256 ёмкость примерно удваивается, дальше растёт плавно ~в 1.25 раза, а итоговое число ещё и округляется вверх до класса размеров аллокатора. Поэтому корректная формулировка — «удвоение для маленьких слайсов, это деталь реализации».

Из свежего, что стоит упоминать: с Go 1.22 переменная цикла for создаётся заново на каждой итерации (семантика включается директивой go 1.22 и выше в go.mod), поэтому классический паззл «3 3 3» теперь печатает 1, 2, 3 в произвольном порядке. С Go 1.21 появились min, max, clear; с Go 1.23 — итераторы range по функциям и пакет iter; в Go 1.24 map переписан на Swiss Tables — это меняет производительность и конкретные капризы порядка обхода, но не гарантии: порядок по-прежнему не определён.

Коротко. Самый частый вариант этой задачи — захват переменной цикла горутинами. С Go 1.22 (при go 1.22+ в go.mod) программа печатает 1, 2, 3 в произвольном порядке, потому что переменная цикла теперь своя на каждой итерации; на Go 1.21 и ниже почти всегда печаталось 3, 3, 3.

Глубже. До Go 1.22 v была одной переменной на весь цикл, все замыкания видели её последнее значение, а горутины стартовали уже после завершения цикла. Изменение описано в release notes Go 1.22 и управляется языковой версией модуля, а не версией компилятора: если в go.mod написано go 1.19, собранный Go 1.24 код сохранит старое поведение. Вывод остаётся неупорядоченным — планировщик не даёт гарантий, WaitGroup гарантирует только факт завершения.

package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
for _, v := range []int{1, 2, 3} {
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println(v)
}()
}
wg.Wait()
}

Коротко. Классика этой формулировки — defer и именованный результат: функция возвращает 2, потому что return 1 присваивает 1 именованной переменной result, а отложенная функция успевает её увеличить до фактического возврата.

Глубже. return x в функции с именованными результатами — это два шага: запись значения в переменную результата и собственно выход; defer выполняется между ними и видит эту переменную. Если результат не именован (func inc() int), возвращаемое значение уже скопировано, и defer изменить его не может — будет 1. То же различие объясняет, почему recover() может подменить результат только при именованном возврате.

package main
import "fmt"
func incNamed() (result int) {
defer func() { result++ }()
return 1
}
func incPlain() int {
x := 1
defer func() { x++ }()
return x
}
func main() {
fmt.Println(incNamed(), incPlain()) // 2 1
}

Коротко. Самый популярный вариант — «ненулевой nil»: функция возвращает error, внутри которого лежит nil-указатель на конкретный тип, поэтому сравнение с nil даёт false и печатается false, а вызов Error() при аккуратной реализации может даже не паниковать.

Глубже. Интерфейсное значение — это пара (динамический тип, указатель на данные). Оно равно nil только если оба поля пустые. var e *myErr = nil; var err error = e даёт пару (*myErr, nil) — интерфейс не nil. Отсюда правило: не объявляйте переменную конкретного типа ошибки и не возвращайте её как error; возвращайте nil явно. Метод с pointer receiver на nil-приёмнике вызывается нормально, паника будет только при разыменовании поля.

package main
import "fmt"
type myErr struct{}
func (e *myErr) Error() string { return "boom" }
func do(fail bool) error {
var e *myErr
if fail {
e = &myErr{}
}
return e // ловушка: всегда non-nil интерфейс
}
func main() {
err := do(false)
fmt.Println(err == nil, err) // false boom
}

Коротко. В таких задачах ищут четыре типовые вещи: гонку данных (общая map/переменная без синхронизации), sync.WaitGroup/sync.Mutex, скопированные по значению, wg.Add внутри горутины и незакрытый/утёкший канал или горутину.

Глубже. В примере ниже проблем сразу несколько: конкурентная запись в map (рантайм с большой вероятностью упадёт с fatal error: concurrent map writes — это не паника, recover не поможет), wg передаётся в функцию по значению (копию WaitGroup ловит go vet: «passes lock by value»; Add на копии не влияет на оригинал, поэтому Wait() вернётся мгновенно и никого не подождёт), а wg.Add(1) внутри горутины в принципе создаёт гонку с Wait(). Правильный набор фиксов: Add до go, передача *sync.WaitGroup (или замыкание), защита map мьютексом либо сбор результатов через канал; проверка — go test -race / go run -race.

// Плохо
func bad() {
var wg sync.WaitGroup
m := map[int]int{}
for i := 0; i < 10; i++ {
go func(i int, wg sync.WaitGroup) { // копия WaitGroup
wg.Add(1) // Add уже после старта Wait
m[i] = i * i // конкурентная запись в map
wg.Done()
}(i, wg)
}
wg.Wait()
}
// Хорошо (захват i в замыкании корректен при go 1.22+ в go.mod,
// иначе передавайте i параметром)
func good() map[int]int {
var (
wg sync.WaitGroup
mu sync.Mutex
)
m := make(map[int]int, 10)
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
mu.Lock()
m[i] = i * i
mu.Unlock()
}()
}
wg.Wait()
return m
}

Коротко. Частый вариант — «слайс от слайса» и append, который пишет в чужой backing array: печатается [1 2 3 99 5] [2 3 99], потому что у b есть запас cap, и append перезаписывает a[3].

Глубже. b := a[1:3] даёт len=2, cap=4 (от начала среза до конца исходного массива). append видит len < cap, значит новый массив не нужен, и кладёт 99 в уже существующую ячейку — это и есть a[3]. Лечится full slice expression a[1:3:3]: ёмкость обрезается, append вынужден скопировать данные, и a остаётся нетронутым. Тот же приём обязателен, когда вы отдаёте наружу подслайс внутреннего буфера.

package main
import "fmt"
func main() {
a := []int{1, 2, 3, 4, 5}
b := a[1:3] // len 2, cap 4
b = append(b, 99) // пишет в a[3]
fmt.Println(a, b) // [1 2 3 99 5] [2 3 99]
c := a[1:3:3] // cap 2
c = append(c, 77) // копирование в новый массив
fmt.Println(a, c) // [1 2 3 99 5] [2 3 77]
}

Коротко. Если в коде есть for k, v := range m по map, правильный ответ — «те же элементы, но в непредсказуемом порядке»; при этом fmt.Println(m) печатает map с ключами, отсортированными по возрастанию, — это свойство fmt, а не map.

Глубже. Рантайм намеренно стартует обход со случайного бакета и случайного смещения, чтобы код не полагался на порядок; в Go 1.24 map переехал на Swiss Tables, реализация другая, гарантия та же — порядок не определён. Спецификация отдельно говорит: удалённые во время итерации ключи гарантированно не будут выданы, а добавленные — могут быть выданы, а могут и нет, поэтому цикл, добавляющий ключи, даёт недетерминированный len. Нужен стабильный порядок — соберите ключи в слайс и отсортируйте (slices.Sorted(maps.Keys(m)) в Go 1.23+).

package main
import "fmt"
func main() {
m := map[string]int{"a": 1, "b": 2, "c": 3}
for k, v := range m {
fmt.Println(k, v) // порядок случайный
}
fmt.Println(m) // map[a:1 b:2 c:3] — fmt сортирует ключи
}

Коротко. Если в задаче отправка в небуферизованный канал из той же горутины, программа ничего не напечатает и упадёт: fatal error: all goroutines are asleep - deadlock! с кодом выхода 2.

Глубже. Небуферизованный канал требует встречи отправителя и получателя; ch <- 1 блокирует main, читателя нет, и детектор дедлоков рантайма (он срабатывает, когда все горутины спят) убивает процесс. Это фатальная ошибка, а не паника — recover её не перехватывает. Важная оговорка: если хотя бы одна горутина спит на таймере, сетевом сокете или системном вызове, дедлок не будет обнаружен и программа просто зависнет — поэтому «Go сам ловит дедлоки» на собеседовании звучит неверно.

package main
import "fmt"
func main() {
ch := make(chan int) // make(chan int, 1) — и всё заработает
ch <- 1
fmt.Println(<-ch)
}

Коротко. Типовой вариант — чтение из закрытого канала: печатается 1 true, затем 0 false, и range по закрытому каналу завершается без блокировки.

Глубже. Закрытие не выбрасывает буфер: сначала вычитываются оставшиеся значения, потом бесконечно возвращается нулевое значение с ok == false. Соседние факты, которые обычно спрашивают тут же: отправка в закрытый канал и повторный close паникуют; операции с nil-каналом блокируются навсегда, а close(nil) паникует; в select с несколькими готовыми ветками выбор псевдослучайный (uniform), а default срабатывает, только если не готова ни одна ветка. Отсюда идиома «отключить ветку select» — присвоить каналу nil.

package main
import "fmt"
func main() {
ch := make(chan int, 2)
ch <- 1
close(ch)
v, ok := <-ch
fmt.Println(v, ok) // 1 true
v, ok = <-ch
fmt.Println(v, ok) // 0 false
for range ch { // не блокируется, тело не выполняется
}
fmt.Println("done")
}

Доп вопрос: как поправить для старых версий, чтоб выводилось 1, 2, 3 в случ порядке, а не 3, 3, 3 (v := v)

Заголовок раздела «Доп вопрос: как поправить для старых версий, чтоб выводилось 1, 2, 3 в случ порядке, а не 3, 3, 3 (v := v)»

Коротко. Два канонических способа: сделать копию внутри тела цикла (v := v) или передать значение параметром в горутину (go func(v int){...}(v)). Оба создают отдельную переменную на итерацию, поэтому каждая горутина видит своё значение; порядок вывода при этом всё равно случайный.

Глубже. До Go 1.22 переменные i/v в for ... range и в трёхчастном for объявлялись один раз на весь цикл, и замыкания захватывали именно её. Приём v := v («shadowing») выглядит странно, но это ровно то, что теперь делает компилятор сам. Третий вариант — накапливать данные в слайсе и передавать индекс: go func(i int){ use(items[i]) }(i). Если проект уже на Go 1.22+, но поведение старое — проверьте директиву go в go.mod: именно она задаёт языковую версию (в Go 1.21 то же поведение можно было включить через GOEXPERIMENT=loopvar). Обратно «состарить» отдельный файл можно build-констрейнтом на более раннюю версию языка, но в проде так делать не стоит.

for _, v := range []int{1, 2, 3} {
v := v // до Go 1.22 обязательно
go func() { fmt.Println(v) }()
}
for _, v := range []int{1, 2, 3} {
go func(v int) { fmt.Println(v) }(v) // альтернатива
}

Коротко. Под этой формулировкой чаще всего прячется порядок defer: отложенные вызовы выполняются LIFO, а их аргументы вычисляются в момент постановки, поэтому цикл с defer fmt.Println(i) печатает 2, 1, 0.

Глубже. defer привязан к функции, а не к блоку: в цикле внутри долгоживущей функции отложенные вызовы копятся до её конца (частая утечка файловых дескрипторов — defer f.Close() в цикле). Начиная с Go 1.14 «открытые» defer инлайнятся и стоят порядка нескольких наносекунд, так что аргумент «defer медленный» устарел. os.Exit и фатальные ошибки рантайма defer’ы не выполняют; при панике — выполняют, и именно там работает recover().

package main
import "fmt"
func main() {
for i := 0; i < 3; i++ {
defer fmt.Println(i) // 2 1 0
}
x := 10
defer fmt.Println("arg:", x) // arg: 10 — аргумент вычислен сейчас
defer func() { fmt.Println("closure:", x) }() // closure: 20
x = 20
}

Вывод: closure: 20, arg: 10, 2, 1, 0.

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

Глубже. В примере a зависит от b, поэтому сначала вычисляется b = f() = 2, затем a = 3 — хотя a объявлена выше. Каждый пакет инициализируется ровно один раз, циклические зависимости инициализации — ошибка компиляции. В пределах одного файла init()-функций может быть несколько, они выполняются сверху вниз. Полагаться на алфавитный порядок файлов — плохая практика: go build передаёт файлы отсортированными, но спецификация гарантирует лишь «в порядке предъявления компилятору». Если вопрос про порядок в конкурентном коде — правильный ответ «не определён, если нет явной синхронизации».

package main
import "fmt"
var a = b + 1 // 3
var b = f() // 2
func f() int { return 2 }
func init() { fmt.Println("init", a, b) }
func main() { fmt.Println("main", a, b) }
// init 3 2
// main 3 2

Коротко. Если печатают строку с не-ASCII, ответ упирается в байты против рун: len("привет") равно 12, s[0] — это байт 208, а for i, r := range s идёт по рунам, и индексы прыгают через 2.

Глубже. Строка в Go — неизменяемая последовательность байт, обычно в UTF-8. Индексирование даёт byte; string(s[0]) вернёт «Ð» (и go vet предупредит о конверсии целого в строку). Количество символов — utf8.RuneCountInString, посимвольный доступ — через []rune(s) (это аллокация и O(n)). Отдельная ловушка: len([]rune(s)) может не совпадать с «числом символов, которые видит человек», из-за комбинирующих знаков и эмодзи-последовательностей.

package main
import (
"fmt"
"unicode/utf8"
)
func main() {
s := "привет"
fmt.Println(len(s)) // 12
fmt.Println(s[0]) // 208
fmt.Println(utf8.RuneCountInString(s)) // 6
for i, r := range s {
fmt.Print(i, string(r), " ") // 0п 2р 4и 6в 8е 10т
}
fmt.Println()
}

Коротко. Обычно «так» — это изменение элемента внутри range: for _, x := range xs { x.N++ } ничего не меняет, потому что x — копия; менять надо через индекс xs[i].N++ или через слайс указателей.

Глубже. range по слайсу/массиву/map всегда отдаёт копию значения; для массива копируется ещё и сам массив в момент старта цикла (для слайса — только header, поэтому изменения элементов через индекс видны). То же с методами: метод с value receiver работает с копией и не изменит оригинал. Побочный эффект Go 1.22: &v внутри цикла теперь даёт разные адреса на разных итерациях, так что старый баг «все элементы слайса указателей одинаковые» исчез — но только при языковой версии 1.22+.

package main
import "fmt"
type item struct{ n int }
func (i item) IncVal() { i.n++ } // no-op для оригинала
func (i *item) IncPtr() { i.n++ }
func main() {
xs := []item{{1}, {2}}
for _, x := range xs {
x.n = 100
x.IncVal()
}
fmt.Println(xs) // [{1} {2}]
for i := range xs {
xs[i].IncPtr()
}
fmt.Println(xs) // [{2} {3}]
}

Коротко. Вопрос про идиому: нужно назвать конструкцию и её смысл в одном предложении. Например, var _ io.Writer = (*Buf)(nil) — проверка на этапе компиляции, что *Buf реализует io.Writer, без создания объекта.

Глубже. Мини-словарь того, что чаще всего показывают: s = append(s[:i], s[i+1:]...) — удаление элемента с сохранением порядка (в Go 1.21+ есть slices.Delete, который ещё и зануляет хвост); buf = buf[:0] — переиспользование буфера без новой аллокации; ch := make(chan struct{}) — канал-сигнал, struct{}{} не занимает памяти; defer mu.Unlock() сразу после mu.Lock() — освобождение при любом выходе, включая панику; _, _ = io.Copy(io.Discard, resp.Body) перед resp.Body.Close() — дочитывание тела, чтобы соединение вернулось в keep-alive пул; select {} — вечная блокировка; runtime.KeepAlive(x) — запрет GC собирать объект раньше времени при работе с unsafe/cgo; _ = x — заглушка от «declared and not used».

package main
import "io"
type Buf struct{}
func (b *Buf) Write(p []byte) (int, error) { return len(p), nil }
var _ io.Writer = (*Buf)(nil) // compile-time проверка контракта
func main() {}

Коротко. Вопрос-провокация про рантайм-ошибки. Нужно чётко разделять: паника (close закрытого канала, отправка в закрытый канал, запись в nil-map, разыменование nil, выход за границы, type assertion без ok) и штатное поведение (чтение из nil-map, len/range по nil-слайсу, delete из nil-map, чтение из закрытого канала).

Глубже. Полезно помнить асимметрию nil-map: чтение возвращает нулевое значение, delete — no-op, а запись даёт panic: assignment to entry in nil map. Для nil-канала любая операция блокируется навсегда, а close(nil) паникует. Ещё частая пара: паника в горутине без recover внутри этой же горутины кладёт весь процесс — recover в main не спасёт; и recover() работает только при прямом вызове из отложенной функции.

package main
import "fmt"
func main() {
var m map[string]int
fmt.Println(m["x"], len(m)) // 0 0 — читать можно
delete(m, "x") // no-op
defer func() {
fmt.Println("recovered:", recover()) // assignment to entry in nil map
}()
m["x"] = 1 // panic
}

Коротко. Здесь ждут не построчный пересказ, а название паттерна: например, «fan-out/fan-in worker pool: N воркеров читают из общего канала задач, пишут в общий выходной канал, отдельная горутина ждёт всех и закрывает выход, отмена — через контекст».

Глубже. Разбирайте по осям: кто владеет каналом и кто его закрывает (закрывает всегда отправитель), где возможна утечка горутин (отправка в канал, который никто не читает после отмены — поэтому select с ctx.Done()), есть ли backpressure (небуферизованный выход — да), как передаются ошибки (здесь никак — в проде обычно golang.org/x/sync/errgroup). Такой ответ показывает, что вы читаете код архитектурно, а не построчно.

package main
import (
"context"
"sync"
)
func pool(ctx context.Context, jobs <-chan int, workers int) <-chan int {
out := make(chan int)
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := range jobs {
select {
case out <- j * j:
case <-ctx.Done():
return
}
}
}()
}
go func() {
wg.Wait()
close(out) // закрывает тот, кто пишет
}()
return out
}
func main() {}

Коротко. Это просьба провести разбор вслух. Хороший каркас: назначение кода одним предложением → точки входа и жизненный цикл (кто вызывает, когда завершается) → данные и владение (что копируется, что общее, кто закрывает ресурсы) → конкурентность и синхронизация → обработка ошибок и отмены → слабые места и что бы вы изменили.

Глубже. Интервьюер проверяет не память, а способность строить модель: проговаривайте инварианты («этот канал закрывает только продюсер», «мьютекс защищает поля X и Y», «функция не должна вызываться после Close») и явно отмечайте, чего в коде нет — таймаутов, ограничения размера, метрик, тестов на гонки. Типичные ошибки: пересказ синтаксиса построчно, молчание при непонятном месте (лучше сказать «здесь я не уверен, предполагаю вот что, проверил бы так-то») и попытка «улучшить» код, не поняв требований.

Коротко. Выведет 101, затем 202. Первый Println печатает текущее значение, а отложенное замыкание выполняется в самом конце main и читает переменную tmp уже изменённой.

Глубже. Замыкание захватывает переменную по ссылке, а не её значение на момент defer; tmp из-за этого «убегает» в кучу (проверяется go build -gcflags=-m). return в конце main ничего не меняет — отложенные вызовы выполняются и при явном, и при неявном возврате.

package main
import "fmt"
func main() {
tmp := 101
fmt.Println(tmp) // 101
defer func() {
fmt.Println(tmp) // 202
}()
tmp = 202
return
}

Коротко. Выведет 101 и снова 101: здесь tmp передан аргументом отложенной функции, а аргументы defer вычисляются и копируются в момент выполнения оператора defer, до присваивания tmp = 202.

Глубже. Это парная задача к предыдущей и лучший способ показать разницу между «захватом переменной» и «копией значения». Тот же принцип действует для go f(x): аргументы вычисляются сразу, а тело — когда горутину запустит планировщик. Практический вывод: если нужно зафиксировать состояние на момент постановки — передавайте параметром.

package main
import "fmt"
func main() {
tmp := 101
fmt.Println(tmp) // 101
defer func(tmpx int) {
fmt.Println(tmpx) // 101 — копия, снятая при defer
}(tmp)
tmp = 202
return
}

Коротко. Выведет len 0 cap 0, затем len 10 cap 16: make([]int, 0) даёт пустой слайс без ёмкости, а десять append проводят его через рост ёмкости 1 → 2 → 4 → 8 → 16.

Глубже. Точные числа роста — деталь реализации, а не спецификации: сейчас при cap < 256 ёмкость удваивается, дальше растёт коэффициентом, стремящимся к 1.25, и результат округляется вверх до класса размеров аллокатора (для 16 значений int это ровно 128 байт, поэтому 16 и остаётся). Правильный ответ на собеседовании: «10 и 16, но полагаться на конкретный cap нельзя; если размер известен заранее — пишите make([]int, 0, 10) и получите одну аллокацию вместо пяти».

package main
import "fmt"
func main() {
s := make([]int, 0)
fmt.Printf("len %d\tcap %d\n", len(s), cap(s)) // len 0 cap 0
for i := 0; i < 10; i++ {
s = append(s, i)
}
fmt.Printf("len %d\tcap %d\n", len(s), cap(s)) // len 10 cap 16
}

Коротко. append(s, []int{}...) не добавляет ни одного элемента и возвращает тот же слайс с тем же массивом, поэтому v[1] = 11 меняет и s. После append(v, 6) ёмкости не хватает, происходит копирование в новый массив, связь рвётся, и v[2] = 22 на s уже не влияет.

Глубже. Полный вывод:

s0: [0 1 2 3 4 5] len=6 cap=6
s1: [0 11 2 3 4 5] len=6 cap=6
v1: [0 11 2 3 4 5] len=6 cap=6
s2: [0 11 2 3 4 5] len=6 cap=6
v2: [0 11 22 3 4 5 6] len=7 cap=12

Ключевая мысль: append возвращает слайс, который может делить память с исходным, а может и не делить, — и предсказать это можно только по cap. Отсюда правило «после append считайте старый слайс невалидным» и приём s[:n:n], если нужно гарантированно отвязать копию. cap=12 — удвоение шести, снова деталь реализации.

package main
import "fmt"
func main() {
s := []int{0, 1, 2, 3, 4, 5}
dump("s0", s)
v := append(s, []int{}...) // тот же массив
v[1] = 11
dump("s1", s)
dump("v1", v)
v = append(v, 6) // рост cap -> новый массив
v[2] = 22
dump("s2", s)
dump("v2", v)
}
func dump(name string, s []int) {
fmt.Printf("%s: %v len=%d cap=%d\n", name, s, len(s), cap(s))
}

Коротко. Выведет map[bar:{2} foo:{1}]: range по map отдаёт копию значения-структуры, поэтому v.a = 9 меняет копию, а не элемент map. Порядок в выводе алфавитный, потому что так печатает fmt, а не потому что map упорядочена.

Глубже. Элементы map не адресуемы: m["foo"].a = 9 даже не скомпилируется («cannot assign to struct field m[“foo”].a in map»). Рабочие варианты: перечитать, изменить и записать целиком (t := m["foo"]; t.a = 9; m["foo"] = t) либо хранить указатели — map[string]*my, тогда v.a = 9 внутри range сработает (ценой лишних аллокаций и риска гонок). Причина ограничения — рантайм вправе перемещать элементы при росте таблицы, поэтому стабильного адреса у них нет; в Go 1.24 с переходом на Swiss Tables это тем более так.

package main
import "fmt"
type my struct {
a int
}
func main() {
m := map[string]my{"foo": {a: 1}, "bar": {a: 2}}
for _, v := range m {
v.a = 9 // меняем копию
}
fmt.Println(m) // map[bar:{2} foo:{1}]
t := m["foo"]
t.a = 9
m["foo"] = t
fmt.Println(m) // map[bar:{2} foo:{9}]
}
  • Называть конкретный порядок вывода там, где он не определён: обход map, гонка горутин, несколько готовых веток select. Правильно — сказать «недетерминировано» и объяснить, чем это вызвано.
  • Отвечать «3 3 3» на задачу с горутинами, не спросив про версию Go: с Go 1.22 (и директивой go 1.22+ в go.mod) вывод — 1, 2, 3 в произвольном порядке.
  • Путать defer с аргументом и defer с замыканием: аргументы вычисляются в момент постановки, переменные в теле читаются в момент выполнения.
  • Забывать про именованный результат: без него ни defer, ни recover не изменят возвращаемое значение.
  • Считать, что err != nil ловит любую ошибку: интерфейс с nil-указателем внутри не равен nil.
  • Заявлять точные значения cap как гарантию языка. Рост слайса — деталь реализации, менявшаяся между версиями.
  • Называть fatal error: all goroutines are asleep и concurrent map writes паниками, которые можно перехватить recover. Это фатальные ошибки рантайма, процесс умирает.
  • Утверждать, что range по слайсу даёт ссылки на элементы: всегда копия, менять — только через индекс.