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

Функции, замыкания и defer

Функции в Go — first-class values: их можно присваивать, передавать, возвращать. Понимание замыканий и defer — обязательная база. На собесе джуну обязательно зададут “когда вычисляются аргументы defer”, “в каком порядке выполняются”, “почему в цикле бажит замыкание” (и как Go 1.22 это исправил).

  1. Базовое определение и API
  2. Внутреннее устройство (ПОД КАПОТОМ)
  3. Тонкие моменты / Gotchas
  4. Производительность / Memory considerations
  5. Типичные вопросы на собеседовании Junior
  6. Practice — мини-задачки
  7. Источники

func add(a, b int) int { return a + b }
// можно присвоить переменной
var op = add
fmt.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)) // 10
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) { ... }
func split(sum int) (x, y int) {
x = sum * 4 / 9
y = sum - x
return // "naked return" — возвращает x, y
}

⚠️ Подвох: naked return удобен в коротких функциях, но в длинных снижает читаемость. Кроме того, именованные возвраты обнуляются автоматически в начале функции и могут быть модифицированы из defer — это иногда полезно, но и источник багов.

func sum(nums ...int) int {
total := 0
for _, n := range nums {
total += n
}
return total
}
sum(1, 2, 3) // 6
nums := []int{1, 2, 3}
sum(nums...) // распаковка ("distribute")

...int под капотом — slice []int. Без копирования (slice header передаётся по значению).

type BinOp func(int, int) int
var op BinOp = add
op(1, 2)
result := func(a, b int) int { return a + b }(2, 3)
counter := func() func() int {
n := 0
return func() int {
n++
return n
}
}()
fmt.Println(counter(), counter(), counter()) // 1 2 3
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
// 1
func safe() {
defer func() {
if r := recover(); r != nil {
fmt.Println("recovered:", r)
}
}()
panic("boom")
}

Closure — структура с указателем на функцию + захваченные переменные

Заголовок раздела «Closure — структура с указателем на функцию + захваченные переменные»

Замыкание под капотом — это функциональный объект (closure value), который содержит:

  • указатель на машинный код функции;
  • ссылки на захваченные переменные.
┌────────────────────────────┐
│ Closure value │
├────────────────────────────┤
│ ptr to code │ ← где лежит скомпилированная функция
│ ptr to captured var #1 │ ← обычно указывает в heap
│ ptr to captured var #2 │
│ ... │
└────────────────────────────┘

Захват всегда по ссылке (через переменную в heap). Поэтому, если две замыкания захватили одну переменную, они видят одни и те же изменения.

x := 0
inc := func() { x++ }
get := func() int { return x }
inc(); inc(); fmt.Println(get()) // 2

x уезжает в heap, потому что переживает функцию-владельца через closure.

Каждый 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-вызова, а не на момент выполнения!

func main() {
x := 1
defer fmt.Println(x) // вычислено сейчас: 1
x = 100
// при выходе напечатает 1
}

Если нужна “поздняя” привязка — оборачиваем в анонимную функцию:

defer func() { fmt.Println(x) }() // вычислится при выходе → 100

До 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 вызывает разворачивание стека: 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.

В Go возвращаемые значения передаются через stack (или регистры, начиная с Go 1.17 для amd64/arm64 ABI). С точки зрения программиста — это просто tuple.

q, r := divmod(17, 5)
// под капотом: q, r передаются через определённые регистры/слоты стека

// 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 — старое поведение сохраняется. Проверяйте.

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
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() внутри каждой горутины.

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 не поможет горутине.

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)

Иногда после 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 — состояние сохраняется между вызовами
type T struct{ X int }
func (t T) Get() int { return t.X }
t := T{X: 10}
mv := t.Get // method value: x уже привязан, mv() → 10
me := T.Get // method expression: me(t) → 10

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-значение, захватывающее переменные, может вызывать аллокацию в heap (closure object + boxed vars). В hot path стоит проверять -gcflags="-m".

// Этот closure делает аллокацию каждый раз:
for i := 0; i < N; i++ {
f := func() int { return i }
use(f)
}

Маленькие функции (по эвристикам компилятора, < ~80 узлов AST) могут быть inline. После inline:

  • closure может исчезнуть как объект;
  • переменная может остаться на стеке;
  • defer может стать совсем дешёвым.

Посмотреть: go build -gcflags="-m=2".

С Go 1.17 (Register-based ABI) возвращаемые значения часто остаются в регистрах — почти бесплатно.

Все три — closures под капотом. Накладные расходы похожи.


  1. Что значит “функции — first-class values”? Их можно присваивать переменным, передавать как аргументы, возвращать из функций, хранить в struct/map.

  2. Что такое замыкание? Функция + захваченные переменные из охватывающего контекста. В Go захват — по ссылке.

  3. Как захватываются переменные в closure? По ссылке. Захваченные переменные обычно “уезжают” в heap, чтобы пережить функцию-владельца.

  4. Почему for i := 0; i < 3; i++ { go func() { print(i) }() } бажил до Go 1.22? Все горутины замкнулись на одну и ту же i. К моменту выполнения i == 3 чаще всего. Фикс — i := i внутри тела.

  5. Что изменилось в Go 1.22 с переменными цикла? Каждая итерация for i := ...; ...; ... { } теперь создаёт новую i. Замыкание захватывает свою копию. Старый баг ушёл.

  6. Что такое defer? В каком порядке выполняются? Регистрация отложенного вызова. Порядок — LIFO (стек defer’ов).

  7. Когда вычисляются аргументы у defer? В момент регистрации defer’а, не при выходе. Чтобы получить “позднее” значение — обернуть в closure.

  8. Что такое open-coded defers? Оптимизация Go 1.14+: defer’ы (≤ 8 в функции, без хитрых конструкций) inline’ятся компилятором вместо использования heap-списка. Снизило стоимость defer до ~1ns.

  9. Можно ли использовать recover без defer? Можно вызвать, но вернёт nil. recover работает только в defer-функции.

  10. Что вернёт recover() если паники не было? nil.

  11. Поймает ли recover в main горутине панику из другой горутины? Нет. Каждая горутина имеет свой стек defer’ов; чужие panic не ловятся.

  12. Как защитить горутину от паники? defer func() { recover() }() в начале горутины.

  13. Что такое named return values? Зачем они нужны? Именованные возвращаемые переменные. Удобны для документации, naked return, и для модификации в defer (например, оборачивание ошибки).

  14. Variadic функция — что это под капотом? func f(args ...T) принимает []T. Снаружи можно вызвать f(a, b, c) или f(slice...). В функции args — это slice.

  15. Что выведет:

    func main() {
    x := 1
    defer fmt.Println(x)
    x = 100
    }

    1. Аргумент defer вычислен сразу.

  16. А если так:

    defer func() { fmt.Println(x) }()

    100. Closure захватил x по ссылке.

  17. defer внутри for — что плохого? Defer’ы накапливаются до выхода из функции. Если в цикле тысячи итераций — leak ресурсов и памяти.

  18. Можно ли вызвать метод по указателю на nil? Можно, если метод не обращается к полям. Например, (*List)(nil).Len() валиден, если Len проверяет nil первым делом.

  19. Чем отличается func() тип от func() error? Сигнатурой. Function types в Go — это тип сигнатуры. Совместимость по сигнатуре строгая.

  20. Можно ли вернуть несколько ошибок? Можно вернуть error, обёрнутый через errors.Join (Go 1.20+) или composed error. Возвращать (err1, err2 error) — крайне редко.


func main() {
defer fmt.Println("A")
defer fmt.Println("B")
defer fmt.Println("C")
fmt.Println("start")
}
Ответ ``` start C B A ```
func main() {
for i := 0; i < 3; i++ {
defer fmt.Println(i)
}
}
Ответ ``` 2 1 0 ``` (Аргументы вычислены сразу; defer'ы LIFO.)
func main() {
i := 0
defer func() { fmt.Println(i) }()
i++
}
Ответ `1`. Closure, late binding.
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`.
func mod(s ...int) { s[0] = 99 }
func main() {
a := []int{1, 2, 3}
mod(a...)
fmt.Println(a)
}
Ответ `[99 2 3]`. Variadic с распаковкой не копирует данные.
func f() (x int) {
defer func() { x++ }()
return 10
}
fmt.Println(f())
Ответ `11`. Named return: x = 10, defer делает x++, возвращается 11.
func main() {
defer fmt.Println("deferred")
panic("boom")
}
Ответ Выведет `deferred`, потом сообщение о панике и stack trace. defer'ы выполняются даже при panic.
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 из другой горутины.

Напишите генератор счётчика c := counter() так, чтобы c() возвращал 1, 2, 3, …

Ответ ```go func counter() func() int { n := 0 return func() int { n++; return n } } ```
type T struct{ X int }
t := T{X: 10}
mv := t.Get // допустим есть метод Get
t.X = 20
fmt.Println(mv())
Ответ Зависит от того, value receiver или pointer. Value receiver: 10 (привязан копию). Pointer receiver: 20.
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 ```

Что выведет?

func main() {
x := 1
f := func() { x = 100 }
f()
fmt.Println(x)
}
Ответ `100`. Closure захватил x по ссылке.

  1. Go Spec — Defer statements
  2. Go 1.22 Release Notes — loop variable scoping
  3. Go 1.14 Release Notes — open-coded defers
  4. Dave Cheney — “Defer is not free”
  5. Effective Go — Defer, Panic, Recover