Урок курса

defer: порядок выполнения и отличие ожидаемой ошибки от panic

Golang с нуля

В прошлом уроке функции начали возвращать error как второе значение, а вызывающий код проверял if err != nil. Это позволило явно обрабатывать предсказуемые сбои. Сейчас добавим к этому ещё один инструмент — defer, — а потом разберёмся, что такое panic и почему это совсем другое.

defer: регистрация отложенного вызова и порядок LIFO

Когда Go встречает строку defer f(), он не вызывает f немедленно. Он запоминает вызов и откладывает его до момента выхода из текущей функции — через return, через достижение конца тела, или из-за аварии (panic). Аргументы при этом вычисляются сразу, в момент регистрации, а не в момент выполнения.

Посмотрим на простой пример:

package main

import "fmt"

func countdown() {
    defer fmt.Println("три")
    defer fmt.Println("два")
    defer fmt.Println("один")
    fmt.Println("пуск")
}

func main() {
    countdown()
}

Вывод:

пуск
один
два
три

Go накапливает зарегистрированные defer в стек. Когда функция завершается, этот стек раскручивается: последний добавленный вызов выполняется первым. Отсюда LIFO — Last In, First Out.

Почему именно стек? Если в одной функции несколько defer, то порядок их выполнения зеркален порядку регистрации. В примере выше "один" зарегистрирован последним — он и выводится первым, затем "два", затем "три". Это предсказуемо и не зависит от того, через какой именно return вышла функция.

Важный нюанс с аргументами:

func show() {
    x := 1
    defer fmt.Println("x при регистрации:", x) // запомнит значение 1
    x = 99
    fmt.Println("x сейчас:", x)
}

Вывод:

x сейчас: 99
x при регистрации: 1

Значение x было вычислено и сохранено в момент выполнения строки defer, а не в момент выхода из функции. Последующее изменение x на 99 уже не влияет на то, что выведет отложенный вызов.

defer как гарантия финального действия при любом пути выхода

Представь функцию, которая проверяет входные данные и может вернуться из нескольких точек:

func process(name string) error {
    defer fmt.Println("process завершён:", name)

    if name == "" {
        return fmt.Errorf("имя не может быть пустым")
    }
    if len(name) > 50 {
        return fmt.Errorf("имя слишком длинное")
    }
    fmt.Println("обработка:", name)
    return nil
}

func main() {
    err := process("Alice")
    if err != nil {
        fmt.Println("ошибка:", err)
    }

    err = process("")
    if err != nil {
        fmt.Println("ошибка:", err)
    }
}

Вывод:

обработка: Alice
process завершён: Alice
process завершён:
ошибка: имя не может быть пустым

defer зарегистрирован первой строкой тела — ещё до любых проверок. Поэтому он сработает при любом return: и при успешном, и при раннем выходе с ошибкой.

Теперь сравни с наивным альтернативным решением:

func processNaive(name string) error {
    if name == "" {
        return fmt.Errorf("имя не может быть пустым") // финального сообщения нет
    }
    fmt.Println("обработка:", name)
    fmt.Println("process завершён:", name) // до этой строки при раннем return не доберёмся
    return nil
}

При return из-за пустого имени последняя строка просто не выполнится. defer решает именно эту проблему: финальное действие отвязано от конкретного пути выполнения и прикреплено к самому факту выхода из функции.

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

panic vs error: аварийная раскрутка стека против ожидаемого сбоя

error — это значение. Функция его возвращает, вызывающий код получает, смотрит на него и решает, что делать: повторить запрос, залогировать, вернуть выше. Программа продолжает работать в штатном режиме.

panic работает принципиально иначе. Когда Go встречает panic("что-то сломалось"), обычное выполнение текущей функции прекращается немедленно. Дальше происходит раскрутка стека вызовов: Go поднимается по цепочке вызвавших функций, и в каждой из них выполняет зарегистрированные defer. После того как стек раскручен до верхнего уровня, программа завершается аварийно и печатает сообщение о панике и трассировку.

Вот минимальный пример:

package main

import "fmt"

func riskyOp() {
    panic("что-то сломалось")
}

func main() {
    defer fmt.Println("defer в main сработал")
    riskyOp()
}

Вывод будет выглядеть примерно так (конкретные пути и номера строк зависят от окружения):

defer в main сработал
panic: что-то сломалось

goroutine 1 [running]:
main.riskyOp(...)
    .../main.go:...
main.main()
    .../main.go:...
exit status 2

Посмотри на порядок: сначала при раскрутке стека выполняется defer fmt.Println, поэтому появляется строка defer в main сработал. Затем, поскольку panic не была перехвачена, программа печатает panic: что-то сломалось и трассировку и завершается. Это подтверждает, что defer срабатывает и во время раскрутки стека из-за panic, а не только при обычном return.

Почему нельзя использовать panic вместо error для обычных ошибок?

Во-первых, вызывающий код теряет контроль. Если функция вернула error, вызывающий может его проверить, обработать или передать выше. Если функция сделала panic, вызывающий код не может перехватить это обычным if err != nil — программа уже летит к завершению.

Во-вторых, это нарушает соглашение Go. Невалидный ввод, недоступная запись, несовпадение формата — всё это ожидаемые ситуации, для которых язык предоставляет error. panic предназначен для ситуаций, которые означают ошибку самого программиста или нарушение инварианта, который «никогда не должен произойти» при правильной логике программы.

Практически: если ты пишешь функцию divide(a, b int) и b == 0 — возвращай error. Если где-то внутри твоей структуры данных обнаруживается состояние, которое физически не может возникнуть при корректной работе кода, — panic уместен как сигнал о баге в самой программе, а не о внешней ситуации.

Попробуйте решить

В какой момент выполняется функция, зарегистрированная через defer?

Продолжить с проверкой и прогрессом

Откройте интерактивный раннер с заданиями урока.

Перейти к интерактивному уроку