Ошибки, panic и recover
Go отказался от исключений в пользу явных error values. Это формирует стиль: проверяй ошибку сразу, оборачивай (wrap) при пробрасывании, не паникуй в библиотечном коде. На собесе джуну зададут: разница
errors.Isvserrors.As, что такое%w, когда panic уместен, и почемуrecoverработает только вdefer.
Содержание
Заголовок раздела «Содержание»- Базовое определение и API
- Внутреннее устройство (ПОД КАПОТОМ)
- Тонкие моменты / Gotchas
- Производительность / Memory considerations
- Типичные вопросы на собеседовании Junior
- Practice — мини-задачки
- Источники
1. Базовое определение и API
Заголовок раздела «1. Базовое определение и API»error интерфейс
Заголовок раздела «error интерфейс»type error interface { Error() string}Любой тип, реализующий Error() string, — это error.
Создание ошибок
Заголовок раздела «Создание ошибок»import "errors"
err1 := errors.New("file not found")err2 := fmt.Errorf("failed to open %s: %v", name, err1)Идиоматичная обработка
Заголовок раздела «Идиоматичная обработка»data, err := os.ReadFile("foo.txt")if err != nil { return fmt.Errorf("read foo: %w", err) // %w = wrap}// use data⚠️ %w (Go 1.13+) — оборачивает ошибку, сохраняя цепочку. %v — просто форматирование (теряет тип).
errors.Is — сравнение с target
Заголовок раздела «errors.Is — сравнение с target»if errors.Is(err, os.ErrNotExist) { // ...}Is проходит по цепочке Unwrap() и сравнивает каждое звено с target. Заменяет прямое err == os.ErrNotExist, которое не работает с wrapped errors.
errors.As — извлечение конкретного типа
Заголовок раздела «errors.As — извлечение конкретного типа»var pathErr *fs.PathErrorif errors.As(err, &pathErr) { fmt.Println("path:", pathErr.Path)}As ищет в цепочке ошибку, совместимую по типу с target (target — указатель на переменную нужного типа).
Unwrap() метод
Заголовок раздела «Unwrap() метод»type WrappedError struct { Op string Err error}func (e *WrappedError) Error() string { return e.Op + ": " + e.Err.Error() }func (e *WrappedError) Unwrap() error { return e.Err }errors.Is и errors.As используют Unwrap чтобы пройти по цепочке.
Sentinel errors
Заголовок раздела «Sentinel errors»Константные значения ошибок, экспортируемые пакетом:
package iovar EOF = errors.New("EOF")n, err := r.Read(buf)if errors.Is(err, io.EOF) { // конец данных}⚠️ Sentinel errors создают API contract — менять/удалять их = breaking change.
Custom error types
Заголовок раздела «Custom error types»type NotFoundError struct { ID int64}func (e *NotFoundError) Error() string { return fmt.Sprintf("not found: %d", e.ID)}
err := &NotFoundError{ID: 42}var nfe *NotFoundErrorif errors.As(err, &nfe) { fmt.Println("nfe.ID:", nfe.ID)}errors.Join (Go 1.20+)
Заголовок раздела «errors.Join (Go 1.20+)»Объединение нескольких ошибок:
err := errors.Join(errA, errB, errC)// err.Error() = "errA\nerrB\nerrC"// errors.Is(err, errA) → true (проходит по всем)panic и recover
Заголовок раздела «panic и recover»panic("something wrong") // паникуетpanic(errors.New("oops")) // паника с error значением
defer func() { if r := recover(); r != nil { fmt.Println("recovered:", r) }}()⚠️ recover() работает только внутри defer-функции.
2. Внутреннее устройство (ПОД КАПОТОМ)
Заголовок раздела «2. Внутреннее устройство (ПОД КАПОТОМ)»error — это просто interface
Заголовок раздела «error — это просто interface»type error interface { Error() string }Никакой магии. Любой объект с методом Error() string — error. Под капотом — обычный iface (см. файл 08).
errors.New и fmt.Errorf
Заголовок раздела «errors.New и fmt.Errorf»// errors.New (упрощённо):func New(text string) error { return &errorString{text}}type errorString struct { s string }func (e *errorString) Error() string { return e.s }Каждый вызов errors.New("x") создаёт новую структуру с новым адресом. Поэтому два таких объекта не равны:
errors.New("x") == errors.New("x") // falseSentinel errors решают это, имея один общий адрес (var io.EOF = errors.New("EOF")).
fmt.Errorf и %w
Заголовок раздела «fmt.Errorf и %w»С Go 1.13 fmt.Errorf распознаёт один %w глагол. Когда он есть — возвращаемый объект реализует Unwrap() error.
err := fmt.Errorf("op: %w", inner)// err.(interface{ Unwrap() error }).Unwrap() → innerС Go 1.20+ можно использовать несколько %w:
err := fmt.Errorf("got %w and %w", e1, e2)// err.Unwrap() возвращает []error (отличается от стандартного Unwrap() error)Реализация errors.Is
Заголовок раздела «Реализация errors.Is»Упрощённо:
func Is(err, target error) bool { if target == nil { return err == target } for { if isComparable && err == target { return true } if x, ok := err.(interface{ Is(error) bool }); ok && x.Is(target) { return true } // распаковываем switch x := err.(type) { case interface{ Unwrap() error }: err = x.Unwrap() if err == nil { return false } case interface{ Unwrap() []error }: // multi-error for _, e := range x.Unwrap() { if Is(e, target) { return true } } return false default: return false } }}Также: тип может реализовать собственный метод Is(error) bool для кастомной логики сравнения (например, os.SyscallError так делает).
Реализация errors.As
Заголовок раздела «Реализация errors.As»Похожая логика, но сравнение по типу:
- идёт по цепочке
Unwrap; - на каждом шаге пытается
target = err; - если совместимо — записывает и возвращает true.
target обязательно указатель на переменную, в которую записывать.
panic под капотом
Заголовок раздела «panic под капотом»panic(v) создаёт runtime._panic объект в стеке горутины и инициирует разворачивание стека:
panic("boom") ↓runtime создаёт _panic{arg: "boom", link: prev} ↓goroutine начинает идти по defer-цепочке: - exec defer #N - exec defer #N-1 - ... ↓если recover() в каком-то defer — паника останавливается, функция возвращается нормальноесли recover не было — runtime печатает stack trace и завершает программуruntime.Goexit() тоже разворачивает стек (с выполнением defer’ов), но не печатает panic.
recover
Заголовок раздела «recover»recover():
- проверяет, есть ли активная паника на стеке текущей горутины;
- если есть и вызывается из
defer-функции — возвращает значение panic’а и помечает панику как обработанную; - если нет — возвращает nil;
- если вызвана не из defer — возвращает nil независимо от того, есть паника или нет.
defer func() { r := recover() // OK, мы в defer // ...}()r := recover() // плохо, вне defer — всегда nilСтек паники в горутинах
Заголовок раздела «Стек паники в горутинах»Каждая горутина имеет свой стек defer’ов и свою panic-цепочку. recover в одной горутине не ловит panic в другой.
defer recoverHandler()go func() { panic("x") // не поймается родительским defer}()debug.Stack()
Заголовок раздела «debug.Stack()»import "runtime/debug"defer func() { if r := recover(); r != nil { log.Printf("panic: %v\n%s", r, debug.Stack()) }}()debug.Stack() возвращает текущий stack trace как []byte.
3. Тонкие моменты / Gotchas
Заголовок раздела «3. Тонкие моменты / Gotchas»⚠️ Gotcha 1: errors.New(“x”) == errors.New(“x”) → false
Заголовок раздела «⚠️ Gotcha 1: errors.New(“x”) == errors.New(“x”) → false»Каждый вызов создаёт новый объект. Сравнивать через ==, только если оба указывают на одну sentinel-переменную.
⚠️ Gotcha 2: typed-nil error
Заголовок раздела «⚠️ Gotcha 2: typed-nil error»type E struct{}func (e *E) Error() string { return "e" }
func bad() error { var e *E // nil return e}
err := bad()fmt.Println(err == nil) // false!Это классический gotcha. Решение: возвращать nil явно, не nil-указатель custom типа.
⚠️ Gotcha 3: %v vs %w
Заголовок раздела «⚠️ Gotcha 3: %v vs %w»err := fmt.Errorf("ctx: %v", inner) // НЕ wrap — теряем типerr := fmt.Errorf("ctx: %w", inner) // wrap — errors.Is/As работаютВ пробрасываниях всегда %w. %v — только если намеренно не хотим связи.
⚠️ Gotcha 4: %w только один раз (до 1.20)
Заголовок раздела «⚠️ Gotcha 4: %w только один раз (до 1.20)»// До Go 1.20:fmt.Errorf("%w %w", a, b) // ❌ ошибка, можно только один %wС Go 1.20+ можно несколько. Тогда Unwrap() []error.
⚠️ Gotcha 5: panic на горутине роняет программу
Заголовок раздела «⚠️ Gotcha 5: panic на горутине роняет программу»go func() { panic("oops") }()Если в горутине нет defer recover, паника роняет всю программу. Стандартный паттерн: запускать горутины через хелпер с defer recover.
⚠️ Gotcha 6: recover вне defer — бесполезен
Заголовок раздела «⚠️ Gotcha 6: recover вне defer — бесполезен»func main() { r := recover() // nil fmt.Println(r) panic("x")}⚠️ Gotcha 7: Игнорирование ошибок
Заголовок раздела «⚠️ Gotcha 7: Игнорирование ошибок»data, _ := os.ReadFile("foo") // ❌ потеряли ошибкуИдиоматичный Go — никогда не игнорировать ошибку без причины. Если действительно безопасно — комментарий: data, _ := ... // ignore, fallback below.
⚠️ Gotcha 8: Sentinel error в публичном API — навсегда
Заголовок раздела «⚠️ Gotcha 8: Sentinel error в публичном API — навсегда»package mypkgvar ErrNotFound = errors.New("not found")Если потребители делают errors.Is(err, mypkg.ErrNotFound), переименование/удаление = breaking change.
⚠️ Gotcha 9: panic для управления потоком — антипаттерн
Заголовок раздела «⚠️ Gotcha 9: panic для управления потоком — антипаттерн»// ПЛОХОfunc parse(s string) int { if !valid(s) { panic("invalid") } return ...}Используется panic как “throw”. Это не Go-way. Возвращайте error.
⚠️ Gotcha 10: defer-recover-rethrow
Заголовок раздела «⚠️ Gotcha 10: defer-recover-rethrow»Если поймали панику и решили её “пробросить дальше” — panic(r):
defer func() { if r := recover(); r != nil { log.Println("got:", r) panic(r) // re-panic }}()⚠️ Gotcha 11: errors.As требует указатель на target
Заголовок раздела «⚠️ Gotcha 11: errors.As требует указатель на target»var pe *PathErrorif errors.As(err, pe) { } // ❌ NOT a pointer to typeif errors.As(err, &pe) { } // OK⚠️ Gotcha 12: Custom Is/As методы
Заголовок раздела «⚠️ Gotcha 12: Custom Is/As методы»Тип может реализовать Is(target error) bool для кастомной логики (например, сравнение по полю). Тогда errors.Is использует его.
type MyErr struct{ Code int }func (e *MyErr) Error() string { return ... }func (e *MyErr) Is(target error) bool { t, ok := target.(*MyErr) return ok && t.Code == e.Code}⚠️ Gotcha 13: Wrapping nil
Заголовок раздела «⚠️ Gotcha 13: Wrapping nil»err := fmt.Errorf("ctx: %w", nil)err НЕ будет nil! Это объект, обёрнутый nil. Проверяйте перед wrap’ом.
4. Производительность / Memory considerations
Заголовок раздела «4. Производительность / Memory considerations»errors.New — аллокация
Заголовок раздела «errors.New — аллокация»Каждый errors.New("x") — это heap alloc (объект error). В hot path не вызывайте в цикле — создайте sentinel сверху.
fmt.Errorf — дороже
Заголовок раздела «fmt.Errorf — дороже»fmt.Errorf использует пакет fmt — больше аллокаций (форматирование строки + объект). На горячем пути — комбинируйте предсозданные ошибки с явным wrapping.
errors.Is / errors.As — обход цепочки
Заголовок раздела «errors.Is / errors.As — обход цепочки»В худшем случае O(глубина цепочки). Глубина редко > 5-10. Не критично.
panic/recover — дорого
Заголовок раздела «panic/recover — дорого»panic и разворачивание стека — медленные (несколько микросекунд + GC pressure от созданного _panic объекта). Никогда не используйте panic как механизм нормальной обработки.
Stack trace через debug.Stack()
Заголовок раздела «Stack trace через debug.Stack()»Сборка stack trace — медленно (~100мкс). Используйте только при логировании реальных ошибок.
slog (Go 1.21+) для логирования
Заголовок раздела «slog (Go 1.21+) для логирования»import "log/slog"slog.Error("op failed", "err", err, "user", uid)Структурированное логирование. Эффективнее log.Printf для production.
5. Типичные вопросы на собеседовании Junior
Заголовок раздела «5. Типичные вопросы на собеседовании Junior»-
Что такое error в Go? Интерфейс с одним методом
Error() string. Любой тип, реализующий его, — error. -
Почему в Go нет исключений? Дизайн-выбор: явные error values делают код предсказуемым, control flow виден. Это упрощает code review и поддержку.
-
Чем
errors.Newотличается отfmt.Errorf?errors.New(s)— простая ошибка с заданным текстом.fmt.Errorf— с форматированием, поддерживает%wдля wrapping. -
Что делает
%wвfmt.Errorf? Оборачивает inner error, добавляя методUnwrap(). Позволяетerrors.Isиerrors.Asпройти по цепочке. -
Разница
errors.Isиerrors.As?Is— сравнивает с конкретным значением (sentinel).As— пытается извлечь конкретный тип из цепочки. -
Что такое sentinel error? Экспортируемая константная ошибка пакета. Пример:
io.EOF. Сравнение с ней — черезerrors.Is. -
Как сделать кастомный тип ошибки? Объявить struct, реализовать метод
Error() string. Опционально —Unwrap(),Is(),As(). -
Когда уместен panic?
- Programmer error (нарушенный invariant, “не должно произойти”).
- Init-фаза (нечего восстанавливать).
- Из библиотеки — почти никогда.
-
Что такое recover? Функция, прерывающая разворачивание стека во время паники. Возвращает значение panic’а. Работает только внутри
defer-функции. -
Можно ли recover вне defer? Можно вызвать, но всегда вернёт
nil. -
Ловит ли recover в main горутине панику другой горутины? Нет. Каждая горутина имеет свой стек defer’ов. Защищайте каждую горутину собственным
defer recover. -
Что произойдёт с программой при паника в неотловленной горутине? Программа упадёт с stack trace всех горутин.
-
Как обернуть ошибку с контекстом?
fmt.Errorf("operation X failed: %w", err). -
Что такое errors.Join (Go 1.20+)? Объединяет несколько ошибок в одну.
errors.Is/Asпроходят по всем. -
Что такое
typed-nil errorи как его избежать? Когда возвращаешь nil-указатель custom error типа — interface не nil. Нужно явноreturn nil. -
Как получить stack trace из panic?
runtime/debug.Stack()внутриdefer recover(). -
Что такое Unwrap метод? Метод
Unwrap() errorна типе ошибки. Возвращает обёрнутую ошибку. Используетсяerrors.Is/As. -
Разница
log.Fatalиpanic?log.Fatal— печатает сообщение иos.Exit(1). Не выполняет defer’ы.panic— выполняет defer’ы (можно поймать). -
Где обрабатывать ошибку: внизу или вверху? Обрабатывать там, где есть контекст для решения. До этого — оборачивать с
%wи пробрасывать. -
Pkg/errors устарел? Да, после Go 1.13 стандартная библиотека покрывает основное. Wrapping через
%w,errors.Is,errors.As. Pkg/errors всё ещё иногда встречается в старых проектах.
6. Practice — мини-задачки
Заголовок раздела «6. Practice — мини-задачки»Задача 1
Заголовок раздела «Задача 1»err1 := errors.New("x")err2 := errors.New("x")fmt.Println(err1 == err2)Ответ
`false`. Разные адреса.Задача 2
Заголовок раздела «Задача 2»var ErrFoo = errors.New("foo")err := fmt.Errorf("ctx: %w", ErrFoo)fmt.Println(errors.Is(err, ErrFoo))Ответ
`true`. Цепочка распакована через Unwrap.Задача 3 — typed-nil
Заголовок раздела «Задача 3 — typed-nil»type MyErr struct{}func (e *MyErr) Error() string { return "e" }
func get() error { var e *MyErr return e}
err := get()fmt.Println(err == nil)Ответ
`false`. Typed-nil gotcha.Задача 4
Заголовок раздела «Задача 4»Напишите функцию divide(a, b int) (int, error) возвращающую ошибку при делении на ноль.
Ответ
```go var ErrDivZero = errors.New("division by zero") func divide(a, b int) (int, error) { if b == 0 { return 0, ErrDivZero } return a / b, nil } ```Задача 5
Заголовок раздела «Задача 5»Сделайте кастомный error NotFoundError{ID} и используйте errors.As.
Ответ
```go type NotFoundError struct{ ID int } func (e *NotFoundError) Error() string { return fmt.Sprintf("not found: %d", e.ID) }err := fmt.Errorf(“op: %w”, &NotFoundError{ID: 42}) var nfe *NotFoundError if errors.As(err, &nfe) { fmt.Println(nfe.ID) } // 42
</details>
### Задача 6```gofunc work() { defer func() { if r := recover(); r != nil { fmt.Println("rec:", r) } }() panic("boom")}work()fmt.Println("after")Ответ
``` rec: boom after ```Задача 7
Заголовок раздела «Задача 7»func work() { defer func() { if r := recover(); r != nil { fmt.Println("rec:", r) } }() go func() { panic("g") }() time.Sleep(time.Second)}work()fmt.Println("after")Ответ
Программа упадёт. recover в work не ловит panic из вложенной горутины.Задача 8 — wrap + unwrap
Заголовок раздела «Задача 8 — wrap + unwrap»e1 := errors.New("inner")e2 := fmt.Errorf("middle: %w", e1)e3 := fmt.Errorf("outer: %w", e2)fmt.Println(errors.Is(e3, e1))Ответ
`true`. Проход по цепочке.Задача 9 — Join (Go 1.20+)
Заголовок раздела «Задача 9 — Join (Go 1.20+)»e := errors.Join(errors.New("a"), errors.New("b"))fmt.Println(e.Error())Ответ
``` a b ``` (элементы через `\n`).Задача 10
Заголовок раздела «Задача 10»Напишите хелпер safeGo(f func()), который запускает горутину с recover.
Ответ
```go func safeGo(f func()) { go func() { defer func() { if r := recover(); r != nil { log.Printf("panic in goroutine: %v\n%s", r, debug.Stack()) } }() f() }() } ```Задача 11
Заголовок раздела «Задача 11»Когда возвращать sentinel error, а когда — кастомный тип?
Ответ
- Sentinel: когда хочется простое сравнение "та или не та" ошибка (io.EOF). - Custom тип: когда нужно нести данные (Code, ID, Path) — потребитель извлекает через errors.As.Задача 12 — panic при init
Заголовок раздела «Задача 12 — panic при init»Допустимо ли вызывать panic в func init()?
Ответ
Да, иногда. Если конфиг битый и продолжать смысла нет — init может паниковать. Программа не запустится.Задача 13
Заголовок раздела «Задача 13»В чём проблема?
func process(data []byte) (err error) { defer func() { if r := recover(); r != nil { err = fmt.Errorf("recovered: %v", r) } }() // ... тело return nil}Ответ
Никакой! Это валидный паттерн — конвертация panic в error. Используется в защитном коде (например, парсеры).Задача 14 — log.Fatal vs panic
Заголовок раздела «Задача 14 — log.Fatal vs panic»Что предпочесть в main?
Ответ
log.Fatal проще: печатает + os.Exit(1) сразу. Panic выполняет defer'ы, может быть пойман. В main для критических ошибок — log.Fatal норм. Для библиотек — error returns.Задача 15
Заголовок раздела «Задача 15»Реализуйте Is для кастомного типа, чтобы две ошибки с одинаковым Code считались равными.