Функции, замыкания и defer
Функции в Go — first-class values: их можно присваивать, передавать, возвращать. Понимание замыканий и
defer— обязательная база. На собесе джуну обязательно зададут “когда вычисляются аргументы defer”, “в каком порядке выполняются”, “почему в цикле бажит замыкание” (и как Go 1.22 это исправил).
Содержание
Заголовок раздела «Содержание»- Базовое определение и API
- Внутреннее устройство (ПОД КАПОТОМ)
- Тонкие моменты / Gotchas
- Производительность / Memory considerations
- Типичные вопросы на собеседовании Junior
- Practice — мини-задачки
- Источники
1. Базовое определение и API
Заголовок раздела «1. Базовое определение и API»Функция — first-class citizen
Заголовок раздела «Функция — first-class citizen»func add(a, b int) int { return a + b }
// можно присвоить переменнойvar op = addfmt.Println(op(2, 3)) // 5
// передавать как аргументfunc apply(f func(int, int) int, x, y int) int { return f(x, y) }
// возвращать из функцииfunc multiplier(k int) func(int) int { return func(n int) int { return n * k }}double := multiplier(2)fmt.Println(double(5)) // 10Multiple return values
Заголовок раздела «Multiple return values»func divmod(a, b int) (int, int) { return a / b, a % b}q, r := divmod(17, 5) // 3, 2Идиоматично — возвращать (value, error):
func readFile(name string) ([]byte, error) { ... }Named return values
Заголовок раздела «Named return values»func split(sum int) (x, y int) { x = sum * 4 / 9 y = sum - x return // "naked return" — возвращает x, y}⚠️ Подвох: naked return удобен в коротких функциях, но в длинных снижает читаемость. Кроме того, именованные возвраты обнуляются автоматически в начале функции и могут быть модифицированы из defer — это иногда полезно, но и источник багов.
Variadic functions
Заголовок раздела «Variadic functions»func sum(nums ...int) int { total := 0 for _, n := range nums { total += n } return total}
sum(1, 2, 3) // 6nums := []int{1, 2, 3}sum(nums...) // распаковка ("distribute")...int под капотом — slice []int. Без копирования (slice header передаётся по значению).
Function types
Заголовок раздела «Function types»type BinOp func(int, int) int
var op BinOp = addop(1, 2)Anonymous functions
Заголовок раздела «Anonymous functions»result := func(a, b int) int { return a + b }(2, 3)Closures
Заголовок раздела «Closures»counter := func() func() int { n := 0 return func() int { n++ return n }}()fmt.Println(counter(), counter(), counter()) // 1 2 3defer — отложенный вызов
Заголовок раздела «defer — отложенный вызов»func main() { defer fmt.Println("bye") fmt.Println("hi")}// hi// byeПорядок — LIFO (последний зарегистрированный выполняется первым):
defer fmt.Println("1")defer fmt.Println("2")defer fmt.Println("3")// 3// 2// 1recover()
Заголовок раздела «recover()»func safe() { defer func() { if r := recover(); r != nil { fmt.Println("recovered:", r) } }() panic("boom")}2. Внутреннее устройство (ПОД КАПОТОМ)
Заголовок раздела «2. Внутреннее устройство (ПОД КАПОТОМ)»Closure — структура с указателем на функцию + захваченные переменные
Заголовок раздела «Closure — структура с указателем на функцию + захваченные переменные»Замыкание под капотом — это функциональный объект (closure value), который содержит:
- указатель на машинный код функции;
- ссылки на захваченные переменные.
┌────────────────────────────┐│ Closure value │├────────────────────────────┤│ ptr to code │ ← где лежит скомпилированная функция│ ptr to captured var #1 │ ← обычно указывает в heap│ ptr to captured var #2 ││ ... │└────────────────────────────┘Захват всегда по ссылке (через переменную в heap). Поэтому, если две замыкания захватили одну переменную, они видят одни и те же изменения.
x := 0inc := func() { x++ }get := func() int { return x }inc(); inc(); fmt.Println(get()) // 2x уезжает в heap, потому что переживает функцию-владельца через closure.
defer — реализация (упрощённо)
Заголовок раздела «defer — реализация (упрощённо)»Каждый defer создаёт запись (defer record) и связывает её в связный список на стеке горутины. При выходе из функции (return, panic) runtime проходит список в порядке LIFO и вызывает функции.
Stack (frame):┌─────────────────────────┐│ defer record #3 (last) ─┼─→ defer record #2 ─→ defer record #1│ defer record #2 ││ defer record #1 (first) ││ local vars │└─────────────────────────┘Когда вычисляются аргументы defer
Заголовок раздела «Когда вычисляются аргументы defer»Аргументы вычисляются на момент defer-вызова, а не на момент выполнения!
func main() { x := 1 defer fmt.Println(x) // вычислено сейчас: 1 x = 100 // при выходе напечатает 1}Если нужна “поздняя” привязка — оборачиваем в анонимную функцию:
defer func() { fmt.Println(x) }() // вычислится при выходе → 100Open-coded defers (Go 1.14+)
Заголовок раздела «Open-coded defers (Go 1.14+)»До 1.14 каждый defer создавал heap-объект (defer record) и снижал производительность. С Go 1.14 компилятор делает open-coded defers для функций с ≤ 8 defer’ов: вместо runtime-списка вставляется inline-код, и накладные расходы стали почти нулевыми.
Это снизило стоимость defer с ~50ns/op до ~1ns в типичных случаях.
⚠️ Если функция содержит defer в цикле, или defer’ов > 8, или runtime.Goexit, или recover сложный — компилятор может откатиться на heap-based defers.
panic/recover механизм
Заголовок раздела «panic/recover механизм»panic вызывает разворачивание стека: runtime идёт по стековым фреймам в обратном порядке, выполняя зарегистрированные defer-и. Если в каком-то defer вызвать recover() — паника останавливается, и функция возвращается нормально (с её именованными возвращаемыми значениями или их zero values).
panic("boom") ↓goroutine unwinds stack: frame4 → run defers → no recover → continue frame3 → run defers → recover() → STOP unwinding, return from frame3 frame2 → not reached frame1 → not reached⚠️ recover() имеет смысл только внутри defer. Вне defer он возвращает nil.
Multiple return values — ABI
Заголовок раздела «Multiple return values — ABI»В Go возвращаемые значения передаются через stack (или регистры, начиная с Go 1.17 для amd64/arm64 ABI). С точки зрения программиста — это просто tuple.
q, r := divmod(17, 5)// под капотом: q, r передаются через определённые регистры/слоты стека3. Тонкие моменты / Gotchas
Заголовок раздела «3. Тонкие моменты / Gotchas»⚠️ Gotcha 1: Closure в цикле (Go < 1.22)
Заголовок раздела «⚠️ Gotcha 1: Closure в цикле (Go < 1.22)»// Go < 1.22 — БАГfuncs := []func(){}for i := 0; i < 3; i++ { funcs = append(funcs, func() { fmt.Println(i) })}for _, f := range funcs { f() }// Выведет: 3 3 3 (а не 0 1 2!)Причина: переменная i одна на все итерации, замыкания захватили её по ссылке, и к моменту вызова i == 3.
Фикс (старый Go):
for i := 0; i < 3; i++ { i := i // shadow — новая переменная на итерации funcs = append(funcs, func() { fmt.Println(i) })}Go 1.22+: Поведение изменено. Каждая итерация for создаёт новую переменную цикла. Теперь код выше выведет 0 1 2 без shadowing. Это одно из самых важных изменений языка за годы.
⚠️ Если вы поддерживаете старый код или собираетесь с go 1.21 в go.mod — старое поведение сохраняется. Проверяйте.
⚠️ Gotcha 2: defer + аргументы — eager evaluation
Заголовок раздела «⚠️ Gotcha 2: defer + аргументы — eager evaluation»func main() { x := 1 defer fmt.Println("x =", x) // вычислит "x = 1" сейчас x = 100}// x = 1Фикс: оборачиваем в closure → late binding:
defer func() { fmt.Println("x =", x) }() // x = 100⚠️ Gotcha 3: defer внутри цикла → leak
Заголовок раздела «⚠️ Gotcha 3: defer внутри цикла → leak»for _, name := range files { f, _ := os.Open(name) defer f.Close() // ❌ закроется только при выходе из ВСЕЙ функции process(f)}Если файлов 10000 — все 10000 хендлов открыты до конца функции. Фикс — выделить в отдельную функцию:
for _, name := range files { func() { f, _ := os.Open(name) defer f.Close() process(f) }()}⚠️ Gotcha 4: panic из горутины — нельзя поймать снаружи
Заголовок раздела «⚠️ Gotcha 4: panic из горутины — нельзя поймать снаружи»func main() { defer func() { if r := recover(); r != nil { fmt.Println("rec:", r) } }() go func() { panic("boom") // НЕ поймается main'овым recover }() time.Sleep(time.Second)}// программа упадётРецепт: defer recover() внутри каждой горутины.
⚠️ Gotcha 5: named return + defer = мутация результата
Заголовок раздела «⚠️ Gotcha 5: named return + defer = мутация результата»func calc() (result int) { defer func() { result *= 2 }() return 5 // result = 5, потом defer делает result *= 2 → возвращается 10}// calc() == 10Полезно для оборачивания ошибок:
func work() (err error) { defer func() { if err != nil { err = fmt.Errorf("work failed: %w", err) } }() return doSomething()}⚠️ Gotcha 6: variadic — slice уже передан, копия не делается
Заголовок раздела «⚠️ Gotcha 6: variadic — slice уже передан, копия не делается»func mutate(nums ...int) { nums[0] = 999 }s := []int{1, 2, 3}mutate(s...)fmt.Println(s) // [999 2 3]!Если хочешь обезопаситься — копируй внутри функции или не передавай ....
⚠️ Gotcha 7: recover работает только в текущей горутине
Заголовок раздела «⚠️ Gotcha 7: recover работает только в текущей горутине»recover ловит панику только в той же горутине, где вызван defer. Внешний recover не поможет горутине.
⚠️ Gotcha 8: recover вне defer — бесполезен
Заголовок раздела «⚠️ Gotcha 8: recover вне defer — бесполезен»func bad() { r := recover() // всегда nil fmt.Println(r) panic("x") // паника всё равно вверх}⚠️ Gotcha 9: defer на nil-функции — паника при выходе
Заголовок раздела «⚠️ Gotcha 9: defer на nil-функции — паника при выходе»var f func()defer f() // паника при выходе из функции (panic: runtime error: invalid memory address)⚠️ Gotcha 10: re-panic
Заголовок раздела «⚠️ Gotcha 10: re-panic»Иногда после recover() нужно “пробросить” панику дальше:
defer func() { if r := recover(); r != nil { log.Println("got:", r) panic(r) // re-panic }}()⚠️ Gotcha 11: Closure захватывает по ссылке даже параметр функции
Заголовок раздела «⚠️ Gotcha 11: Closure захватывает по ссылке даже параметр функции»funcs := []func(){}for i := 0; i < 3; i++ { val := i funcs = append(funcs, func() { val++; fmt.Println(val) })}funcs[0](); funcs[0](); funcs[0]()// 1, 2, 3 — состояние сохраняется между вызовами⚠️ Gotcha 12: Method value vs method expression
Заголовок раздела «⚠️ Gotcha 12: Method value vs method expression»type T struct{ X int }func (t T) Get() int { return t.X }
t := T{X: 10}mv := t.Get // method value: x уже привязан, mv() → 10me := T.Get // method expression: me(t) → 104. Производительность / Memory considerations
Заголовок раздела «4. Производительность / Memory considerations»defer cost (по версиям)
Заголовок раздела «defer cost (по версиям)»| Go версия | defer cost (typical) |
|---|---|
| < 1.13 | ~50ns + allocation |
| 1.13 | ~35ns |
| 1.14+ | ~1-2ns (open-coded), при ≤8 defers и без сложных конструкций |
⚠️ Open-coded оптимизация выключается, если есть defer в цикле, > 8 defers, или применяется goto. Тогда возвращается классическая heap-реализация.
Closure и аллокации
Заголовок раздела «Closure и аллокации»Каждое closure-значение, захватывающее переменные, может вызывать аллокацию в heap (closure object + boxed vars). В hot path стоит проверять -gcflags="-m".
// Этот closure делает аллокацию каждый раз:for i := 0; i < N; i++ { f := func() int { return i } use(f)}Inline функций
Заголовок раздела «Inline функций»Маленькие функции (по эвристикам компилятора, < ~80 узлов AST) могут быть inline. После inline:
- closure может исчезнуть как объект;
- переменная может остаться на стеке;
deferможет стать совсем дешёвым.
Посмотреть: go build -gcflags="-m=2".
Multiple return values
Заголовок раздела «Multiple return values»С Go 1.17 (Register-based ABI) возвращаемые значения часто остаются в регистрах — почти бесплатно.
Anonymous function vs method value vs lambda
Заголовок раздела «Anonymous function vs method value vs lambda»Все три — closures под капотом. Накладные расходы похожи.
5. Типичные вопросы на собеседовании Junior
Заголовок раздела «5. Типичные вопросы на собеседовании Junior»-
Что значит “функции — first-class values”? Их можно присваивать переменным, передавать как аргументы, возвращать из функций, хранить в struct/map.
-
Что такое замыкание? Функция + захваченные переменные из охватывающего контекста. В Go захват — по ссылке.
-
Как захватываются переменные в closure? По ссылке. Захваченные переменные обычно “уезжают” в heap, чтобы пережить функцию-владельца.
-
Почему
for i := 0; i < 3; i++ { go func() { print(i) }() }бажил до Go 1.22? Все горутины замкнулись на одну и ту жеi. К моменту выполненияi == 3чаще всего. Фикс —i := iвнутри тела. -
Что изменилось в Go 1.22 с переменными цикла? Каждая итерация
for i := ...; ...; ... { }теперь создаёт новуюi. Замыкание захватывает свою копию. Старый баг ушёл. -
Что такое defer? В каком порядке выполняются? Регистрация отложенного вызова. Порядок — LIFO (стек defer’ов).
-
Когда вычисляются аргументы у defer? В момент регистрации defer’а, не при выходе. Чтобы получить “позднее” значение — обернуть в closure.
-
Что такое open-coded defers? Оптимизация Go 1.14+: defer’ы (≤ 8 в функции, без хитрых конструкций) inline’ятся компилятором вместо использования heap-списка. Снизило стоимость defer до ~1ns.
-
Можно ли использовать
recoverбезdefer? Можно вызвать, но вернётnil.recoverработает только вdefer-функции. -
Что вернёт
recover()если паники не было?nil. -
Поймает ли
recoverв main горутине панику из другой горутины? Нет. Каждая горутина имеет свой стек defer’ов; чужие panic не ловятся. -
Как защитить горутину от паники?
defer func() { recover() }()в начале горутины. -
Что такое named return values? Зачем они нужны? Именованные возвращаемые переменные. Удобны для документации, naked return, и для модификации в defer (например, оборачивание ошибки).
-
Variadic функция — что это под капотом?
func f(args ...T)принимает[]T. Снаружи можно вызватьf(a, b, c)илиf(slice...). В функцииargs— это slice. -
Что выведет:
func main() {x := 1defer fmt.Println(x)x = 100}1. Аргумент defer вычислен сразу. -
А если так:
defer func() { fmt.Println(x) }()100. Closure захватилxпо ссылке. -
defer внутри for — что плохого? Defer’ы накапливаются до выхода из функции. Если в цикле тысячи итераций — leak ресурсов и памяти.
-
Можно ли вызвать метод по указателю на nil? Можно, если метод не обращается к полям. Например,
(*List)(nil).Len()валиден, если Len проверяет nil первым делом. -
Чем отличается
func()тип отfunc() error? Сигнатурой. Function types в Go — это тип сигнатуры. Совместимость по сигнатуре строгая. -
Можно ли вернуть несколько ошибок? Можно вернуть
error, обёрнутый черезerrors.Join(Go 1.20+) или composed error. Возвращать(err1, err2 error)— крайне редко.
6. Practice — мини-задачки
Заголовок раздела «6. Practice — мини-задачки»Задача 1
Заголовок раздела «Задача 1»func main() { defer fmt.Println("A") defer fmt.Println("B") defer fmt.Println("C") fmt.Println("start")}Ответ
``` start C B A ```Задача 2
Заголовок раздела «Задача 2»func main() { for i := 0; i < 3; i++ { defer fmt.Println(i) }}Ответ
``` 2 1 0 ``` (Аргументы вычислены сразу; defer'ы LIFO.)Задача 3
Заголовок раздела «Задача 3»func main() { i := 0 defer func() { fmt.Println(i) }() i++}Ответ
`1`. Closure, late binding.Задача 4 — Go 1.22+
Заголовок раздела «Задача 4 — Go 1.22+»funcs := []func() int{}for i := 0; i < 3; i++ { funcs = append(funcs, func() int { return i })}for _, f := range funcs { fmt.Println(f()) }Ответ
Go 1.22+: `0 1 2`. Go ≤ 1.21: `3 3 3`.Задача 5
Заголовок раздела «Задача 5»func mod(s ...int) { s[0] = 99 }func main() { a := []int{1, 2, 3} mod(a...) fmt.Println(a)}Ответ
`[99 2 3]`. Variadic с распаковкой не копирует данные.Задача 6
Заголовок раздела «Задача 6»func f() (x int) { defer func() { x++ }() return 10}fmt.Println(f())Ответ
`11`. Named return: x = 10, defer делает x++, возвращается 11.Задача 7
Заголовок раздела «Задача 7»func main() { defer fmt.Println("deferred") panic("boom")}Ответ
Выведет `deferred`, потом сообщение о панике и stack trace. defer'ы выполняются даже при panic.Задача 8
Заголовок раздела «Задача 8»func main() { defer func() { if r := recover(); r != nil { fmt.Println("rec:", r) } }() go func() { panic("g") }() time.Sleep(time.Second) fmt.Println("done")}Ответ
Программа упадёт. recover в main горутине не ловит panic из другой горутины.Задача 9 — counter
Заголовок раздела «Задача 9 — counter»Напишите генератор счётчика c := counter() так, чтобы c() возвращал 1, 2, 3, …
Ответ
```go func counter() func() int { n := 0 return func() int { n++; return n } } ```Задача 10
Заголовок раздела «Задача 10»type T struct{ X int }t := T{X: 10}mv := t.Get // допустим есть метод Gett.X = 20fmt.Println(mv())Ответ
Зависит от того, value receiver или pointer. Value receiver: 10 (привязан копию). Pointer receiver: 20.Задача 11 — Closure для middleware
Заголовок раздела «Задача 11 — Closure для middleware»func withLog(h func()) func() { return func() { fmt.Println("before") h() fmt.Println("after") }}Используйте, чтобы обернуть fmt.Println("hello") и продемонстрируйте.
Ответ
```go h := withLog(func() { fmt.Println("hello") }) h() // before // hello // after ```Задача 12
Заголовок раздела «Задача 12»Что выведет?
func main() { x := 1 f := func() { x = 100 } f() fmt.Println(x)}Ответ
`100`. Closure захватил x по ссылке.7. Источники
Заголовок раздела «7. Источники»- Go Spec — Defer statements
- Go 1.22 Release Notes — loop variable scoping
- Go 1.14 Release Notes — open-coded defers
- Dave Cheney — “Defer is not free”
- Effective Go — Defer, Panic, Recover