Во многих языках ошибка — это исключение: где-то глубоко что-то пошло не так,
управление «выпрыгивает» из функции и летит вверх по стеку, пока кто-нибудь его
не поймает. В Go всё устроено проще и честнее: ошибка — это обычное значение,
которое функция возвращает наравне с результатом. Никакой магии, никакого
невидимого потока управления. Хочешь обработать — обрабатываешь прямо здесь,
по месту.
Из этого вытекает вся идиоматика главы: проверка if err != nil, создание
ошибок через errors.New и fmt.Errorf, аккуратное оборачивание одной ошибки
в другую с помощью %w, и разбор «что именно случилось» через errors.Is и
errors.As. В конце разберём panic/recover — механизм, который похож на
исключения, но нужен крайне редко, и поймём, почему его не стоит тащить в
повседневный код.
В стандартной библиотеке есть встроенный интерфейс error. Он крошечный:
type error interface { Error() string}
Любой тип, у которого есть метод Error() string, уже является ошибкой. Когда
функция может не справиться, она по соглашению возвращает errorпоследним
значением. Всё хорошо — возвращаем nil. Что-то пошло не так — возвращаем не-nil
ошибку, а полезный результат оставляем нулевым.
Вызывающий код сразу же это значение проверяет. Отсюда самая узнаваемая
конструкция в Go:
strconv.Atoi возвращает два значения: число и ошибку. Мы первым делом смотрим
на err. Если она не nil — печатаем и выходим, не трогая n (там лежит ноль,
доверять ему нельзя). Если nil — спокойно работаем с числом. Запустите пример:
строка "42" разбирается, на экране будет разобрали: 84.
Поменяйте в коде "42" на "abc" — и сработает ветка с ошибкой. Это и есть весь
смысл подхода: путь успеха и путь ошибки видны рядом, в одном месте, без
спрятанных прыжков.
Новичков иногда смущает, что таких проверок в коде много. Это нормально и
осознанно: Go делает обработку ошибок явной, чтобы её нельзя было случайно
пропустить. Базовый шаблон такой:
result, err := doSomething()if err != nil { // обработали: вернули выше, залогировали, подставили запасной вариант… return err}// сюда дошли — значит err == nil и result валиден
Несколько практичных правил:
Проверяйте ошибку сразу после вызова, пока контекст свежий.
Пока err != nil, считайте остальные возвращённые значения мусором.
Чаще всего ошибку не «глотают», а возвращают выше — пусть решает тот, кто
вызвал. Решение «что делать» обычно принимается ближе к верху программы.
Когда ваша собственная функция хочет сообщить о проблеме, ошибку нужно создать.
Простейший способ — errors.New с фиксированным текстом:
package mainimport ( "errors" "fmt")func half(n int) (int, error) { if n%2 != 0 { return 0, errors.New("число нечётное, пополам не делится") } return n / 2, nil}func main() { for _, n := range []int{8, 7} { res, err := half(n) if err != nil { fmt.Printf("half(%d): %v\n", n, err) continue } fmt.Printf("half(%d) = %d\n", n, res) }}
Вывод:
half(8) = 4half(7): число нечётное, пополам не делится
Если в текст нужно подставить данные (что за значение, какой файл, какой код),
берут fmt.Errorf — это как fmt.Sprintf, только результат сразу error:
return fmt.Errorf("не делится: получили %d", n)
Маленькое, но важное соглашение про текст ошибки: пишите его с маленькой буквы и
без точки в конце. Причина — ошибки часто склеивают друг с другом в цепочку, и
красивее, когда «внешний контекст: внутренняя причина» читается одной строкой,
а не как набор предложений с заглавными буквами посередине.
Часто ошибка от нижнего слоя сама по себе малоинформативна. Хочется добавить
контекст («не смог загрузить профиль»), но при этом не потерять исходную
причину. Для этого в fmt.Errorf есть особый глагол %w (от wrap —
обернуть). Он вкладывает одну ошибку внутрь другой, сохраняя к ней доступ.
А чтобы потом спросить «а нет ли где-то в этой цепочке вот такой конкретной
ошибки?», используют errors.Is. Он разворачивает цепочку слой за слоем и
сравнивает с образцом:
package mainimport ( "errors" "fmt")var errNotFound = errors.New("запись не найдена")func loadUser(id int) error { if id != 1 { // оборачиваем причину через %w, добавляя свой контекст return fmt.Errorf("loadUser(%d): %w", id, errNotFound) } return nil}func main() { err := loadUser(42) fmt.Println(err) fmt.Println("это errNotFound?", errors.Is(err, errNotFound))}
Вывод:
loadUser(42): запись не найденаэто errNotFound? true
Здесь происходит сразу две вещи. Во-первых, err печатается как
loadUser(42): запись не найдена — мы видим и свой контекст, и причину.
Во-вторых, errors.Is(err, errNotFound) возвращает true, хотя мы сравниваем
обёртку с тем, что лежит у неё внутри. Именно для этого нужен %w: вызывающий
код может реагировать на конкретную причину, не разбирая строку руками.
Заметьте важную деталь: образец errNotFound объявлен один раз как переменная
пакета (так называемая sentinel-ошибка). Сравнивать ошибки по тексту нельзя —
текст меняется. Сравнивают по значению-образцу, и errors.Is делает это
правильно даже сквозь несколько уровней оборачивания.
errors.Is отвечает на вопрос «это та самая ошибка?». Иногда вопрос другой:
«а это ошибка такого типа, и если да — дай мне её поля». Многие ошибки
стандартной библиотеки — это структуры с полезными полями. Например,
strconv.Atoi на плохой строке возвращает *strconv.NumError, у которого есть
поля Func и Num. errors.As ищет в цепочке ошибку нужного типа и, найдя,
записывает её в вашу переменную:
package mainimport ( "errors" "fmt" "strconv")// parsePort превращает строку в номер порта, оборачивая ошибку парсинга.func parsePort(s string) (int, error) { n, err := strconv.Atoi(s) if err != nil { return 0, fmt.Errorf("разбор порта: %w", err) } return n, nil}func main() { _, err := parsePort("8o80") // опечатка: буква вместо нуля // strconv.Atoi возвращает *strconv.NumError — добираемся до его полей. var ne *strconv.NumError if errors.As(err, &ne) { fmt.Printf("не удалось разобрать %q в функции %s\n", ne.Num, ne.Func) } else { fmt.Println("какая-то другая ошибка:", err) }}
Вывод: не удалось разобрать "8o80" в функции Atoi.
Обратите внимание на errors.As(err, &ne) — вторым аргументом передаётся
адрес указателя ne. Если в цепочке найдётся *strconv.NumError, функция
вернёт true и положит её в ne, после чего можно читать ne.Num, ne.Func.
Так вы достаёте структурированные данные из ошибки, а не парсите строку.
У *strconv.NumError есть и третье поле — Err, в котором лежит сама причина
(strconv.ErrSyntax для нечисловой строки или strconv.ErrRange для перебора).
Именно через него работает errors.Is(err, strconv.ErrSyntax) — то есть тот же
тип ошибки можно матчить и по типу через As, и по причине через Is.
Точно так же errors.As работает с вашими собственными типами ошибок —
объявите структуру с методом Error() и ищите её по указателю
(var ve *ValidationError; errors.As(err, &ve)):
type ValidationError struct { Field string}func (e *ValidationError) Error() string { return "невалидное поле: " + e.Field}// errors.As(err, &ve) найдёт *ValidationError в цепочке и даст доступ к ve.Field.
(Этот фрагмент про пользовательский тип запускайте локально: браузерный
интерпретатор не умеет матчить errors.As по типам, объявленным внутри самой
программы, — но на настоящем Go он работает.)
Короткое правило выбора: сравниваете с конкретным значением-образцом — Is;
хотите вытащить тип и его поля — As.
panic — это аварийная остановка. Когда происходит паника, обычное выполнение
функции прекращается, отложенные defer всё же отрабатывают, и управление летит
вверх по стеку. Если панику никто не перехватил — программа падает с трейсом.
Звучит как исключение, и технически это близко. Но в Go так не обрабатывают
ожидаемые ошибки. Файла нет, число не распарсилось, пользователь не найден — это
не повод паниковать, это обычные error, которые надо вернуть и проверить.
panic оставляют для ситуаций, когда продолжать просто бессмысленно: нарушен
инвариант, который «не может» нарушиться, обязательная зависимость не
инициализирована, в map по ключу, который обязан существовать, ничего нет.
То есть для багов, а не для штатных исходов.
recover умеет перехватить панику — но только внутри defer. Он гасит
панику и возвращает то значение, с которым паниковали. Классический и редкий
уместный сценарий: на границе компонента не дать одной плохой операции уронить
всю программу, превратив панику в обычную ошибку:
package mainimport "fmt"func safeDivide(a, b int) (result int, err error) { defer func() { if r := recover(); r != nil { err = fmt.Errorf("восстановились после паники: %v", r) } }() if b == 0 { panic("деление на ноль") } result = a / b return result, nil}func main() { if r, err := safeDivide(10, 2); err == nil { fmt.Println("10 / 2 =", r) } if _, err := safeDivide(10, 0); err != nil { fmt.Println("поймали:", err) }}
Что здесь происходит. При b == 0 функция паникует. Но у нас есть defer с
recover: он ловит панику, и вместо падения программы мы аккуратно заполняем
именованный возвращаемый err. Поэтому вызывающий код получает не крах, а
обычную ошибку и продолжает работать.
Вывод:
10 / 2 = 5поймали: восстановились после паники: деление на ноль
Ключевые детали этого приёма: recover работает только из defer; чтобы
подменить результат функции, нужны именованные возвращаемые значения
(result int, err error) — иначе из defer до них не дотянуться. И всё же
помните: это инструмент для границ и для действительно фатального, а не замена
if err != nil.
Игнорировать ошибку через _.res, _ := mayFail() молча выбрасывает
проблему, и потом вы отлаживаете «непонятно почему ноль». Проверяйте err
всегда; пропуск допустим только когда вы осознанно и доказуемо знаете, что
ошибки быть не может.
Сравнивать ошибки по тексту.if err.Error() == "not found" хрупко: текст
меняется, ломается из-за обёрток. Используйте sentinel-переменную и
errors.Is, а для типов — errors.As.
Путать %w и %v.%v просто вставляет текст ошибки, но не
оборачивает — errors.Is/errors.As потом ничего внутри не найдут. Если
причину важно сохранить для проверки — берите %w.
Паниковать вместо возврата ошибки.panic ради «ну тут точно всё плохо» в
библиотечном коде — это сюрприз для того, кто вас вызвал. Возвращайте error и
дайте вызывающему решать.
Вы научились главному: в Go ошибка — это значение, его возвращают и проверяют
явно, оборачивают через %w и разбирают через errors.Is/errors.As, а
panic/recover держат для редких по-настоящему фатальных случаев.
Дальше эти идеи естественно встречаются с другими темами. defer, на котором
держится recover, подробно разбирается в главе про
функции и defer. Интерфейс error — это частный
случай интерфейсов, так что своя структура-ошибка с
методом Error() — обычная реализация интерфейса. А множественные возвращаемые
значения, благодаря которым (результат, error) вообще возможны, вы уже видели
там же, среди функций.