Урок курса
Тип error: возврат, проверка и оборачивание
Golang с нуляВ прошлом уроке функция divide сигнализировала о делении на ноль вторым возвращаемым значением типа bool. Это работало, но информация скудная: вызывающий код знает лишь «что-то пошло не так», а не почему. Go решает эту проблему типом error — он несёт текстовое описание причины сбоя и встроен в язык как первоклассный интерфейс.
Интерфейс error: контракт, nil и соглашение о позиции
В Go error определён примерно так:
type error interface {
Error() string
}
Это встроенный интерфейс с одним методом. Любой тип, реализующий Error() string, автоматически удовлетворяет этому интерфейсу — никаких явных объявлений не нужно.
Ключевой момент: нулевое значение интерфейса — nil. Именно nil — единственный способ сказать «всё прошло успешно». Если функция вернула nil в позиции error, значит сбоя не было.
Посмотрите на типичную сигнатуру:
func validatePositive(n int) (int, error)
Здесь два возвращаемых значения. Если error не равен nil, нужно обработать ошибку и использовать остальные возвращаемые значения только так, как разрешает контракт конкретной функции. Например, validatePositive при ошибке возвращает 0 просто как неиспользуемое значение — читать в нём смысл не стоит. В других функциях контракт может быть иным: некоторые намеренно возвращают полезный частичный результат вместе с ошибкой (например, io.Reader.Read сообщает, сколько байт успело прочитаться до сбоя). Это соглашение о поведении конкретной функции, а не ограничение компилятора.
Почему error принято ставить последним? Читая сигнатуру слева направо, вы сначала видите «что возвращает функция при успехе», а потом — «что вернётся при сбое». Если поставить error первым, код вызова выглядел бы как err, result := f() — взгляд каждый раз спотыкается о err до того, как понял, что вообще вызывается.
Сравните (float64, bool) из предыдущего урока с (float64, error): оба паттерна используют несколько возвращаемых значений, но error добавляет к факту сбоя его описание. Вызывающий код может прочитать это описание, залогировать, передать выше — bool на такое неспособен.
Создание и оборачивание ошибок: errors.New, fmt.Errorf с %w и errors.Is
Самый простой способ создать ошибку — errors.New:
import "errors"
var ErrNotPositive = errors.New("число должно быть положительным")
errors.New возвращает значение типа error с фиксированным сообщением. Объявлять такие переменные на уровне пакета — стандартная практика: они называются сигнальными ошибками (sentinel errors) и позволяют сравнивать конкретные причины сбоя.
Но что если функция нашла ошибку глубже и хочет добавить контекст, не теряя оригинал? Здесь нужен fmt.Errorf с глаголом %w:
import "fmt"
func process(n int) error {
err := validate(n)
if err != nil {
return fmt.Errorf("process: %w", err)
}
return nil
}
%w именно оборачивает ошибку: новый объект содержит текст "process: число должно быть положительным", но внутри хранит ссылку на исходный ErrNotPositive. Это не то же самое, что fmt.Sprintf — у него нет %w, и цепочка была бы потеряна.
Теперь вопрос: как вызывающий код определит, что внутри обёртки именно ErrNotPositive, а не другая ошибка? Прямое сравнение err == ErrNotPositive не пройдёт — err уже другой объект.
err := process(-1)
fmt.Println(err == ErrNotPositive) // false — это обёртка
fmt.Println(errors.Is(err, ErrNotPositive)) // true — разворачивает цепочку
errors.Is(err, target) проходит по всей цепочке обёрток и возвращает true, как только находит target. Цепочка может быть сколь угодно длинной — errors.Is доберётся до корня.
Практический вывод: errors.New создаёт «якорь», fmt.Errorf %w добавляет контекст на каждом уровне, errors.Is находит якорь в любой глубине обёртки.
Проверка ошибки через if err != nil и функция validatePositive
После вызова функции, возвращающей error, принято проверять результат немедленно и завершать ветку с ошибкой явно — через return, return err или передачу выше:
result, err := someFunc()
if err != nil {
return err // или log + return, или return 0, err
}
// только здесь result можно использовать согласно контракту функции
Ключ — в завершении ветки. Если после if err != nil { ... } нет return или другого выхода, выполнение продолжится с тем же result. Что именно содержит result при ошибке — зависит от контракта конкретной функции: одни возвращают нулевое значение, другие — частичный результат (например, io.Reader.Read сообщает, сколько байт успело прочитаться до сбоя). Считать result пригодным без явного разрешения контракта нельзя.
Соберём полный пример:
package main
import (
"errors"
"fmt"
)
var ErrNotPositive = errors.New("число должно быть положительным")
func validatePositive(n int) (int, error) {
if n <= 0 {
return 0, ErrNotPositive
}
return n, nil
}
func process(n int) (int, error) {
result, err := validatePositive(n)
if err != nil {
return 0, fmt.Errorf("process: %w", err)
}
return result, nil
}
func main() {
var n int
fmt.Scan(&n)
result, err := process(n)
if err != nil {
fmt.Println("ошибка:", err)
if errors.Is(err, ErrNotPositive) {
fmt.Println("причина: число не положительное")
}
return
}
fmt.Println("результат:", result)
}
Запустим с вводом -3:
ошибка: process: число должно быть положительным причина: число не положительное
Несколько деталей, на которые стоит обратить внимание.
validatePositive и process возвращают 0 в позиции int при ошибке — не потому что 0 что-то означает, а просто как неиспользуемое значение: такой контракт выбран явно. Именно поэтому process, увидев err != nil от validatePositive, немедленно возвращает 0, fmt.Errorf(...) — значение результата отбрасывается, ошибка с контекстом поднимается выше.
errors.Is внутри main находит исходный ErrNotPositive даже сквозь обёртку "process: " — этот механизм разобран в предыдущей секции, здесь мы видим его в действии целиком.
Теперь о том, что происходит при пропуске проверки:
result, _ := process(-3) // ошибка выброшена
fmt.Println(result) // печатает 0 — но это нулевое значение по контракту process при сбое, не результат вычисления
Компилятор не запретит такой код. result равен 0 не потому что вычисление дало ноль, а потому что произошёл сбой — и process намеренно вернула 0 по своему контракту. Никакого предупреждения нет — ошибка растворилась. Поэтому _ для ошибки допустим только когда вы точно знаете, что сбой в данном месте невозможен или заведомо несущественен.
Как проверить обёрнутую ошибку
Функция возвращает ошибку отсутствия записи, а следующий слой добавляет контекст через fmt.Errorf с %w. Сравнение текста ломается при смене формулировки. Для известных sentinel errors используйте errors.Is, чтобы проверить причину через цепочку обёрток.
Разделите три случая: успешный результат, ожидаемое отсутствие записи и технический сбой. Пользовательский ответ может отличаться, но внутренний текст ошибки не обязан целиком попадать наружу. При обработке не забудьте вернуть ошибку выше, если текущий слой её не исправил. Игнорирование err и использование нулевого результата часто делает сбой похожим на реальные данные.
Продолжите разбор: defer: порядок выполнения и отличие ожидаемой ошибки от panic.
Что делать, если запрос пришёл дважды, а база недоступна?
В сервисе мало вернуть error: нужно понять, можно ли повторить операцию, как ответить клиенту и не создать одну запись дважды. Такие ситуации разберём на практике в платном курсе:
- Ошибки, повторы и восстановление. Идемпотентные POST-запросы, таймауты, транзакции PostgreSQL и повторная доставка событий Kafka.
Микросервисы на Go: HTTP, PostgreSQL, gRPC, Kafka и Docker
Для старта понадобятся структуры, интерфейсы, HTTP, JSON и go test. Эти темы вы изучите здесь; программу продолжения можно посмотреть уже сейчас.
Задача 1
Какой фрагмент правильно обрабатывает результат функции func validatePositive(n int) (int, error) и не использует значение при ошибке?
Задача 2
Что вернёт вызов fmt.Errorf("wrap: %w", errors.New("исходная"))?
Задача 3
Что выведет следующая программа? Введите ровно ту строку, которая появится в stdout.
package main
import (
"errors"
"fmt"
)
var ErrBad = errors.New("bad")
func main() {
err := fmt.Errorf("wrap: %w", ErrBad)
fmt.Println(errors.Is(err, ErrBad))
}
Задача 4
Заявка на резерв
На складе принята практика резервирования товаров под будущие отгрузки: кладовщик оформляет заявку, указывая, сколько единиц нужно отложить. Логика проста — количество либо имеет смысл, либо нет, и система должна немедленно дать однозначный ответ.
Если указанное число положительно, заявка считается корректной и система подтверждает резерв, повторяя введённое количество. Если же кладовщик указал ноль или отрицательное число, заявка отклоняется с чётко сформулированной причиной: количество должно быть положительным.
Что нужно сделать
Допишите validatePositive и reserve. Первая функция должна отклонять ноль и отрицательные значения через уже объявленную сигнальную ошибку. Вторая вызывает проверку и добавляет к ошибке контекст операции заявка, сохраняя исходную причину. Код в main уже выводит полную ошибку и отдельно распознаёт причину через errors.Is.
Формат ввода
Одно целое число — количество единиц.
Формат вывода
Для положительного числа выведите зарезервировано: N. Для нуля или отрицательного числа выведите две строки: ошибка: заявка: количество должно быть положительным и причина: количество должно быть положительным.
Пример
Ввод: 12 Вывод: зарезервировано: 12
Без регистрации. Гостевая проверка не сохраняет прогресс. Для сохранения перейдите к уроку.
Отправьте решение, чтобы увидеть результаты тестов.
Задача 5
Рабочий диапазон глубины
Водолазные операции требуют строгого контроля рабочих глубин: слишком мелко — и погружение теряет смысл или становится опасным из-за волнения, слишком глубоко — и давление выходит за пределы безопасного диапазона. Поэтому любая система, принимающая значение глубины от оператора, обязана немедленно распознать нарушение границ и сообщить о нём точно, без размытых формулировок.
Пользователь хочет передать конкретное числовое значение глубины и получить однозначный ответ: лежит ли оно в допустимом диапазоне от пяти до тридцати метров включительно. Если значение выходит за нижнюю границу, это один вид отказа; если за верхнюю — другой. Различие принципиально, потому что реакция оператора в каждом случае своя.
Чтобы цепочка ошибок не терялась при передаче вверх по стеку, внешняя проверка оборачивает исходную причину, добавляя собственный контекст, но сохраняя возможность докопаться до первопричины стандартными средствами сравнения ошибок. Итоговый вывод программы должен чётко различать оба типа нарушения и демонстрировать, что обёртывание не скрыло исходную информацию.
Что уже дано
В шаблоне объявлены две сигнальные ошибки: ErrTooShallow и ErrTooDeep.
Что нужно сделать
Допишите validateDepth и checkDivePlan. Рабочий диапазон глубины — от 5 до 30 метров включительно. Верните ErrTooShallow ниже диапазона и ErrTooDeep выше него. checkDivePlan добавляет контекст план погружения, сохраняя причину для errors.Is. В main уже выводятся разные причины для двух сигнальных ошибок.
Ввод и вывод
Программа читает одно целое число из stdin. Для допустимой глубины она печатает глубина принята: N м; иначе печатает ошибку с контекстом и строку причины.
Без регистрации. Гостевая проверка не сохраняет прогресс. Для сохранения перейдите к уроку.
Отправьте решение, чтобы увидеть результаты тестов.
Задачи к уроку
- Go errors.Is и fmt.Errorf с %w: оборачивание ошибок
Проверьте диапазон глубины, оберните сигнальную ошибку с контекстом и сохраните её распознавание через errors.Is.
Продолжить с сохранением прогресса
Перейдите к уроку в учебном кабинете.
