Раздел 22 · Go

Ошибки: error, defer, panic, обёртывание, Is/As/Join

junior~70 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Перед этим уроком

Ошибки: error, defer, panic, обёртывание, Is/As/Join

В Go нет исключений. Ошибки это значения, которые ты возвращаешь и обрабатываешь явно. Многословно? Да. Но взамен ты видишь все возможные сбои в коде глазами, без скрытого try-graf-а, без перехвата на 5 уровней выше.

Этот урок собирает все техники работы с ошибками в одну картину: что такое error как тип, как делать свои ошибки, как defer помогает закрывать ресурсы, что такое обёртывание %w, как errors.Is/As ищут конкретную ошибку в цепочке, и зачем errors.Join появился в Go 1.20.

После этого урока пойдёшь в 12-generics-basics, последний “технический” урок junior-трека.

1. error это интерфейс

type error interface {
    Error() string
}

Один метод, одна строка. Любой тип с Error() string это error.

Создавать ошибки можно тремя способами:

import "errors"
import "fmt"

err1 := errors.New("not found")                    // самая простая
err2 := fmt.Errorf("user %d not found", id)        // с форматированием
err3 := fmt.Errorf("get user: %w", err1)           // с обёртыванием (см. §6)

errors.New для статичной строки. fmt.Errorf когда хочешь подставить значения (или обернуть другую ошибку через %w).

Главная идиома: проверка после вызова

u, err := getUser(id)
if err != nil {
    return fmt.Errorf("update name: %w", err) // оборачиваем и пробрасываем выше
}
// дальше работаем с u

90% Go-кода выглядит как if err != nil { ... }. Это нормально, не борись с этим. Это видимая работа с ошибками, в обмен ты не ловишь сюрпризы из глубоких стеков.

2. Кастомные типы ошибок

Когда хочется не строку, а структурированную ошибку (с полями, с методами), делают свой тип:

type ValidationError struct {
    Field  string
    Reason string
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("validation: %s %s", e.Field, e.Reason)
}

func ValidateAge(n int) error {
    if n < 0 {
        return &ValidationError{Field: "age", Reason: "negative"}
    }
    if n > 150 {
        return &ValidationError{Field: "age", Reason: "too large"}
    }
    return nil
}

Заметь:

  • Метод Error на pointer receiver, по convention в Go-коде. Так пара (*ValidationError)(nil) распознаётся как nil-error (см. §3), и сравнение errors.As(err, &target) работает.
  • Возврат &ValidationError{...}, не ValidationError{...}. Указатель, иначе value receiver требовался бы.
  • Возврат nil literal, не var e *ValidationError; return e, иначе typed-nil ловушка из 10-interfaces.

Sentinel-ошибки

Если хочется одной “канонической” ошибки на весь пакет, объявляют sentinel на уровне пакета:

var ErrNotFound = errors.New("not found")

func GetUser(id int) (User, error) {
    // ...
    return User{}, ErrNotFound
}

Снаружи это сравнивают через errors.Is(err, ErrNotFound) (см. §7).

В stdlib примеры: io.EOF, sql.ErrNoRows, os.ErrNotExist.

3. Когда ничего не возвращать: nil как идиома

func foo() error {
    if все_хорошо {
        return nil  // настоящий nil-интерфейс
    }
    return errors.New("плохо")
}

Возвращать nil literal, не переменную с nil-указателем (см. урок 10 §8). Иначе err != nil сработает там, где не должен.

4. defer: LIFO и закрытие ресурсов

defer это отложенный вызов: функция, помеченная defer, вызовется в момент выхода из текущей функции, что бы ни случилось (return, panic, любой путь).

func ReadFile(path string) ([]byte, error) {
    f, err := os.Open(path)
    if err != nil {
        return nil, err
    }
    defer f.Close() // выполнится перед return, даже если io.ReadAll упадёт

    return io.ReadAll(f)
}

Это ровно то, что нужно для закрытия ресурсов: файлов, соединений, mutex-ов. Не забудешь, не пропустишь, потому что defer срабатывает автоматически.

Три правила defer

  1. LIFO-порядок. Несколько defer выполняются в обратном порядке от объявления:

    defer fmt.Println("1")
    defer fmt.Println("2")
    defer fmt.Println("3")
    // напечатает 3, 2, 1
  2. Аргументы вычисляются в момент defer, не в момент выполнения:

    x := 1
    defer fmt.Println("deferred:", x) // вычислится: "deferred: 1"
    x = 99
    // печатает: deferred: 1

    Если хочешь, чтобы захватилось текущее, оборачивай в анонимную функцию:

    defer func() { fmt.Println("deferred:", x) }() // 99
  3. defer работает только в текущей функции, не в цикле всего блока. Это главная ловушка:

    for _, path := range files {
        f, _ := os.Open(path)
        defer f.Close() // ← все Close выполнятся в конце функции, не итерации
    }

    Накопится N открытых файлов до конца. Лечение, выносить в отдельную функцию:

    for _, path := range files {
        processFile(path) // defer внутри, в конце processFile
    }

defer и err через named return

Defer может поправить возвращаемое значение, если оно named:

func ReadConfig(path string) (cfg Config, err error) {
    f, err := os.Open(path)
    if err != nil {
        return
    }
    defer func() {
        if cerr := f.Close(); cerr != nil && err == nil {
            err = cerr
        }
    }()

    // парсинг...
    return cfg, nil
}

Замысел: если Close() вернул ошибку, и до этого ошибки не было, вернуть Close-ошибку. Если уже была ошибка чтения, оставить её. Это идиома “не маскируй первую ошибку второй”.

5. panic и recover: только для unrecoverable

panic это аварийный механизм. Программа печатает стек и падает:

panic("something is very wrong")

Когда паниковать обязательно:

  • Программная инвариантность нарушена. “Этот if никогда не должен срабатывать, если он сработал, мы не знаем что делать.”
  • Init-этап. “Не смогли загрузить конфиг, продолжать бессмысленно.”
  • stdlib делает это сама. Деление на 0, выход за границы массива, обращение по nil-указателю.

Когда нельзя:

  • Бизнес-логика. “Юзер не найден”, это error, не паника.
  • Невалидный ввод. Возвращай error.
  • Network-ошибка. Тем более.

recover

Чтобы поймать панику в текущей горутине, есть recover. Работает только в defer:

func safeDo(work func()) (err error) {
    defer func() {
        if r := recover(); r != nil {
            err = fmt.Errorf("panicked: %v", r)
        }
    }()
    work()
    return nil
}

Шаблон с recover можно увидеть в HTTP-middleware: ловят панику в обработчике, чтобы не упал весь сервер. Подробно в 20-http-services.

recover в горутине

recover ловит только панику той горутины, в которой стоит. Если дочерняя горутина паникует, родительская не узнает:

go func() {
    panic("oops") // упадёт вся программа
}()

Каждая горутина обязана защитить себя, если есть риск паники. Подробно в 17-concurrency-deep.

премиум-урок

Продолжение в премиуме: 1 200 ₽ в месяц или 10 000 ₽ в год. Вместе с ним серверные проверки задач и школьные токены AI-коуча.

Оплата пока не подключена: премиум и курсы открываются промокодом.