Содержание урока · 10 разделов
- Проблема, которую решает defer
- Порядок LIFO: как выполняются отложенные вызовы
- Аргументы вычисляются в момент объявления
- defer и именованный возврат: доработка результата
- Порядок событий вокруг return
- Defer в цикле: накопление до конца функции
- Где defer обязателен
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Проблема, которую решает defer#
Вспомните любую работу с ресурсами: открыли файл — надо закрыть, взяли блокировку — надо отпустить, подключились к базе — надо отключиться. Пока функция одна и прямолинейна, забыть трудно. Но у функции минимум три выхода: конец тела, ранний return по ошибке и паника. Без defer закрытие приходится дублировать в каждой ветке, и однажды одна из них окажется пропущенной — я видел такой баг в проде: соединение с базой утекало ровно в одной редкой ветке обработки ошибок.
Defer решает это структурно. Вызов объявляется один раз, рядом со строкой, где ресурс получен, а выполняется автоматически при любом выходе из функции. В коде точки «открыл» и «закрыл» стоят рядом, хотя в выполнении их разделяет вся функция.
В C до этого доходили через goto cleanup — метка в конце функции, куда сходились все ветки. Defer — та же идея, встроенная в язык и безопасная даже при панике.
Порядок LIFO: как выполняются отложенные вызовы#
Отложенные вызовы копятся в стек и выполняются в обратном порядке: последним объявлен — первым выполнен. LIFO (last in, first out) здесь не прихоть дизайнеров, а прямое следствие парности ресурсов. Открыли A, потом внутри открыли B — закрывать нужно сначала B, потом A. Блокировка, взятая внутри транзакции, отпускается раньше самой транзакции. Обратный порядок поддерживает вложенность автоматически, вам не надо ничего запоминать.
Практическое правило, которое сэкономит вам часы отладки: пишите defer сразу после строки, где ресурс получен. Между получением и объявлением отложенной уборки не должно быть ничего — ни цикла, ни условных веток, ни второго ресурса. Нарушите — в какой-то из веток паника случится раньше, чем объявлен defer, и уборка не сработает.
Три отложенных вызова
GOpackage main
import "fmt"
func main() {
defer fmt.Println("первый")
defer fmt.Println("второй")
fmt.Println("тело")
}Вызовы, помеченные defer, выполнились после Println("тело") и в обратном порядке: сначала «второй», потом «первый». Первый объявленный вызов лёг в самый низ стека и дождался своей очереди последним.
Попробуйте: третий в стеке
Прежде чем запускать, произнесите вслух порядок строк, который ожидаете. Затем запустите и сверьте. После этого перенесите третий defer в самое начало функции и предскажите новый вывод.
Запустите код, чтобы увидеть вывод.
Уборка при раннем выходе
GOpackage main
import "fmt"
func process(items []string) {
fmt.Println("подключение к базе")
defer fmt.Println("отключение от базы")
for _, it := range items {
fmt.Println("обработка:", it)
}
if len(items) == 0 {
return
}
fmt.Println("готово")
}
func main() {
process([]string{"заказ 1", "заказ 2"})
process([]string{})
}defer объявлен до цикла и до условного return — и всё равно выполняется при каждом выходе: и после «готово», и после раннего возврата на пустом срезе. Перенесите уборку вручную в конец функции — и вторую ветку вы забудете с вероятностью, растущей с каждой правкой.
Проверьте себя
Когда именно выполняется отложенный вызов defer?
Аргументы вычисляются в момент объявления#
Нюанс, на котором валится половина собеседований: аргументы отложенного вызова вычисляются тогда, когда выполняется строка defer, — а не когда вызов доходит до исполнения. Разберём на примере.
```go package main
import "fmt"
func main() { x := 1 defer fmt.Println("defer x =", x) // значение прочитано прямо здесь: 1 x = 100 fmt.Println("тело x =", x) } // Вывод в настоящем Go: // тело x = 100 // defer x = 1 ```
Строка с defer прочитала x, когда он был равен 1, — и сохранила это значение в стеке. Присваивание x = 100 уже ничего не меняет. При этом замыкание defer func() { ... }() захватывает переменную по ссылке и видит финальное значение — об этом следующий блок.
Браузерный интерпретатор вычисляет аргументы отложенного вызова в момент его исполнения, компилятор Go — в момент объявления. Поэтому пример из предыдущего блока мы сознательно не дали в редакторе: в песочнице он напечатал бы «defer x = 100». На замыканиях и на постоянных аргументах оба поведения совпадают — все runnable-примеры урока честны в обеих средах.
Что выведет код?
Прочитайте код и выберите, что он выведет:
package main
import "fmt"
func demo() {
x := 1
defer func() { fmt.Println("defer видит x =", x) }()
x = 100
fmt.Println("тело")
}
func main() {
demo()
}defer и именованный возврат: доработка результата#
Именованные возвращаемые значения меняют правила игры вместе с defer. Функция с сигнатурой (result int) возвращает переменную result, а оператор return без значения возвращает её текущее содержимое. Отложенное замыкание выполняется после возврата из тела, но до передачи значения вызывающему — и успевает изменить result.
Классический приём — доработка результата: счётчики вызовов, логирование, преобразования. И классическая же ловушка: замыкание в defer может незаметно подменить то, что вы аккуратно посчитали. Если ни одно замыкание в defer не трогает ваши именованные возвраты — механизм вам не мешает. Если трогает — теперь вы знаете, откуда берётся «магическое» значение на выходе.
Тот же механизм работает и в обратную сторону: библиотеки сериализации ошибок пишут в named return внутри defer, чтобы заменить сырую панику на ошибку — приём, о котором поговорим в уроке про панику и восстановление.
Порядок событий вокруг return#
Соберём механизм в одну картину. Оператор return в функции с именованным возвратом работает в три шага: сначала вычисляются возвращаемые выражения и записываются в переменные возврата, затем выполняются все отложенные вызовы по порядку LIFO, и только потом настоящий выход — значения из переменных возврата уходят вызывающему. Отсюда и возможность доработать результат: отложенный код исполняется после «возврата», но до того, как его кто-то увидел.
Из того же порядка следует и ограничение: defer не может предотвратить выход из функции (для этого нужен recover, о нём — следующий урок) и не может вернуть другое значение вместо заявленных результатов, если только не перепишет переменные возврата напрямую.
Запомните порядок «вычислил — отработал defer — вышел»: он же объясняет, почему замыкание в defer видит финальные значения, а обычный аргумент — зафиксированные.
Доработка результата через defer
GOpackage main
import "fmt"
func double(n int) (result int) {
defer func() { result *= 2 }()
result = n
return
}
func main() {
fmt.Println(double(21))
}Порядок событий: result = 21, return фиксирует возврат, затем срабатывает defer и удваивает переменную, вызывающий получает 42. Замыкание пишут именно в форме defer func() { ... }() — с парой скобок в конце, потому что defer требует вызова функции, а не её объявления.
Отчёт о работе через defer
Добавьте третий шаг в task и убедитесь, что отложенный отчёт посчитал его. Затем перенесите объявление defer в конец функции — отчёт сломается: строка с defer ещё не выполнилась к моменту выхода... разберитесь, почему.
Запустите код, чтобы увидеть вывод.
Defer в цикле: накопление до конца функции#
Defer в теле цикла — популярный способ выстрелить себе в ногу. Отложенные вызовы не выполняются на каждой итерации: они копятся и ждут конца объемлющей функции. Цикл на десять тысяч итераций с defer внутри к концу функции накопит десять тысяч отложенных вызовов — это и лишняя память, и внезапный «залп» уборки в обратном порядке, когда все ресурсы закрываются одновременно.
Идиоматичное решение — вынести тело цикла в отдельную функцию. Defer внутри неё живёт ровно одну итерацию: обработали элемент — убрались — перешли к следующему. Ниже этот паттерн вживую.
Тело цикла как функция
GOpackage main
import "fmt"
func handle(id int) {
fmt.Println("начали", id)
defer fmt.Println("закончили", id)
fmt.Println("обработка", id)
}
func main() {
for i := 1; i <= 3; i++ {
handle(i)
}
fmt.Println("всё")
}Уборка «закончили» выполняется после каждой итерации, потому что отложенный вызов объявлен в handle, а не в цикле. Достаточно перенести defer в main — и все три «закончили» отложатся до конца всей программы.
Поставьте уборку в стек
Допишите ключевое слово так, чтобы «ресурс закрыт» гарантированно напечатался последним — при любом выходе из функции.
Отложенные вызовы пропускаются при os.Exit: процесс умирает мгновенно, уборка не выполняется. То же верно для log.Fatal — внутри он вызывает os.Exit. Не смешивайте их с defer в одной функции. В песочнице os.Exit нет, но в реальном коде эта пара ломала не один сервис.
Где defer обязателен#
Три сценария, где defer — не украшение, а необходимость: файлы и соединения, мьютексы, перехват паник. С файлом идиома выглядит как f, err := os.Open(path), следом defer f.Close() — и ни одна ветка не утечёт. С мьютексом — mu.Lock() и тут же defer mu.Unlock(): без отложенной разблокировки её забывают в ветке с ошибкой, и однажды программа просто встаёт. Перехват паник — отдельный разговор: recover работает только из отложенной функции, так что без defer его не существует вовсе; см. урок про панику и восстановление.
Мнение о цене: до Go 1.13 defer был дорогим, и в горячем коде его избегали. Сейчас компилятор превращает большинство defer в почти бесплатный вызов, и я больше не пишу ручные ветки уборки ради производительности. Единственное исключение — код, который вызывается десятки миллионов раз в секунду: там сначала замерьте, потом решайте. Про блокировки подробнее — в уроке про мьютексы.
Соберите безопасное деление
Соберите функцию: отложенный отчёт объявляется сразу после сигнатуры, до всех веток return.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- defer ставит вызов в стек; исполнение — при любом выходе из объемлющей функции: конец тела, return, паника.
- Порядок LIFO: закрывается то, что открыто последним — вложенность ресурсов обслуживается автоматически.
- Аргументы обычного вызова вычисляются в строке defer; замыкание defer func(){}() видит финальные значения переменных.
- С именованным возвратом defer успевает доработать результат до передачи вызывающему.
- defer в цикле накапливается до конца функции — тело цикла выносят в отдельную функцию.
- os.Exit и log.Fatal пропускают отложенные вызовы; не смешивайте их с уборкой.
Домашнее задание
Напишите функцию counter() func() int из прошлого урока и функцию demo(), которая трижды вызывает счётчик, а результат каждого вызова печатает через defer-замыкание — отложенные строки соберут итоговый список вызовов в обратном порядке. Запустите в песочнице и сверьте порядок: счётчик копит значения вперёд, defer читает их назад.
Личные заметкиПусто
Вопросы и ответы по уроку
Чем defer отличается от вызова в конце функции?
Конец функции — лишь один из выходов: ранние return и паники его пропускают. Defer срабатывает на всех выходах, включая аварийные, поэтому для уборки ресурсов он надёжен, а ручной вызов в конце — нет.
Сколько defer можно объявить в одной функции?
Сколько угодно — все они встанут в стек и выполнятся в обратном порядке. Практический потолок — читаемость: больше трёх-четырёх отложенных вызовов подряд обычно признак того, что функцию пора разрезать на части.
Почему в песочнице defer иногда печатает не то, что в настоящем Go?
Интерпретатор вычисляет аргументы отложенного вызова при его исполнении, компилятор Go — при объявлении. Разница проявляется, только когда аргумент меняется между объявлением и выходом из функции. На замыканиях и постоянных аргументах поведение совпадает.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Fiber: экспресс-подобный веб-фреймворк на Fasthttp
Курс Fiber для Go: маршруты, middleware, JSON, CORS, WebSocket, шаблоны, лимитеры и тестирование. 25 уроков с примерами кода и итоговым REST API.
- Поднимать HTTP-сервер на Fiber за минуты
- Проектировать маршруты, группы и версии API
- Писать middleware: логи, CORS, лимиты, recovery
- Принимать и отдавать JSON с валидацией