Урок курса
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 и обработки ошибок в Go. Попробуйте задачу на порядок отложенных вызовов и ранний return, затем — на обёртку ошибки и errors.Is.
Задача 1
В какой момент выполняется функция, зарегистрированная через defer?
Задача 2
Почему не следует использовать panic вместо error для обычных ошибок, например «неверный ввод»?
Задача 3
Что выведет следующая программа? Введите ровно те строки, которые появятся в stdout, каждую с новой строки.
package main
import "fmt"
func f() {
defer fmt.Println("A")
defer fmt.Println("B")
fmt.Println("C")
}
func main() {
f()
fmt.Println("D")
}
Задача 4
Журнал обработки
В производственном журнале каждая операция должна оставлять чёткий след: сколько позиций прошло обработку и каков итог сеанса. Оператор вводит одно целое число, отражающее количество обработанных позиций, и система обязана зафиксировать результат в строго определённом порядке.
Правила журналирования требуют, чтобы два завершающих сообщения регистрировались заранее, однако выводились в обратном порядке относительно момента их постановки в очередь. Это означает, что второе зарегистрированное сообщение появится первым, а первое — последним, независимо от того, как именно завершилась операция.
Что нужно сделать
Допишите функцию process. Сначала зарегистрируйте вывод закрыть журнал, затем — сохранить итог. Из-за LIFO при выходе строки появятся в обратном порядке. При n < 0 верните errors.New("отрицательное количество") до основного сообщения. Иначе выведите обработано: N и верните nil.
Формат ввода
Одно целое число.
Формат вывода
При неотрицательном числе: сообщение об обработке, затем сохранить итог и закрыть журнал. При отрицательном: оба финальных сообщения, затем сообщение об ошибке.
Пример
Ввод: 3 Вывод: обработано: 3 сохранить итог закрыть журнал
Без регистрации. Гостевая проверка не сохраняет прогресс. Для сохранения перейдите к уроку.
Отправьте решение, чтобы увидеть результаты тестов.
Задача 5
Закрытие запущенных этапов
Когда сложная операция разбивается на несколько последовательных этапов, каждый из них требует аккуратного завершения — вне зависимости от того, чем закончился весь процесс. Пропустить закрытие хотя бы одного этапа значит оставить систему в неопределённом состоянии, поэтому порядок завершения не менее важен, чем порядок запуска.
Пользователь задаёт количество этапов, которые нужно запустить последовательно. Каждый запущенный этап немедленно ставится в очередь на закрытие, и эта очередь раскручивается строго в обратном порядке: последний запущенный закрывается первым. Именно такая симметрия гарантирует, что ни один этап не окажется открытым после завершения работы.
Если число этапов выходит за допустимые пределы, программа сообщает об этом как об обычной ошибке и останавливается. В остальных случаях — будь то полный успех или досрочный выход из-за рядовой ошибки в середине работы — в конце всегда появляется итоговое сообщение. Наблюдая за выводом программы, можно убедиться, что закрытия действительно следуют в ожидаемом обратном порядке и финальная строка не теряется ни при каком из допустимых сценариев.
Что уже дано
Программа получает число этапов и выводит запуск каждого этапа в цикле.
Что нужно сделать
Допишите stopStages. При допустимом числе от 1 до 3 регистрируйте через defer закрытие каждого уже запущенного этапа. При выходе закрытия должны появиться в обратном порядке. Одно финальное сообщение сеанс завершён обязано появиться и при раннем возврате обычной ошибки. Для недопустимого числа верните errors.New("некорректное число этапов"); panic использовать нельзя.
Ввод и вывод
Программа читает одно целое число из stdin. Она печатает запуск этапов, затем отложенные закрытия и итоговую строку.
Без регистрации. Гостевая проверка не сохраняет прогресс. Для сохранения перейдите к уроку.
Отправьте решение, чтобы увидеть результаты тестов.
Задачи к уроку
- defer в Go: порядок вызовов, аргументы и ранний return
Когда выполняется defer и почему вызовы идут в обратном порядке? Короткий пример Go и задача на завершение этапов.
Продолжить с сохранением прогресса
Перейдите к уроку в учебном кабинете.
