Обработка ошибок в 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, остальные разбираются в тренажёре.
- Когда уместен panic, а не возврат error?A)Когда ошибку неудобно прокидывать через много уровней вызововB)Для ошибок ввода-вывода — сети, диска, обращений к БДC)Для неисправимых ситуаций: нарушен инвариант, баг программистаD)Когда функция ничего не возвращает и добавлять error лень
показать ответ и разбор
+C)Для неисправимых ситуаций: нарушен инвариант, баг программиста// разбор: panic — для НЕИСПРАВИМЫХ ситуаций и ошибок программиста: нарушенный инвариант, невозможное состояние, обращение к неинициализированному. Ожидаемые ошибки (нет файла, сеть отвалилась, кривой ввод) — это error: их возвращают и обрабатывают. Библиотеки почти никогда не паникуют наружу — panic внутри принято ловить recover на границе и превращать в error.
- Функция получила error от вызова ниже. Что с ним делать по идиоме Go?A)И залогировать здесь, и вернуть выше — так надёжнее ничего не потерятьB)Логировать на месте и возвращать nil на уровень вышеC)Обработать один раз: либо обернуть и вернуть, либо обработать тутD)Сразу пробросить panic'ом, чтобы не тащить error по коду
показать ответ и разбор
+C)Обработать один раз: либо обернуть и вернуть, либо обработать тут// разбор: Ошибку обрабатывают ОДИН раз на всём пути. Обычно её обогащают контекстом (fmt.Errorf("...: %w", err)) и возвращают выше, а логируют/показывают пользователю единожды — на верхнем уровне, где есть контекст решения. И залогировать, и вернуть — двойная запись одной ошибки (шум в логах). Глотать (вернуть nil) или паниковать на штатной ошибке — плохо.
- Функция залогировала ошибку и вернула её вызывающему коду. Что не так?A)Логировать ошибку в момент её получения запрещено соглашениями языкаB)Ошибка будет обработана дважды: в логе появится дубликат на каждом уровнеC)Возврат после логирования теряет исходный тип ошибки для errors.IsD)Логирование внутри функции мешает компилятору встроить её вызов
показать ответ и разбор
+B)Ошибка будет обработана дважды: в логе появится дубликат на каждом уровне// разбор: Обрабатывать ошибку нужно один раз: либо разобрался на месте, либо отдал наверх с контекстом. Если логировать на каждом уровне и всё равно возвращать, один сбой размажется десятком записей в логе, и разбор инцидента превращается в поиск первой строки. Практика такая: внизу оборачивают через %w, а пишут в лог там, где решают, что делать.
- Чем 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.
- Почему в Go не стали делать исключения, а возвращают ошибки значениями?A)Исключения несовместимы с горутинами и сборщиком мусораB)Пути отказа видны в сигнатуре и в коде, а не спрятаны в невидимых переходахC)Возврат значений работает быстрее, чем раскрутка стека при исключенииD)Так проще компилятору: не нужно поддерживать блоки обработки исключений
показать ответ и разбор
+B)Пути отказа видны в сигнатуре и в коде, а не спрятаны в невидимых переходах// разбор: Ошибка в сигнатуре — часть контракта: читая вызов, видно, что он может не получиться, и обработка стоит рядом с местом сбоя. Исключения дают короткий счастливый путь ценой невидимых переходов управления. Плата за выбор Go — многословность (тот самый if err != nil), а panic оставлен для по-настоящему невосстановимого.
дальше
Теорию прочитали. Навык ставится повторением
В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.