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

Ошибки, panic и recover

Go отказался от исключений в пользу явных error values. Это формирует стиль: проверяй ошибку сразу, оборачивай (wrap) при пробрасывании, не паникуй в библиотечном коде. На собесе джуну зададут: разница errors.Is vs errors.As, что такое %w, когда panic уместен, и почему recover работает только в defer.

  1. Базовое определение и API
  2. Внутреннее устройство (ПОД КАПОТОМ)
  3. Тонкие моменты / Gotchas
  4. Производительность / Memory considerations
  5. Типичные вопросы на собеседовании Junior
  6. Practice — мини-задачки
  7. Источники

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 — просто форматирование (теряет тип).

if errors.Is(err, os.ErrNotExist) {
// ...
}

Is проходит по цепочке Unwrap() и сравнивает каждое звено с target. Заменяет прямое err == os.ErrNotExist, которое не работает с wrapped errors.

var pathErr *fs.PathError
if errors.As(err, &pathErr) {
fmt.Println("path:", pathErr.Path)
}

As ищет в цепочке ошибку, совместимую по типу с target (target — указатель на переменную нужного типа).

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 чтобы пройти по цепочке.

Константные значения ошибок, экспортируемые пакетом:

package io
var EOF = errors.New("EOF")
n, err := r.Read(buf)
if errors.Is(err, io.EOF) {
// конец данных
}

⚠️ Sentinel errors создают API contract — менять/удалять их = breaking change.

type NotFoundError struct {
ID int64
}
func (e *NotFoundError) Error() string {
return fmt.Sprintf("not found: %d", e.ID)
}
err := &NotFoundError{ID: 42}
var nfe *NotFoundError
if errors.As(err, &nfe) {
fmt.Println("nfe.ID:", nfe.ID)
}

Объединение нескольких ошибок:

err := errors.Join(errA, errB, errC)
// err.Error() = "errA\nerrB\nerrC"
// errors.Is(err, errA) → true (проходит по всем)
panic("something wrong") // паникует
panic(errors.New("oops")) // паника с error значением
defer func() {
if r := recover(); r != nil {
fmt.Println("recovered:", r)
}
}()

⚠️ recover() работает только внутри defer-функции.


type error interface { Error() string }

Никакой магии. Любой объект с методом Error() string — error. Под капотом — обычный iface (см. файл 08).

// 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") // false

Sentinel errors решают это, имея один общий адрес (var io.EOF = errors.New("EOF")).

С 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)

Упрощённо:

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 так делает).

Похожая логика, но сравнение по типу:

  • идёт по цепочке Unwrap;
  • на каждом шаге пытается target = err;
  • если совместимо — записывает и возвращает true.

target обязательно указатель на переменную, в которую записывать.

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():

  • проверяет, есть ли активная паника на стеке текущей горутины;
  • если есть и вызывается из 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
}()
import "runtime/debug"
defer func() {
if r := recover(); r != nil {
log.Printf("panic: %v\n%s", r, debug.Stack())
}
}()

debug.Stack() возвращает текущий stack trace как []byte.


Каждый вызов создаёт новый объект. Сравнивать через ==, только если оба указывают на одну sentinel-переменную.

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 типа.

err := fmt.Errorf("ctx: %v", inner) // НЕ wrap — теряем тип
err := fmt.Errorf("ctx: %w", inner) // wrap — errors.Is/As работают

В пробрасываниях всегда %w. %v — только если намеренно не хотим связи.

// До 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.

func main() {
r := recover() // nil
fmt.Println(r)
panic("x")
}
data, _ := os.ReadFile("foo") // ❌ потеряли ошибку

Идиоматичный Go — никогда не игнорировать ошибку без причины. Если действительно безопасно — комментарий: data, _ := ... // ignore, fallback below.

⚠️ Gotcha 8: Sentinel error в публичном API — навсегда

Заголовок раздела «⚠️ Gotcha 8: Sentinel error в публичном API — навсегда»
package mypkg
var 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.

Если поймали панику и решили её “пробросить дальше” — panic(r):

defer func() {
if r := recover(); r != nil {
log.Println("got:", r)
panic(r) // re-panic
}
}()
var pe *PathError
if errors.As(err, pe) { } // ❌ NOT a pointer to type
if errors.As(err, &pe) { } // OK

Тип может реализовать 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
}
err := fmt.Errorf("ctx: %w", nil)

err НЕ будет nil! Это объект, обёрнутый nil. Проверяйте перед wrap’ом.


Каждый errors.New("x") — это heap alloc (объект error). В hot path не вызывайте в цикле — создайте sentinel сверху.

fmt.Errorf использует пакет fmt — больше аллокаций (форматирование строки + объект). На горячем пути — комбинируйте предсозданные ошибки с явным wrapping.

В худшем случае O(глубина цепочки). Глубина редко > 5-10. Не критично.

panic и разворачивание стека — медленные (несколько микросекунд + GC pressure от созданного _panic объекта). Никогда не используйте panic как механизм нормальной обработки.

Сборка stack trace — медленно (~100мкс). Используйте только при логировании реальных ошибок.

import "log/slog"
slog.Error("op failed", "err", err, "user", uid)

Структурированное логирование. Эффективнее log.Printf для production.


  1. Что такое error в Go? Интерфейс с одним методом Error() string. Любой тип, реализующий его, — error.

  2. Почему в Go нет исключений? Дизайн-выбор: явные error values делают код предсказуемым, control flow виден. Это упрощает code review и поддержку.

  3. Чем errors.New отличается от fmt.Errorf? errors.New(s) — простая ошибка с заданным текстом. fmt.Errorf — с форматированием, поддерживает %w для wrapping.

  4. Что делает %w в fmt.Errorf? Оборачивает inner error, добавляя метод Unwrap(). Позволяет errors.Is и errors.As пройти по цепочке.

  5. Разница errors.Is и errors.As? Is — сравнивает с конкретным значением (sentinel). As — пытается извлечь конкретный тип из цепочки.

  6. Что такое sentinel error? Экспортируемая константная ошибка пакета. Пример: io.EOF. Сравнение с ней — через errors.Is.

  7. Как сделать кастомный тип ошибки? Объявить struct, реализовать метод Error() string. Опционально — Unwrap(), Is(), As().

  8. Когда уместен panic?

    • Programmer error (нарушенный invariant, “не должно произойти”).
    • Init-фаза (нечего восстанавливать).
    • Из библиотеки — почти никогда.
  9. Что такое recover? Функция, прерывающая разворачивание стека во время паники. Возвращает значение panic’а. Работает только внутри defer-функции.

  10. Можно ли recover вне defer? Можно вызвать, но всегда вернёт nil.

  11. Ловит ли recover в main горутине панику другой горутины? Нет. Каждая горутина имеет свой стек defer’ов. Защищайте каждую горутину собственным defer recover.

  12. Что произойдёт с программой при паника в неотловленной горутине? Программа упадёт с stack trace всех горутин.

  13. Как обернуть ошибку с контекстом? fmt.Errorf("operation X failed: %w", err).

  14. Что такое errors.Join (Go 1.20+)? Объединяет несколько ошибок в одну. errors.Is/As проходят по всем.

  15. Что такое typed-nil error и как его избежать? Когда возвращаешь nil-указатель custom error типа — interface не nil. Нужно явно return nil.

  16. Как получить stack trace из panic? runtime/debug.Stack() внутри defer recover().

  17. Что такое Unwrap метод? Метод Unwrap() error на типе ошибки. Возвращает обёрнутую ошибку. Используется errors.Is/As.

  18. Разница log.Fatal и panic? log.Fatal — печатает сообщение и os.Exit(1). Не выполняет defer’ы. panic — выполняет defer’ы (можно поймать).

  19. Где обрабатывать ошибку: внизу или вверху? Обрабатывать там, где есть контекст для решения. До этого — оборачивать с %w и пробрасывать.

  20. Pkg/errors устарел? Да, после Go 1.13 стандартная библиотека покрывает основное. Wrapping через %w, errors.Is, errors.As. Pkg/errors всё ещё иногда встречается в старых проектах.


err1 := errors.New("x")
err2 := errors.New("x")
fmt.Println(err1 == err2)
Ответ `false`. Разные адреса.
var ErrFoo = errors.New("foo")
err := fmt.Errorf("ctx: %w", ErrFoo)
fmt.Println(errors.Is(err, ErrFoo))
Ответ `true`. Цепочка распакована через Unwrap.
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.

Напишите функцию 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 } ```

Сделайте кастомный 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
```go
func work() {
defer func() {
if r := recover(); r != nil {
fmt.Println("rec:", r)
}
}()
panic("boom")
}
work()
fmt.Println("after")
Ответ ``` rec: boom after ```
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 из вложенной горутины.
e1 := errors.New("inner")
e2 := fmt.Errorf("middle: %w", e1)
e3 := fmt.Errorf("outer: %w", e2)
fmt.Println(errors.Is(e3, e1))
Ответ `true`. Проход по цепочке.
e := errors.Join(errors.New("a"), errors.New("b"))
fmt.Println(e.Error())
Ответ ``` a b ``` (элементы через `\n`).

Напишите хелпер 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() }() } ```

Когда возвращать sentinel error, а когда — кастомный тип?

Ответ - Sentinel: когда хочется простое сравнение "та или не та" ошибка (io.EOF). - Custom тип: когда нужно нести данные (Code, ID, Path) — потребитель извлекает через errors.As.

Допустимо ли вызывать panic в func init()?

Ответ Да, иногда. Если конфиг битый и продолжать смысла нет — init может паниковать. Программа не запустится.

В чём проблема?

func process(data []byte) (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("recovered: %v", r)
}
}()
// ... тело
return nil
}
Ответ Никакой! Это валидный паттерн — конвертация panic в error. Используется в защитном коде (например, парсеры).

Что предпочесть в main?

Ответ log.Fatal проще: печатает + os.Exit(1) сразу. Panic выполняет defer'ы, может быть пойман. В main для критических ошибок — log.Fatal норм. Для библиотек — error returns.

Реализуйте Is для кастомного типа, чтобы две ошибки с одинаковым Code считались равными.

Ответ ```go type APIErr struct{ Code int; Msg string } func (e *APIErr) Error() string { return e.Msg } func (e *APIErr) Is(target error) bool { t, ok := target.(*APIErr) return ok && t.Code == e.Code } ```

  1. Go Blog — Working with Errors in Go 1.13
  2. Effective Go — Errors
  3. Dave Cheney — Don’t just check errors, handle them gracefully
  4. errors package documentation
  5. Go 1.20 Release Notes — errors.Join, multiple %w