Go не бросает исключений. Любая функция, способная не справиться, возвращает ошибку отдельным значением, а вызывающий проверяет её в лоб: if err != nil. Одни видят в этом многословие, от которого ноют глаза, и ностальгию по лаконичному try/catch. Другие замечают, что код впервые стал честным: каждая точка отказа названа по имени, поток управления не делает скрытых зигзагов. Разберём устройство механизма, реальные издержки многословности и приёмы, которые сокращают её без потери контроля: оборачивание через %w, сопоставление через errors.Is и errors.As, единый обработчик на весь слой и соединение ошибок через errors.Join.
Ошибка как обычное значение встроенного интерфейса error
В основе лежит крошечный интерфейс:
type error interface {
Error() string
}
Любой тип с таким методом годится в ошибки. Простейший создаёт errors.New("not found"), форматированный собирает fmt.Errorf. Сигнатура os.Open показывает канон на все случаи:
func Open(name string) (*File, error)
Пара "результат, ошибка" работает как жёсткий договор: если err не равен nil, остальные возвращаемые значения лишены смысла и трогать их нельзя. Ошибка в Go - обычное значение, её кладут в переменную, передают в функцию, хранят в структуре, сравнивают. Исключений в языке нет, поэтому прочтя функцию сверху вниз, инженер видит все места, где возможен сбой, без невидимой механики раскрутки стека.
Мелочь, которая всё меняет: error - интерфейс, значит ошибкой бывает структура с полезными полями, а не только строка. nil означает успех, любое другое значение - сбой, третий вариант не предусмотрен.
Скептикам помогает простой эксперимент: открыть случайный файл зрелого проекта и оценить, сколько места занимает обвязка. Лестницы проверок действительно занимают треть экрана, зато рядом всегда видно, что функция делает с каждым сбоем: ждёт, повторяет, подменяет значением по умолчанию или сдаётся наверх. В коде на исключениях та же информация размазана по стеку и часто не написана нигде.
Многословность проверок if err != nil и что она покупает
Типичная функция любого проекта выглядит как лестница:
func loadConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read config: %w", err)
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parse config: %w", err)
}
if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf("validate config: %w", err)
}
return &cfg, nil
}
Три шага - три проверки, и в большой кодовой базе таких лестниц сотни. Критики говорят про шум, про то, что содержательные строки тонут в обвязке. Спор был бы чистой эстетикой, если бы многословность ничего не покупала. Но она покупает главное: решение об ошибке принимается там, где она возникла, либо явно откладывается на уровень выше. Никакой вызов не может незаметно прервать функцию на середине. Проглотить сбой возможно только через пустой идентификатор, и запись вида result, _ := parse() видна при ревью как красная тряпка.
Тело проверки почти всегда умещается в одну-две строки, а сами проверки следуют сразу за вызовом. Многословность - это цена локальности решений, и на дистанции она дешевле, чем отладка скрытой раскрутки стека, которая пронеслась через шесть уровней абстракции.
Отдельная тема - затенение переменной err. Повторное := с новой переменной в левой части работает тихо:
f, err := os.Open(path)
if err != nil {
return fmt.Errorf("open: %w", err)
}
data, err := io.ReadAll(f)
Во второй паре строк := законен, потому что слева появилась новая переменная data, а err просто получает новое значение. Опасность кроется в другом: длинная функция с десятком таких блоков превращается в шараду, где уже непонятно, какой err проверяется. Отсюда правило стиля: держать проверку вплотную к вызову и не разрывать пару вызов-проверка посторонним кодом.
Оборачивание ошибок с контекстом и глагол формата %w
До осени 2019 года контекст добавляли глаголом %v, и старая ошибка растворялась в тексте: понять, чем лечить, было уже нельзя. С Go 1.13 у fmt.Errorf появился %w:
return fmt.Errorf("open socket %s: %w", addr, err)
Такая ошибка хранит исходный сбой внутри, метод Error() печатает всю цепочку, а глагол %w требует, чтобы операнд был значением типа error; чужой тип там ловит go vet. Сторонние ошибки упаковываются в собственный тип с методом Unwrap:
type QueryError struct {
Query string
Err error
}
func (e *QueryError) Error() string { return e.Query + ": " + e.Err.Error() }
func (e *QueryError) Unwrap() error { return e.Err }
Оборачивать или нет, решают по нуждам вызывающего, и практика сводит решение к трём вопросам:
1. Нужен ли исходный сбой вызывающему для решения о повторе или классификации;
2. Помогает ли текст контекста понять, что именно произошло на этом уровне;
3. Не разрушит ли упаковка контракт, на который рассчитывают выше по стеку.
Если исходник нужен - %w, если хватает текста - %v, если контракт важнее - новая доменная ошибка.
У %w есть оборотная сторона: обёртка становится частью интерфейса функции. Стоит позже сменить %w на %v - и вызывающий код с errors.Is сломается молча. Решение об упаковке фиксируют на ревью как контракт, а не как вкусовщину в оформлении.
Сопоставление ошибок через errors.Is и errors.As
Сторожевые значения сравнивают через errors.Is:
var ErrNotFound = errors.New("not found")
if errors.Is(err, ErrNotFound) {
return http.StatusNotFound
}
Функция идёт по всей цепочке и возвращает истину, если любой её уровень совпадает с образцом. errors.As решает задачу типов:
var pathErr *os.PathError
if errors.As(err, &pathErr) {
log.Printf("путь: %s, операция: %s", pathErr.Path, pathErr.Op)
}
Ссылка на готовую переменную выглядит непривычно, зато после проверки структура с полями сразу доступна. До Go 1.13 то же самое делали приведением типа и разбором e.Err вручную, новые функции спрятали механику за один вызов. Собственный тип может настроить сравнение методом Is(error) bool, тогда errors.Is учтёт особую эквивалентность. Шлюз к хранилищу объявляет любой сетевой разрыв равным доменной ошибке:
func (e *StorageDownError) Is(target error) bool {
return target == ErrStorageDown
}
Теперь errors.Is вернёт истину на любом разрыве соединения, и верхним слоям не нужно знать про адреса и таймауты. С отменой контекста дружит тот же механизм:
if errors.Is(err, context.DeadlineExceeded) {
return ErrTimeout
}
if errors.Is(err, context.Canceled) {
return nil // клиент ушёл, работа не нужна
}
Обе ошибки - стандартные сторожевые значения пакета context, и ловить их по тексту нельзя. Ловить по тексту вообще нельзя: сообщение ошибки не является интерфейсом, запись strings.Contains(err.Error(), "timeout") ломается при первом же переформатировании. Строка - для человека, тип и значение - для кода.
Для редких случаев, когда вариантов немного и все свои, живёт и старый добрый разбор по типам:
switch e := err.(type) {
case *ValidationError:
return e.Field
case *NotFoundError:
return e.Name
}
Он по-прежнему уместен внутри одного пакета, за его пределами честнее errors.As.
Поведенческие ошибки на примере net.Error и json.SyntaxError
Код ошибки отвечает на вопрос "что делать дальше", а не только "что случилось". Классика - сетевой интерфейс:
type Error interface {
error
Timeout() bool
Temporary() bool
}
Вызывающий не разбирает строки, он спрашивает поведение: Temporary вернул истину - подождать и повторить, таймаут - удвоить интервал. Парсер JSON живёт иначе: его SyntaxError несёт поле Offset int64, по которому вызывающий вычисляет строку и колонку сбоя. По соседству лежит os.PathError с полями Op, Path и Err: операция, путь и вложенная причина разложены по отдельным полям, сообщение собирается само. Доменные ошибки работают так же:
type ValidationError struct {
Field string
Rule string
}
func (e *ValidationError) Error() string {
return e.Field + ": " + e.Rule
}
Поля читаются кодом без парсинга сообщений, и это главный аргумент за ошибки-значения: информация остаётся машиночитаемой на любом этаже стека.
С типизированными ошибками связана и главная ловушка языка: nil-указатель пользовательского типа, возвращённый как error, не равен nil:
func do() error {
var verr *ValidationError // nil-указатель
return verr // интерфейс хранит тип без значения
}
Интерфейсное значение - пара "тип, значение". У возвращённого интерфейса тип есть, а значение пустое, поэтому проверка err != nil даёт истину и фиксирует сбой там, где его нет. Лечение одно: возвращать nil явно, а не переменную указательного типа.
Приёмы против копипасты повторяющихся обработчиков
Первый приём - перенести механику на край системы. HTTP-слой с центральной точкой:
type handler func(http.ResponseWriter, *http.Request) error
func (h handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if err := h(w, r); err != nil {
var apiErr *APIError
switch {
case errors.As(err, &apiErr):
writeJSON(w, apiErr.HTTPStatus, apiErr)
default:
log.Printf("internal: %v", err)
writeJSON(w, http.StatusInternalServerError, errorResponse("internal"))
}
}
}
Каждый обработчик просто возвращает ошибку, а перевод в коды ответа живёт в одном месте. Добавить новый тип - значит добавить один case, а не сорок копий по файлам.
Второй приём - именованные возвращаемые значения с defer для транзакций:
func (s *Store) Save(ctx context.Context, item *Item) (err error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer func() {
if err == nil {
err = tx.Commit()
} else {
_ = tx.Rollback()
}
}()
return s.insert(ctx, tx, item)
}
Коммит или откат выбираются сами, а функция остаётся плоской. Третий приём - паники для ошибок программиста: regexp.MustCompile падает при кривом шаблоне в init, потому что чинить это должен автор кода, а не рантайм. В долгоживущем сервере recover страхует обработчик от чужой паники, но превращать его в замену проверок - путь к тем самым скрытым зигзагам.
Четвёртый приём - ошибки из горутин. Голая горутина не умеет возвращать значение, и без обвязки сбой в ней исчезает бесследно. Пакет golang.org/x/sync/errgroup закрывает дыру тремя строками:
g, gctx := errgroup.WithContext(ctx)
g.Go(func() error { return fetchUsers(gctx) })
g.Go(func() error { return fetchOrders(gctx) })
if err := g.Wait(); err != nil {
return fmt.Errorf("load profile: %w", err)
}
Wait возвращает первый сбой, а контекст группы гасится, чтобы остальные горутины не молотили зря. Без errgroup ошибки пришлось бы вручную перегонять через канал и следить за группой ожидания.
Пара слов о дисциплине логов. Записывать одну и ту же ошибку на каждом уровне - болезнь, из-за которой один сбой разрастается в пять строк аларма. Правило простое: лог живёт на границе системы, где ошибка впервые становится событием для человека, внутренние слои только возвращают её наверх.
errors.Join в Go 1.20 и судьба предложения try
Параллельные шаги дают несколько сбоев сразу, и с Go 1.20 их можно соединить:
combined := errors.Join(errFetch, errParse, errCache)
return fmt.Errorf("refresh feed: %w", combined)
Разрешены и несколько %w в одном fmt.Errorf:
return fmt.Errorf("refresh: fetch %w; parse %w", errFetch, errParse)
errors.Is и errors.As прозрачно видят каждую из присоединённых ошибок, а запись в лог сохраняет все причины разом: ничто не теряется в первой же.
Многословность пытались лечить и на уровне языка. В 2019 году на обсуждение выставили встроенную функцию try, которая скрыла бы проверку за краткой записью. Волна критики показала, что сообщество ценит явность выше краткости, и предложение сняли с повестки. С тех пор экосистема пошла другим путём: не менять синтаксис, а выносить однотипную механику на границы слоёв.
if err != nil - не случайность, а осознанный выбор дизайна. Три строки на проверку покупают код, где точки отказа названы, решения локальны, а сообщение об ошибке собирается в цепочку, читаемую и человеком, и errors.Is. Девять из десяти ошибок просто уходят на уровень выше с добавкой контекста, и именно эти девять делают стек читаемым как таблицу. Многословность лечится не новым синтаксисом, а грамотной границей, за которой механика обработки живёт один раз.