Все главы учебника
Содержание учебника

Ошибки как значения

16 мин чтенияКонтент v0.12.0

О чём глава

Программа, которая работает только на правильных данных, бесполезна. Пользователь введёт 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:

package main
 
import (
	"fmt"
	"strconv"
)
 
func main() {
	n, err := strconv.Atoi("42")
	if err != nil {
		fmt.Println("не число:", err)
		return
	}
	fmt.Println("разобрали:", n*2)
}

strconv.Atoi возвращает два значения: число и ошибку. Мы первым делом смотрим на err. Если она не nil — печатаем и выходим из main через return, не трогая n (там лежит ноль, и доверять ему нельзя). Если nil — спокойно работаем с числом. Запустите пример: строка "42" разбирается, на экране будет разобрали: 84.

Поменяйте в коде "42" на "abc" — и сработает ветка с ошибкой. Это и есть весь смысл подхода: путь успеха и путь ошибки видны рядом, в одном месте, без спрятанных прыжков.

Идиома if err != nil

Новичков иногда смущает, что таких проверок в коде много. Это нормально и осознанно: Go делает обработку ошибок явной, чтобы её нельзя было случайно пропустить. Базовый шаблон такой:

result, err := doSomething()
if err != nil {
    // обработали: вернули выше, залогировали, подставили запасной вариант…
    return err
}
// сюда дошли — значит err == nil и result валиден

Несколько практичных правил:

  • Проверяйте ошибку сразу после вызова, пока контекст свежий.
  • При err != nil проверьте контракт функции. Например, io.Reader.Read может вернуть прочитанные байты вместе с ошибкой — их обрабатывают до проверки ошибки.
  • Чаще всего ошибку не «глотают», а возвращают выше — пусть решает тот, кто вызвал. Решение «что делать» обычно принимается ближе к верху программы.

Создаём ошибки: errors.New и fmt.Errorf

Когда ваша собственная функция хочет сообщить о проблеме, ошибку нужно создать. Простейший способ — errors.New с фиксированным текстом:

package main
 
import (
	"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) = 4
half(7): число нечётное, пополам не делится

Если в текст нужно подставить данные (что за значение, какой файл, какой код), берут fmt.Errorf — это как fmt.Sprintf, только результат сразу error:

return fmt.Errorf("не делится: получили %d", n)

Маленькое, но важное соглашение про текст ошибки: пишите его с маленькой буквы и без точки в конце. Причина — ошибки часто склеивают друг с другом в цепочку, и красивее, когда «внешний контекст: внутренняя причина» читается одной строкой, а не как набор предложений с заглавными буквами посередине.

Оборачивание: %w и errors.Is

Часто ошибка от нижнего слоя сама по себе малоинформативна. Хочется добавить контекст («не смог загрузить профиль»), но при этом не потерять исходную причину. Для этого в fmt.Errorf есть особый глагол %w (от wrap — обернуть). Он вкладывает одну ошибку внутрь другой, сохраняя к ней доступ.

А чтобы потом спросить «а нет ли где-то в этой цепочке вот такой конкретной ошибки?», используют errors.Is. Он разворачивает цепочку слой за слоем и сравнивает с образцом:

package main
 
import (
	"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.As: добраться до типа ошибки

errors.Is отвечает на вопрос «это та самая ошибка?». Иногда вопрос другой: «а это ошибка такого типа, и если да — дай мне её поля». Многие ошибки стандартной библиотеки — это структуры с полезными полями. Например, strconv.Atoi на плохой строке возвращает *strconv.NumError, у которого есть поля Func и Num. errors.As ищет в цепочке ошибку нужного типа и, найдя, записывает её в вашу переменную:

package main
 
import (
	"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 main
 
import (
	"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 и recover: и когда их НЕ надо

panic — это аварийная остановка. Когда происходит паника, обычное выполнение функции прекращается, отложенные defer всё же отрабатывают, и управление летит вверх по стеку. Если панику никто не перехватил — программа падает с трейсом.

Звучит как исключение, и технически это близко. Но в Go так не обрабатывают ожидаемые ошибки. Файла нет, число не распарсилось, пользователь не найден — это не повод паниковать, это обычные error, которые надо вернуть и проверить. panic оставляют для ситуаций, когда продолжать просто бессмысленно: нарушен инвариант, который «не может» нарушиться, обязательная зависимость не инициализирована, в map по ключу, который обязан существовать, ничего нет. То есть для багов, а не для штатных исходов.

recover умеет перехватить панику — но только при непосредственном вызове из отложенной функции в той же горутине. Он гасит панику и возвращает то значение, с которым паниковали. Классический и редкий уместный сценарий: на границе компонента не дать одной плохой операции уронить всю программу, превратив панику в обычную ошибку:

package main
 
import "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.

Ловушка: nil, который не равен nil

Эту ошибку делают и опытные разработчики, и о ней любят спрашивать на собеседованиях. Функция объявляет переменную-указатель на свою ошибку и возвращает её как error:

package main
 
import "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. Нулевой результат при ошибке здесь часть контракта вашей функции; вызывающий не принимает его за успешное число.

Что дальше

Следующая глава — введение в дженерики: как написать один алгоритм сразу для нескольких типов, не теряя проверок компилятора.

Продолжить

Остались вопросы?