GraphLMS

Основы
Начать
Глава 09 · Основы~14 мин чтения

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

О чём глава

Во многих языках ошибка — это исключение: где-то глубоко что-то пошло не так, управление «выпрыгивает» из функции и летит вверх по стеку, пока кто-нибудь его не поймает. В 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:

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 — печатаем и выходим, не трогая n (там лежит ноль, доверять ему нельзя). Если nil — спокойно работаем с числом. Запустите пример: строка "42" разбирается, на экране будет разобрали: 84.

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

Идиома if err != nil

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

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

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

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

Создаём ошибки: 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() и ищите её по указателю (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 и recover: и когда их НЕ надо

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

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

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

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)
		}
	}()
	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) вообще возможны, вы уже видели там же, среди функций.