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

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 2 из 25 ~20 мин +50 XP

Отложенные вызовы defer

Ключевое слово defer в Go откладывает вызов функции до выхода из текущей функции — и делает это так, что код уборки пишется рядом с открытием ресурса. Разберём порядок LIFO, момент вычисления аргументов, доработку результата через именованный возврат и классические баги с defer в цикле: все примеры запускаются прямо на странице.

Содержание урока · 10 разделов

Проблема, которую решает defer#

Вспомните любую работу с ресурсами: открыли файл — надо закрыть, взяли блокировку — надо отпустить, подключились к базе — надо отключиться. Пока функция одна и прямолинейна, забыть трудно. Но у функции минимум три выхода: конец тела, ранний return по ошибке и паника. Без defer закрытие приходится дублировать в каждой ветке, и однажды одна из них окажется пропущенной — я видел такой баг в проде: соединение с базой утекало ровно в одной редкой ветке обработки ошибок.

Defer решает это структурно. Вызов объявляется один раз, рядом со строкой, где ресурс получен, а выполняется автоматически при любом выходе из функции. В коде точки «открыл» и «закрыл» стоят рядом, хотя в выполнении их разделяет вся функция.

В C до этого доходили через goto cleanup — метка в конце функции, куда сходились все ветки. Defer — та же идея, встроенная в язык и безопасная даже при панике.

Порядок LIFO: как выполняются отложенные вызовы#

Отложенные вызовы копятся в стек и выполняются в обратном порядке: последним объявлен — первым выполнен. LIFO (last in, first out) здесь не прихоть дизайнеров, а прямое следствие парности ресурсов. Открыли A, потом внутри открыли B — закрывать нужно сначала B, потом A. Блокировка, взятая внутри транзакции, отпускается раньше самой транзакции. Обратный порядок поддерживает вложенность автоматически, вам не надо ничего запоминать.

Практическое правило, которое сэкономит вам часы отладки: пишите defer сразу после строки, где ресурс получен. Между получением и объявлением отложенной уборки не должно быть ничего — ни цикла, ни условных веток, ни второго ресурса. Нарушите — в какой-то из веток паника случится раньше, чем объявлен defer, и уборка не сработает.

Три отложенных вызова

GO
package main

import "fmt"

func main() {
        defer fmt.Println("первый")
        defer fmt.Println("второй")
        fmt.Println("тело")
}

Вызовы, помеченные defer, выполнились после Println("тело") и в обратном порядке: сначала «второй», потом «первый». Первый объявленный вызов лёг в самый низ стека и дождался своей очереди последним.

Попробуйте: третий в стеке

Прежде чем запускать, произнесите вслух порядок строк, который ожидаете. Затем запустите и сверьте. После этого перенесите третий defer в самое начало функции и предскажите новый вывод.

Вывод

Запустите код, чтобы увидеть вывод.

Уборка при раннем выходе

GO
package 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() { ... }() захватывает переменную по ссылке и видит финальное значение — об этом следующий блок.

Что выведет код?

Прочитайте код и выберите, что он выведет:

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

GO
package 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 внутри неё живёт ровно одну итерацию: обработали элемент — убрались — перешли к следующему. Ниже этот паттерн вживую.

Тело цикла как функция

GO
package 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 — и все три «закончили» отложатся до конца всей программы.

Поставьте уборку в стек

Допишите ключевое слово так, чтобы «ресурс закрыт» гарантированно напечатался последним — при любом выходе из функции.

package main import "fmt" func withResource() { fmt.Println("ресурс открыт") fmt.Println("ресурс закрыт") fmt.Println("работа с ресурсом") } func main() { withResource() }

Где 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 читает их назад.

Личные заметкиПусто
Хранятся только в вашем браузере (Go Pro · Отложенные вызовы defer)

Вопросы и ответы по уроку

Чем defer отличается от вызова в конце функции?

Конец функции — лишь один из выходов: ранние return и паники его пропускают. Defer срабатывает на всех выходах, включая аварийные, поэтому для уборки ресурсов он надёжен, а ручной вызов в конце — нет.

Сколько defer можно объявить в одной функции?

Сколько угодно — все они встанут в стек и выполнятся в обратном порядке. Практический потолок — читаемость: больше трёх-четырёх отложенных вызовов подряд обычно признак того, что функцию пора разрезать на части.

Почему в песочнице defer иногда печатает не то, что в настоящем Go?

Интерпретатор вычисляет аргументы отложенного вызова при его исполнении, компилятор Go — при объявлении. Разница проявляется, только когда аргумент меняется между объявлением и выходом из функции. На замыканиях и постоянных аргументах поведение совпадает.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Следующая ступень учебного пути

Fiber25 уроков~13 чпрактика25 уроков уже доступны

Fiber: экспресс-подобный веб-фреймворк на Fasthttp

Курс Fiber для Go: маршруты, middleware, JSON, CORS, WebSocket, шаблоны, лимитеры и тестирование. 25 уроков с примерами кода и итоговым REST API.

  • Поднимать HTTP-сервер на Fiber за минуты
  • Проектировать маршруты, группы и версии API
  • Писать middleware: логи, CORS, лимиты, recovery
  • Принимать и отдавать JSON с валидацией