Программа, которая работает только на правильных данных, бесполезна. Пользователь
введёт 8o80 вместо 8080, файла не окажется на диске, сервер не ответит. Вопрос
не в том, случится ли ошибка, а в том, что программа сделает дальше.
Во многих языках ошибка — это исключение: где-то глубоко что-то пошло не так,
управление «выпрыгивает» из функции и летит вверх, пока кто-нибудь его не
поймает. По коду не видно, какая строка может выпрыгнуть. В Go ошибка — это
обычное значение, которое функция возвращает наравне с результатом. Вы уже
видели это в strconv.Atoi и safeDiv. Каждая проверка записана явно, и, читая
код, вы видите все места, где что-то может пойти не так.
Из этого вытекает вся идиоматика главы: проверка 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 — печатаем и выходим из main через return, не трогая n
(там лежит ноль, и доверять ему нельзя). Если nil — спокойно работаем с числом. Запустите пример:
строка "42" разбирается, на экране будет разобрали: 84.
Поменяйте в коде "42" на "abc" — и сработает ветка с ошибкой. Это и есть весь
смысл подхода: путь успеха и путь ошибки видны рядом, в одном месте, без
спрятанных прыжков.
Новичков иногда смущает, что таких проверок в коде много. Это нормально и
осознанно: Go делает обработку ошибок явной, чтобы её нельзя было случайно
пропустить. Базовый шаблон такой:
result, err := doSomething()if err != nil { // обработали: вернули выше, залогировали, подставили запасной вариант… return err}// сюда дошли — значит err == nil и result валиден
Несколько практичных правил:
Проверяйте ошибку сразу после вызова, пока контекст свежий.
При err != nil проверьте контракт функции. Например, io.Reader.Read может вернуть прочитанные байты вместе с ошибкой — их обрабатывают до проверки ошибки.
Чаще всего ошибку не «глотают», а возвращают выше — пусть решает тот, кто
вызвал. Решение «что делать» обычно принимается ближе к верху программы.
Когда ваша собственная функция хочет сообщить о проблеме, ошибку нужно создать.
Простейший способ — 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() string — и её значение уже ошибка:
package mainimport ( "errors" "fmt")type ValidationError struct { Field string}func (e *ValidationError) Error() string { return "невалидное поле: " + e.Field}func save(email string) error { if email == "" { return fmt.Errorf("сохранение пользователя: %w", &ValidationError{Field: "email"}) } return nil}func main() { err := save("") fmt.Println(err) var ve *ValidationError if errors.As(err, &ve) { fmt.Println("проблема в поле:", ve.Field) }}
Вывод:
сохранение пользователя: невалидное поле: emailпроблема в поле: email
Вызывающий код достал из обёртки исходную *ValidationError и прочитал поле
Field, ничего не разбирая в тексте. Так, например, HTTP-сервер решает, ответить
«400: исправьте поле email» или «500: что-то сломалось у нас».
Короткое правило выбора: сравниваете с конкретным значением-образцом — Is;
хотите вытащить тип и его поля — As.
panic — это аварийная остановка. Когда происходит паника, обычное выполнение
функции прекращается, отложенные defer всё же отрабатывают, и управление летит
вверх по стеку. Если панику никто не перехватил — программа падает с трейсом.
Звучит как исключение, и технически это близко. Но в Go так не обрабатывают
ожидаемые ошибки. Файла нет, число не распарсилось, пользователь не найден — это
не повод паниковать, это обычные error, которые надо вернуть и проверить.
panic оставляют для ситуаций, когда продолжать просто бессмысленно: нарушен
инвариант, который «не может» нарушиться, обязательная зависимость не
инициализирована, в map по ключу, который обязан существовать, ничего нет.
То есть для багов, а не для штатных исходов.
recover умеет перехватить панику — но только при непосредственном вызове из отложенной функции в той же горутине. Он гасит
панику и возвращает то значение, с которым паниковали. Классический и редкий
уместный сценарий: на границе компонента не дать одной плохой операции уронить
всю программу, превратив панику в обычную ошибку:
package mainimport "fmt"func safeDivide(a, b int) (result int, err error) { defer func() { if r := recover(); r != nil { err = fmt.Errorf("восстановились после паники: %v", r) } }() return a / b, nil // при b == 0 здесь случится паника}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) }}
Вывод:
10 / 2 = 5поймали: восстановились после паники: runtime error: integer divide by zero
Что здесь происходит. Целочисленное деление на ноль в Go — паника: среда
выполнения сама её поднимает с сообщением runtime error: integer divide by zero.
Обычно программа на этом упала бы. Но в функции есть defer с recover: при
выходе из функции он ловит панику, и вместо падения мы заполняем именованный
результат err. Вызывающий код получает обычную ошибку и продолжает работать.
В настоящем коде делитель, конечно, проверяют заранее через if b == 0 и
возвращают error. Пример нужен только чтобы показать механизм.
Две детали этого приёма: recover должен вызываться непосредственно из отложенной функции в той же горутине; чтобы
подменить результат функции, нужны именованные возвращаемые значения
(result int, err error) — иначе из defer до них не дотянуться. И всё же
помните: это инструмент для границ и для действительно фатального, а не замена
if err != nil.
Эту ошибку делают и опытные разработчики, и о ней любят спрашивать на
собеседованиях. Функция объявляет переменную-указатель на свою ошибку и
возвращает её как error:
package mainimport "fmt"type ValidationError struct { Field string}func (e *ValidationError) Error() string { return "невалидное поле: " + e.Field}func validate(email string) error { var ve *ValidationError // nil-указатель if email == "" { ve = &ValidationError{Field: "email"} } return ve // ловушка: возвращаем указатель, даже когда он nil}func main() { err := validate("anna@example.com") fmt.Println(err == nil)}
Вывод: false. Почта корректная, ошибки нет, а проверка err != nil скажет,
что ошибка есть.
Причина — в устройстве интерфейса из прошлой главы:
значение интерфейса — это пара «тип + данные». Интерфейс равен nil, только
когда пусты обе части. Здесь данные — nil-указатель, но тип известен:
*ValidationError. Пара не пустая, значит, и err не nil.
Правило простое: если ошибки нет, возвращайте литерал nil, а не переменную
конкретного типа:
if email == "" { return &ValidationError{Field: "email"}}return nil
Выведет false: err хранит пару «тип *ValidationError, значение nil», а такой интерфейс не равен nil. Нажмите кнопку в примере выше и убедитесь.
Игнорировать ошибку через _.res, _ := mayFail() молча выбрасывает
проблему, и потом вы отлаживаете «непонятно почему ноль». Проверяйте err
всегда; пропуск допустим только когда вы осознанно и доказуемо знаете, что
ошибки быть не может.
Сравнивать ошибки по тексту.if err.Error() == "not found" хрупко: текст
меняется, ломается из-за обёрток. Используйте sentinel-переменную и
errors.Is, а для типов — errors.As.
Путать %w и %v.%v просто вставляет текст ошибки, но не
оборачивает — errors.Is/errors.As потом ничего внутри не найдут. Если
причину важно сохранить для проверки — берите %w.
Возвращать nil-указатель как error. Интерфейс с типом внутри не равен
nil, даже если указатель пустой. Когда ошибки нет, пишите return nil.
Паниковать вместо возврата ошибки.panic ради «ну тут точно всё плохо» в
библиотечном коде — это сюрприз для того, кто вас вызвал. Возвращайте error и
дайте вызывающему решать.
Напишите parsePositive(s string) (int, error): преобразуйте строку через strconv.Atoi, сохраните причину ошибки через %w и отвергните число <= 0. Проверьте строки "7", "0", "abc". В вызывающем коде сначала проверяйте err, затем используйте число. Через errors.Is проверьте, сохранилась ли причина strconv.ErrSyntax для "abc".
Только "7" даёт 7, nil. "0" и "abc" дают ошибку; для последнего входа errors.Is(err, strconv.ErrSyntax) возвращает true.
func parsePositive(s string) (int, error) { n, err := strconv.Atoi(s) if err != nil { return 0, fmt.Errorf("положительное число %q: %w", s, err) } if n <= 0 { return 0, fmt.Errorf("число должно быть больше нуля: %d", n) } return n, nil}
Нужны импорты fmt, strconv и, для проверки причины, errors. Нулевой результат при ошибке здесь часть контракта вашей функции; вызывающий не принимает его за успешное число.