сеньорчикОткрыть в Telegram
← вся теориятеория к собесу · Ошибки и рантайм Go

Обработка ошибок в Go

Ошибки как значения

В Go нет исключений. Функция, которая может не справиться, возвращает вторым значением error, и вызывающий обязан на это посмотреть. Отсюда знаменитые три строчки после каждого вызова, над которыми принято ворчать - и отсюда же свойство, за которое Go любят: глядя на код, видно каждую точку, где всё может пойти не так.

Стержень: ошибка - обычное значение обычного интерфейсного типа; её либо обрабатывают, либо возвращают выше с контекстом, но не то и другое сразу.

// Формулировки: «почему в Go нет исключений?», «что делать с ошибкой снизу?», «зачем возвращать ошибку, если её всё равно логируют?»

Почему значения, а не исключения

Исключение - невидимый путь исполнения. Смотришь на строчку вызова и не знаешь, вылетит ли отсюда управление куда-то на пять уровней вверх. В Go этот путь сделан видимым: ошибка возвращается наравне с результатом, и её нельзя случайно не заметить.

Сам интерфейс минимален: один метод Error() string, и всё. Любой тип с этим методом - ошибка. Никакой иерархии классов исключений, никаких catch по типу - вместо этого обычные значения, которые можно сравнивать, оборачивать и передавать.

Цена честная: многословность. Три строчки после каждого вызова - реальность Go-кода, и с этим просто живут. Выигрыш - отсутствие невидимых выходов из функции и одинаковая форма обработки во всём коде: сначала ошибка, потом счастливый путь.

// Отсюда и стиль: сначала проверка ошибки с ранним возвратом, потом основная логика. Код читается сверху вниз без вложенности, а счастливый путь всегда прижат к левому краю.

Обработать один раз

Главное правило работы с ошибкой: у неё один обработчик. Либо ты с ней справился здесь, либо вернул выше - и тогда решает вызывающий. Оба действия сразу означают, что решения нет ни у кого.

Самое частое нарушение выглядит невинно: залогировал и вернул. В итоге одна проблема даёт три-четыре записи в журнале на разных уровнях, каждая со своим текстом, и разбирать инцидент приходится по обрывкам. Логирует тот, кто принимает решение, - обычно это обработчик запроса или главный цикл, самый верх.

Второе нарушение - проглотить ошибку через подчёркивание. Иногда это осознанно (закрытие файла, открытого только на чтение), и тогда рядом ставят комментарий. Чаще это просто «потом разберусь», которое живёт годами.

// Возвращая ошибку наверх, добавляй контекст: что именно делал. Не «not found», а «загрузка профиля пользователя 42: not found». Тогда по одной строчке лога понятно, где искать, без пляски со стеком.

// плохо: два обработчика на одну ошибку
if err != nil {
    log.Printf("не удалось прочитать: %v", err)
    return err   // выше залогируют ещё раз
}

// хорошо: добавили контекст и отдали решение выше
if err != nil {
    return fmt.Errorf("чтение профиля %d: %w", id, err)
}

Контракт возврата

Договорённость, на которую опирается весь Go-код: если ошибка не nil, остальные возвращаемые значения считаются негодными. Не «пустыми», не «частично заполненными» - негодными. Вызывающий обязан выйти, а не пытаться что-то из них извлечь.

Обратное тоже верно: если ошибка nil, результат обязан быть валидным. Функция, возвращающая иногда nil-ошибку с nil-результатом, ломает контракт, и её пользователи начнут писать двойные проверки на всякий случай.

Создают ошибки двумя способами. errors.New для простого текста - обычно на уровне пакета, в переменную. fmt.Errorf, когда нужна подстановка или обёртывание. Текст ошибки в Go пишут с маленькой буквы и без точки в конце: он почти всегда становится частью более длинной строки, и заглавная буква посреди фразы выглядит странно.

// Ошибка - часть публичного контракта пакета не меньше, чем сигнатуры. Если пользователи должны уметь отличать «не найдено» от «нет прав», это надо предусмотреть заранее: sentinel-значением или своим типом. Разбор текста ошибки строкой - признак того, что автор пакета об этом не подумал.

Как отвечать: «Что делать с ошибкой, полученной от вызова ниже?»

Одно из двух, и никогда оба сразу. Либо я обрабатываю её здесь - подставляю значение по умолчанию, повторяю попытку, возвращаю запасной ответ - и тогда наверх она уже не идёт. Либо возвращаю выше, обернув в контекст через fmt.Errorf с глаголом %w, чтобы сохранить оригинал для errors.Is и errors.As. Что я точно не делаю - не логирую и не возвращаю одновременно: это даёт три записи в журнале об одной проблеме и ни одного места, где принято решение. Логирует тот, кто решает, обычно самый верх - обработчик запроса. Контекст при обёртывании даю конкретный: что делал и с чем, например «чтение профиля 42». Тогда финальная строка лога сама рассказывает путь запроса. И держу в голове контракт: если ошибка не nil, остальные возвращённые значения негодны, доставать из них ничего нельзя.

Названо правило одного обработчика с объяснением, почему двойное действие вредно, показано оборачивание с сохранением цепочки и упомянут контракт возврата.

На чём валят

  • Логируют ошибку и возвращают её же - одна проблема даёт четыре записи в журнале.
  • Возвращают ошибку без контекста, и наверху остаётся голое «not found» без адреса.
  • Проглатывают ошибку через подчёркивание без комментария, почему это безопасно.
  • Используют значения, возвращённые вместе с ненулевой ошибкой.
  • Разбирают текст ошибки строкой вместо errors.Is или своего типа.
  • Пишут текст ошибки с заглавной буквы и точкой - он же попадёт в середину чужой строки.

Проверьте себя

Пять вопросов из банка по этой подтеме. Всего их 8, остальные разбираются в тренажёре.

  1. #go_err_idiom1 / 5
    Когда уместен panic, а не возврат error?
    A)Когда ошибку неудобно прокидывать через много уровней вызовов
    B)Для ошибок ввода-вывода — сети, диска, обращений к БД
    C)Для неисправимых ситуаций: нарушен инвариант, баг программиста
    D)Когда функция ничего не возвращает и добавлять error лень
    показать ответ и разбор
    +C)Для неисправимых ситуаций: нарушен инвариант, баг программиста

    // разбор: panic — для НЕИСПРАВИМЫХ ситуаций и ошибок программиста: нарушенный инвариант, невозможное состояние, обращение к неинициализированному. Ожидаемые ошибки (нет файла, сеть отвалилась, кривой ввод) — это error: их возвращают и обрабатывают. Библиотеки почти никогда не паникуют наружу — panic внутри принято ловить recover на границе и превращать в error.

  2. #go_err_idiom2 / 5
    Функция получила error от вызова ниже. Что с ним делать по идиоме Go?
    A)И залогировать здесь, и вернуть выше — так надёжнее ничего не потерять
    B)Логировать на месте и возвращать nil на уровень выше
    C)Обработать один раз: либо обернуть и вернуть, либо обработать тут
    D)Сразу пробросить panic'ом, чтобы не тащить error по коду
    показать ответ и разбор
    +C)Обработать один раз: либо обернуть и вернуть, либо обработать тут

    // разбор: Ошибку обрабатывают ОДИН раз на всём пути. Обычно её обогащают контекстом (fmt.Errorf("...: %w", err)) и возвращают выше, а логируют/показывают пользователю единожды — на верхнем уровне, где есть контекст решения. И залогировать, и вернуть — двойная запись одной ошибки (шум в логах). Глотать (вернуть nil) или паниковать на штатной ошибке — плохо.

  3. #go_err_idiom3 / 5
    Функция залогировала ошибку и вернула её вызывающему коду. Что не так?
    A)Логировать ошибку в момент её получения запрещено соглашениями языка
    B)Ошибка будет обработана дважды: в логе появится дубликат на каждом уровне
    C)Возврат после логирования теряет исходный тип ошибки для errors.Is
    D)Логирование внутри функции мешает компилятору встроить её вызов
    показать ответ и разбор
    +B)Ошибка будет обработана дважды: в логе появится дубликат на каждом уровне

    // разбор: Обрабатывать ошибку нужно один раз: либо разобрался на месте, либо отдал наверх с контекстом. Если логировать на каждом уровне и всё равно возвращать, один сбой размажется десятком записей в логе, и разбор инцидента превращается в поиск первой строки. Практика такая: внизу оборачивают через %w, а пишут в лог там, где решают, что делать.

  4. #go_err_idiom4 / 5
    Чем errors.New отличается от fmt.Errorf?
    A)errors.New работает быстрее за счёт кэширования одинаковых текстов
    B)errors.New возвращает значение, а fmt.Errorf — указатель на ошибку
    C)errors.New пригоден только для sentinel-ошибок уровня пакета
    D)errors.New создаёт ошибку из готовой строки, fmt.Errorf форматирует её
    показать ответ и разбор
    +D)errors.New создаёт ошибку из готовой строки, fmt.Errorf форматирует её

    // разбор: errors.New берёт готовый текст, fmt.Errorf собирает его по шаблону — и умеет главное: с глаголом %w заворачивает исходную ошибку внутрь, сохраняя её для errors.Is и errors.As. Отсюда разделение на практике: неизменные sentinel-значения пакета объявляют через errors.New, а по дороге наверх обогащают контекстом через fmt.Errorf с %w.

  5. #go_err_idiom5 / 5
    Почему в Go не стали делать исключения, а возвращают ошибки значениями?
    A)Исключения несовместимы с горутинами и сборщиком мусора
    B)Пути отказа видны в сигнатуре и в коде, а не спрятаны в невидимых переходах
    C)Возврат значений работает быстрее, чем раскрутка стека при исключении
    D)Так проще компилятору: не нужно поддерживать блоки обработки исключений
    показать ответ и разбор
    +B)Пути отказа видны в сигнатуре и в коде, а не спрятаны в невидимых переходах

    // разбор: Ошибка в сигнатуре — часть контракта: читая вызов, видно, что он может не получиться, и обработка стоит рядом с местом сбоя. Исключения дают короткий счастливый путь ценой невидимых переходов управления. Плата за выбор Go — многословность (тот самый if err != nil), а panic оставлен для по-настоящему невосстановимого.

дальше

Теорию прочитали. Навык ставится повторением

В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.