1. Введение
Если вы только начинаете, очень хочется сделать так, чтобы программа “никогда не падала”. Это человеческое желание: падение выглядит как провал, а recover выглядит как суперспособность. Проблема в том, что падение иногда — это честный сигнал: “в коде баг”. А если мы начнём “ловить всё” везде, мы превратим баги в тихие, скользкие, дорогие проблемы.
Представьте, что у вас дома выбивает пробки. Есть два подхода: заменить пробки на “бесконечные” (чтобы никогда не выбивало), либо разобраться, почему у вас чайник коротит. recover — это не “бесконечная пробка” для всей квартиры. Это аккуратный щит на входе: если вдруг внутри что-то пошло совсем не так, мы не даём этому “взрыву” разнести пользовательский опыт, но и не притворяемся, что всё нормально.
В Go есть важная культурная норма: даже если пакет внутри использует panic, его внешний API обычно всё равно возвращает обычные error-значения (то есть “наружу — как люди, внутри — как получится”). Сегодня мы фиксируем границы, чтобы не перепутать “как получится” с “как надо”.
Граница приложения: где ставить recover
Когда говорят “граница приложения”, это звучит как что-то философское. На практике это очень приземлённо: место, где ваш код встречается с внешним миром и где вы обязаны превратить внутренние проблемы в понятный результат. В простом учебном приложении это чаще всего main() — единственная точка входа.
Почему именно там? Потому что recover не должен принимать бизнес‑решения. Он не должен решать “добавлять задачу или нет”, “валидный ли ввод”, “что делать, если не найдено”. Это всё нормальные ветки логики, их мы выражаем через error и if err != nil { ... }. recover должен делать другое: “если случилось невозможное — не позоримся перед пользователем трассировкой, а завершаем операцию контролируемо”.
Удобно держать в голове простую картинку слоёв:
flowchart TD
U[Пользователь / внешний мир] --> M["Граница приложения: main()"]
M --> A[Код приложения: функции, логика, модели]
A --> L[Библиотеки / stdlib]
L -->|error| A
A -->|error| M
M -->|сообщение пользователю| U
L -->|panic| A
A -->|panic раскрутка стека| M
M -->|recover + controlled fail| U
Смысл схемы в том, что error — это ожидаемый маршрут (как нормальная дорога), а panic — это аварийная эвакуация из здания. Эвакуацию не надо устраивать при каждом “не понравился ввод”. Эвакуация нужна, когда загорелось.
2. Правило: recover только на границе
Формулировка, которую полезно буквально проговаривать вслух, когда рука тянется к recover внутри логики:
recover должен стоять там, где вы готовы завершить операцию, а не “продолжать как ни в чём не бывало”.
Если вы делаете recover в середине бизнес‑кода, вы обычно не можете гарантировать, что состояние осталось корректным. Паника могла случиться посередине изменения данных. Или посередине вычисления. Или посередине “цепочки” из нескольких шагов. Да, в наших учебных примерах это будут 3 переменные и один слайс, но принцип тот же, что и в продакшене.
Минимальный “защитный шаблон” в стиле Go выглядит так: мы выносим реальную работу в run() (или appRun()), а в safeRun() ставим defer‑перехват паники и возвращаем ошибку.
package main
import (
"fmt"
)
func safeRun() (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("unexpected panic: %v", r)
}
}()
return run()
}
func main() {
if err := safeRun(); err != nil {
fmt.Println("error:", err) // error: unexpected panic: ...
}
}
Обратите внимание на важную деталь: после recover мы не пытаемся “доделать работу”. Мы говорим: “операция закончилась аварийно, вот ошибка”. Это соответствует механике: recover возвращает управление, но код после места паники уже не будет выполнен, а пытаться жить дальше на сомнительном состоянии — обычно плохая идея.
3. Ожидаемые проблемы: используем error
В этом месте у новичков часто случается “подмена понятий”. Кажется, что можно написать “супер‑универсально”: везде паниковать, а наверху ловить. Формально можно, но тогда вы теряете смысл ошибок как части контракта.
Есть очень простая проверка на здравый смысл.
- Если проблему можно предсказать и проверить условием — это кандидат на error.
- Если проблема означает “код попал в невозможное состояние” — это кандидат на panic.
Пример ожидаемой проблемы — деление на ноль, если делитель приходит от пользователя. Это не “невозможное состояние”, это обычный плохой ввод, и его нужно обработать через error:
package main
import (
"errors"
"fmt"
)
func divide(a, b int) (int, error) {
if b == 0 {
return 0, errors.New("division by zero")
}
return a / b, nil
}
func main() {
_, err := divide(10, 0)
fmt.Println("err:", err) // err: division by zero
}
И это важно: если вы в такой ситуации используете panic, вы как будто говорите: “пользователь сделал невозможное”. Но пользователь… пользователь умеет всё. Особенно ломать наши ожидания.
5. Почему recover в бизнес‑логике — анти‑паттерн
Сейчас мы специально посмотрим на “плохой” код. Не потому что вы обязаны так написать, а потому что полезно однажды увидеть и сказать: “ага, вот так делать не буду”.
Допустим, мы пишем маленькое приложение “TaskBox” — список задач. Мы храним задачи в слайсе, и кто-то пытается обновить задачу по индексу. Ошибка индекса — это типичная runtime‑паника (выход за границы).
Некоторые пытаются “лечить” это так:
package main
import "fmt"
func updateAt(tasks []string, idx int, title string) {
defer func() { _ = recover() }() // "лишь бы не упало"
tasks[idx] = title
fmt.Println("updated") // может не выполниться
}
Что здесь плохо, даже если программа “не упала”.
Во-первых, мы проглотили проблему молча. Вызывающий код не знает, что обновление не произошло. Он продолжит работать, как будто задача обновилась. Это уже не просто “ошибка”, это ложь.
Во-вторых, мы разрушили контракт функции. Снаружи updateAt выглядит как функция, которая обновляет задачу. Но на самом деле иногда она “ничего не делает”. Причём без сигнала. Это хуже, чем честная паника.
В-третьих, вы не контролируете, где именно случилась паника. Сегодня это индекс, завтра — другая ошибка. Вы начинаете скрывать баги, которые очень полезно находить на ранней стадии.
Отсюда практическое правило: recover в бизнес‑коде почти всегда превращается в “скрытый try/catch”, который просто маскирует проблемы. А наша цель — предсказуемость.
6. Компромисс: внутри panic, наружу error
Есть тонкий момент: иногда внутри функции реально удобно использовать panic как “быстрый выход” из глубокой вложенности, чтобы не протаскивать err через десять уровней, а наверху этого же компонента аккуратно превратить всё в error. В материалах по Go часто обсуждается идея, что даже если panic применяется внутри, наружный контракт остаётся ошибочным (через error).
Но здесь важны условия.
Этот подход работает, когда вы контролируете всю “капсулу”: вы точно знаете, что паника будет перехвачена в одном месте, и вы не даёте ей протечь наружу к пользователю. И второе: вы не используете это для ожидаемых ошибок ввода, потому что тогда вы просто заменяете if err != nil на “взрыв и ловлю”, а это ухудшение читаемости.
Мини‑пример в стиле “внутри — must, снаружи — error”. Мы намеренно делаем mustParseInt, которая паникует, а выше — безопасный слой, который возвращает ошибку:
package main
import (
"fmt"
"strconv"
)
func mustParseInt(s string) int {
n, err := strconv.Atoi(s)
if err != nil {
panic(err)
}
return n
}
func parseInt(s string) (n int, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("parse int %q: %v", s, r)
}
}()
return mustParseInt(s), nil
}
Такой приём допустим как внутренняя техника, но помните: это не замена нормальному error‑контракту. Это способ не размазывать обработку по низким уровням, если вы осознанно закрываете её наверху.
7. Чек‑лист: когда нельзя использовать recover
Этот раздел специально короткий и прикладной. Это не “теория исключений”, это защита от типичной ошибки “а давай везде поставим recover, и всё будет стабильно”.
Ниже — таблица‑напоминалка. Её удобно держать в голове, когда вы решаете “ловить или не ловить”.
| Ситуация | Почему нельзя recover | Что делать вместо этого |
|---|---|---|
| Плохой ввод пользователя (не то число, пустая строка, неверный формат) | Это ожидаемая ветка. “Ловить панику” тут — всё равно что тушить свечку пожарным краном | Возвращать (T, error) и обрабатывать if err != nil |
| “Не найдено” (ключа нет в map, элемента нет в списке) | Это не авария, а результат поиска. Паника скрывает бизнес‑смысл | Возвращать (T, bool) или (T, error) и делать понятный not found |
| Валидация данных (проверка ограничений, диапазонов, обязательных полей) | Валидация должна быть явной, иначе код становится непредсказуемым | Явные проверки + error с нормальным текстом |
| “Хотим продолжить работу после сбоя” | После паники состояние может быть частично изменено, продолжение часто опасно | Завершить операцию: вернуть ошибку, откатить изменения (если вы это проектировали) |
| “Спрячем баг, чтобы не падало на проде” | Так вы превращаете баг в тихую порчу данных и долгую охоту в темноте | Ловить панику только на границе, логировать/фиксировать, чинить причину |
| recover в библиотечной функции “на всякий случай” | Библиотека не должна решать UX и политику падений за приложение | Пусть паника поднимется к границе приложения, либо возвращайте error |
Заметьте, как это перекликается с общей идеей: recover уместен на верхнем уровне обработчика, чтобы не показывать пользователю “страшные” внутренности, а детали оставить разработчику.
8. Пример на TaskBox: ставим “щит на входе”
Сейчас сделаем маленькую, но важную архитектурную привычку. Мы не будем усложнять “TaskBox” файлами, сетями и прочим — нам достаточно функций и ошибок. Идея такая: main() — это вход, там мы ставим “щит”, а вся логика работает обычными error.
Пусть у нас есть команда add <title>, и мы хотим добавить задачу. Для простоты задачи — это строки.
Слой логики: никакого recover, только error.
package main
import "errors"
func addTask(tasks []string, title string) ([]string, error) {
if title == "" {
return tasks, errors.New("title must not be empty")
}
return append(tasks, title), nil
}
Теперь слой запуска, где мы делаем “безопасный вход”:
package main
import "fmt"
func safeRun() (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("internal error: %v", r)
}
}()
return run()
}
И наконец main() печатает результат пользователю:
package main
import "fmt"
func main() {
if err := safeRun(); err != nil {
fmt.Println(err) // internal error: ...
}
}
Почему это хороший стиль именно для новичка: вы сохраняете привычную модель мира “ошибки — это значения” и не превращаете программу в “паника‑машину”. При этом у вас появляется страховка: если вы где-то забыли проверить границу слайса, случайно получили nil там, где не ожидали, или сделали ещё какой-то “ой”, приложение не развалится некрасиво.
Как не надо: recover как try/catch
Иногда хочется сделать универсальную обёртку вокруг каждой функции: мол, “пусть всё будет safe”. Но это почти всегда ухудшает поддержку.
Представьте, что у вас ошибка “индекс вне диапазона”. Если она всплыла как паника, вы видите трассировку и быстро находите место. Если вы проглотили её где-то внизу, вы видите “что-то пошло не так” без контекста. И начинаете отлаживать как археолог: кисточкой, по песчинке.
Ещё важнее другое: если вы “продолжили” после recover (или сделали вид, что продолжили), вы можете получить повреждённое состояние. Пугающий факт разработки: повреждённое состояние может выглядеть “нормально” минуту, час, день — а потом выстрелить в совсем другом месте.
Поэтому хорошее правило звучит скучно, но спасает нервы: recover — это точка, где мы останавливаем текущую операцию и возвращаем контролируемый результат.
9. Типичные ошибки при выборе panic/recover
Ошибка №1: использовать recover для обычных ошибок (error).
Если вы ловите panic там, где надо было вернуть error, вы ломаете читаемость и контракт функции. Плохой ввод, “не найдено”, валидация — это всё обычные ветки, они должны быть выражены явно через if ... { return ..., err }.
Ошибка №2: ставить recover по всей кодовой базе.
Так вы превращаете любой баг в “тихий сбой”, а не в быстрый сигнал. В результате ошибки перестают быть воспроизводимыми и быстро диагностируемыми. Поставьте один recover на границе (обычно main) и считайте это страховочной сеткой, а не образом жизни.
Ошибка №3: после recover продолжать работу, как будто ничего не случилось.
Механически вы можете “перехватить” панику, но вы не можете гарантировать корректность промежуточного состояния. Правильный исход обычно один: остановить операцию, вернуть ошибку, дать пользователю предсказуемое сообщение.
Ошибка №4: проглотить panic value и потерять контекст.
Если уж вы ловите панику на границе, сформируйте ошибку с контекстом: что вы делали и что было в panic(...). Panic value — это “что принесли на место аварии”, не выбрасывайте его молча.
Ошибка №5: паниковать строкой “error”, без смысла и без деталей.
Да, panic("error") технически работает. Но если это действительно “невозможное состояние”, дайте понятное сообщение: что именно оказалось невозможным. Ваш будущий вы скажет вам спасибо (хотя бы перестанет ругаться).
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ