Урок курса

Тип 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 по своему контракту. Никакого предупреждения нет — ошибка растворилась. Поэтому _ для ошибки допустим только когда вы точно знаете, что сбой в данном месте невозможен или заведомо несущественен.

Попробуйте решить

Какой фрагмент правильно обрабатывает результат функции func validatePositive(n int) (int, error) и не использует значение при ошибке?

Продолжить с проверкой и прогрессом

Откройте интерактивный раннер с заданиями урока.

Перейти к интерактивному уроку