Defer в Go: как работает отложенный вызов, где он реально спасает код и на чём чаще всего ошибаются
Если только начал изучать Go, defer сначала выглядит почти слишком удобной штукой.
Видишь строчку и кажется, что тут вообще нечего обсуждать. Ну да, закрыли файл в конце.
defer file.Close()
Но именно на таких местах потом и начинаются вопросы:
когда именно сработает defer
почему он не выполняется в конце if или итерации цикла
почему несколько defer идут в обратном порядке
почему defer fmt.Println(x) и defer func(){ fmt.Println(x) }() ведут себя по-разному
как defer меняет результат функции с named return
почему defer в цикле может привести к лишним открытым ресурсам
как связаны defer, panic и recover()
В реальном коде эта конструкция встречается постоянно: при работе с os.File, mutex, http.Response.Body, sql.Rows, при cleanup, логировании, снятии блокировок, работе с паникой и восстановлении после неё. То есть тема маленькая только по внешнему виду. На практике она очень быстро упирается в жизненный цикл функции, порядок выполнения и аккуратное управление ресурсами.
Поэтому в статье разберём, что именно произойдёт с кодом после того, как я написал defer?
Здесь разберём:
что такое defer в Go и как на него правильно смотреть
когда именно выполняется отложенный вызов
почему defer работает по принципу LIFO
как defer связан с return
почему аргументы defer вычисляются сразу
как defer ведёт себя с именованными возвращаемыми значениями
где defer реально полезен: Close(), Unlock(), cleanup
Go видит defer fmt.Println("deferred") и ставит этот вызов в стек отложенных вызовов
Выполняется fmt.Println("end")
Функция main завершает выполнение
Только теперь Go запускает отложенный вызов
Зачем defer вообще нужен
Чаще всего затем, чтобы гарантированно выполнить cleanup:
закрыть файл
снять блокировку mutex
закрыть http.Response.Body
закрыть sql.Rows
освободить ресурсы
выполнить финальный лог
поймать panic через recover()
То есть defer особенно полезен там, где действие нужно выполнить в конце функции независимо от того, по какому пути она завершится.
Почему defer любят в Go-коде
Потому что хороший defer держит рядом две вещи, которые по смыслу должны быть рядом:
захват ресурса
его освобождение
Например, без defer:
f, err := os.Open("data.txt")
if err != nil {
return err
}
// работа с файлом
err = f.Close()
if err != nil {
return err
}
Проблема такого кода в том, что при появлении новых return, веток или ошибок cleanup начинает расползаться по функции. На маленьком примере это ещё не страшно. На живом коде очень быстро появляются ситуации, где один путь закрытие делает, а другой уже нет.
С defer чище:
f, err := os.Open("data.txt")
if err != nil {
return err
}
defer f.Close()
// работа с файлом
Полезная ментальная модель
На defer удобно смотреть так: как только получил ресурс, сразу зафиксируй, как его правильно отпустить.
То есть не потом не забуду закрыть, а сразу привяжи cleanup к точке, где ресурс был захвачен.
Это очень Go-шный стиль мышления. Он делает код не только короче, но и прозрачнее: видно, что у объекта есть жизненный цикл, и видно, где этот жизненный цикл заканчивается.
Когда выполняется defer
Это первый вопрос, который надо прояснить до конца.
defer выполняется не сразу и не в момент, когда Go до него дошёл. Он выполняется при выходе из текущей функции.
Что значит “при выходе из функции”
Это может быть любой из вариантов:
функция дошла до конца
сработал return
случилась panic
Во всех этих сценариях отложенные вызовы всё равно будут запущены.
То есть defer, объявленный внутри if, всё равно выполнится только при выходе из main.
Самая частая ошибка понимания
Новичок нередко воспринимает defer как «выполни в конце текущего логического куска».
Но в Golang логический кусок для defer — это не блок, а функция.
Именно поэтому defer так хорошо работает рядом с return, cleanup и panic, но именно поэтому он может оказаться опасным внутри циклов: отложенный вызов не исчезает после итерации, он ждёт конца всей функции.
До этой ловушки мы ещё отдельно дойдём.
Почему defer работает в обратном порядке: LIFO
Если в функции несколько defer, они выполняются в обратном порядке.
Это принцип LIFO: last in, first out. Последний добавленный отложенный вызов выполнится первым.
С defer та же логика: Go складывает отложенные вызовы в стек вызовов, и при завершении функции разворачивает их назад.
Где это реально полезно
Например, когда есть несколько связанных cleanup-действий:
lockA()
defer unlockA()
lockB()
defer unlockB()
При выходе из функции сначала снимется unlockB(), потом unlockA().
Это естественно и безопасно: ресурс освобождается в порядке, обратном захвату.
Самая важная мысль
Несколько defer = обратный порядок выполнения
Defer и return: что выполняется раньше
Вот здесь у новичков очень часто каша.
Надо запомнить такую последовательность:
вычисляется return
выполняются defer
функция окончательно завершает работу
Но это объяснение пока слишком короткое. Давайте разложим понятнее.
package main
import "fmt"
func test() int {
defer fmt.Println("defer")
return 10
}
func main() {
fmt.Println(test())
}
Что будет:
defer
10
Почему вывод именно такой
Когда функция дошла до return 10, значение возврата уже определено. Но перед окончательным выходом из функции Go обязан выполнить все отложенные вызовы.
Поэтому:
результат уже готов
функция ещё не завершена окончательно
defer успевает выполниться
после этого результат уходит наружу
Defer не отменяет return
defer не означает «вернуться попозже». Он означает «сначала выполни cleanup, потом уже окончательно выйди».
Это ключевая причина, почему defer так удобен для освобождения ресурсов и обработки ошибок.
Полезный шаблон мышления
Когда видите return, не думайте что функция уже закончилась.
В Go правильнее думать: return запускает завершение функции, а defer успевает выполниться в этом процессе
Новичок часто думает так: раз вызов отложен, значит и аргументы будут вычислены потом.
Нет.
Аргументы defer вычисляются сразу в момент объявления defer, а не в момент выполнения отложенного вызова.
Пример с числом
package main
import "fmt"
func main() {
x := 10
defer fmt.Println(x)
x = 20
}
Что выведется:
10
Хотя x потом стал 20, в defer уже попало старое значение.
Почему так происходит
Когда Go видит:
defer fmt.Println(x)
он сразу вычисляет аргумент x и сохраняет его для будущего вызова. То есть откладывается сам вызов, но не момент вычисления его аргументов.
Где на этом реально ошибаются
На логах, на счётчиках, на диагностике ошибок и на временных переменных.
Например, человек пишет что-то вроде:
defer logDuration(startTime)
и не до конца понимает, что именно уже зафиксировано в аргументах, а что будет вычисляться позже. Именно поэтому в Go часто встречаются либо аккуратные вызовы с уже готовыми аргументами, либо анонимные функции, если нужно дочитать актуальное состояние в самом конце.
Как получить новое значение
Если нужно, чтобы defer увидел уже актуальное состояние переменной, часто используют анонимную функцию.
package main
import "fmt"
func main() {
x := 10
defer func() {
fmt.Println(x)
}()
x = 20
}
Теперь вывод будет:
20
Почему с анонимной функцией уже иначе
Потому что здесь в defer откладывается вызов анонимной функции, а внутри неё чтение x происходит уже позже, перед выходом из функции.
И вот тут появляется ещё одна важная тема: захват переменной.
Захват переменной и defer
Анонимная функция захватывает саму переменную, а не её старое значение на момент объявления.
Из-за этого поведение:
defer fmt.Println(x)
и
defer func() {
fmt.Println(x)
}()
может сильно отличаться.
Это надо не просто запомнить, а реально руками прогнать.
Named return и как defer может менять результат
Теперь переходим к одной из самых интересных частей темы.
Если у функции есть именованные возвращаемые значения, defer может изменить то, что функция в итоге вернёт.
Это почти обязательный шаблон при работе с HTTP-клиентом.
Закрытие sql.Rows
rows, err := db.Query("SELECT id FROM users")
if err != nil {
return err
}
defer rows.Close()
Если забыть про cleanup таких ресурсов, можно получить неприятные проблемы: зависшие соединения, утечки дескрипторов, рост нагрузки, трудноуловимые баги.
Cleanup как стиль мышления
Одна из лучших привычек в Go выглядит так:
открыл ресурс -> сразу подумал, как он будет освобождён
И если освобождение должно произойти в конце функции, defer часто лучший первый кандидат.
Паттерн, который стоит унести в работу
В нормальном Go-коде часто хочется видеть такую последовательность:
захватили ресурс
сразу поставили defer на освобождение
только потом пошли в основную логику
Это мелочь, но она сильно снижает шанс забыть cleanup, когда функция со временем обрастёт новыми ветками, логированием, ранними return и обработкой ошибок.
Но defer не всегда обязателен
Если функция очень короткая и cleanup очевиден без отложенного вызова, defer не всегда даёт выигрыш.
Но в большинстве реальных функций с несколькими ветками он улучшает надёжность и читаемость.
Небольшая история из практики
У новичков это всплывает особенно часто при первых API-проектах. Человек уже умеет читать файл, отправлять HTTP-запрос, делать SQL-запросы, но ещё не привык думать про жизненный цикл функции и освобождение ресурсов. В итоге код вроде работает, а потом начинаются странные подвисания, накопление открытых объектов и баги, которые плохо воспроизводятся.
И вот тут defer становится не просто синтаксисом, а частью нормального инженерного мышления.
Опасная ловушка: defer в цикле
Вот это место очень важно.
defer внутри цикла — не автоматически зло, но очень частая ловушка.
В чём проблема
Посмотрим на код:
for _, name := range files {
f, err := os.Open(name)
if err != nil {
return err
}
defer f.Close()
// работа с файлом
}
На первый взгляд кажется, что файл будет закрываться в конце каждой итерации.
Но нет.
Все defer f.Close() отработают только при выходе из всей функции, а не после каждой итерации.
Почему это опасно
Если файлов много, вы можете долго держать открытыми сразу кучу os.File.
Это уже риск:
утечки дескрипторов
лишней нагрузки на систему
странных ошибок на большом количестве файлов
проблем, которые на маленьких тестах не видны
Когда defer в цикле допустим
Если итераций мало, ресурсы дешёвые, а код очень прозрачен, это ещё может быть терпимо.
Но в большинстве случаев лучше либо закрывать ресурс явно в конце итерации, либо выносить тело итерации в отдельную функцию.
Правильный вариант через отдельную функцию
func processFile(name string) error {
f, err := os.Open(name)
if err != nil {
return err
}
defer f.Close()
// работа с файлом
return nil
}
func main() {
for _, name := range files {
if err := processFile(name); err != nil {
// обработка ошибки
}
}
}
Теперь defer срабатывает в конце каждой обработки файла, потому что каждая итерация живёт в своей функции.
Альтернатива без defer
Иногда проще явно закрыть ресурс:
for _, name := range files {
f, err := os.Open(name)
if err != nil {
return err
}
// работа с файлом
f.Close()
}
Но здесь уже надо быть аккуратным со всеми путями выхода и ошибками.
Практическое правило
Если используешь defer в цикле, сначала спроси себя: точно ли мне нужно ждать конца всей функции?