Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 15 из 25 ~22 мин +50 XP

Ошибки: оборачивание, errors.Is и errors.As

errors is as — так в поисковик сжимается вопрос, который встаёт сразу после двадцать первого урока базового курса: ошибка возвращена, напечатана, а какая именно она — узнать нечем. Пятнадцатый урок закрывает пробел. fmt.Errorf с глаголом %w оборачивает причину, сохраняя не только текст, но и ссылку на неё; метод Unwrap() — дверь между слоями обёртки; errors.Is находит sentinel-ошибку сквозь любое число слоёв; errors.As достаёт из цепочки конкретный тип со всеми полями. Разберём, чем обёртка с %w отличается от обёртки без него, почему == перестаёт работать после первого же слоя, как устроен кастомный тип ошибки с методами Error, Unwrap и Is, и зачем в Go 1.20 появился errors.Join. Оборачивание ошибок go — не украшение, а протокол честности между слоями программы: низ знает, что сломалось, верх — в какой операции, и ничего не теряется по дороге.

Содержание урока · 14 разделов

errors.Is и errors.As в Go: адрес, который появляется у ошибки#

В уроке про ошибки и возвраты базового курса мы прошли первый круг: ошибка — обычное значение типа error, возвращаемое парой (value, err); фабрики errors.New и fmt.Errorf делают новые ошибки; sentinel-переменная ErrDivZero позволяет вернуть один и тот же отказ из нескольких мест. Но в самом конце того урока мелькнула оговорка: «это вход в большой мир обработки ошибок — сравнение через errors.Is, выделение конкретных типов через errors.As, собственные типы ошибок с полями-деталями». Сегодня этот мир открывается целиком.

Проблема, которую решает урок, знакома каждому, кто писал функцию больше десяти строк. Ошибка родилась на дне программы — например, в функции, читающей файл конфигурации. По дороге к main она прошла через три обёртки: читатель сказал «не могу прочитать файл», слой конфигурации — «не могу загрузить настройки», запуск — «сервер не стартует». Печать такой цепочки даёт полный рассказ: «сервер не стартует: не могу загрузить настройки: не могу прочитать файл: open config.yaml: нет такого файла». Красиво. Но как теперь программно спросить: «это точно отсутствие файла? Может, права не те?» — и в зависимости от ответа сделать разное: подставить конфиг по умолчанию или упасть с шумом? Текст парсить стыдно, сравнивать == бесполезно — верхняя ошибка уже не та, что была внизу.

Ответ языка — три функции пакета errors и один форматный глагол. errors.Is отвечает на вопрос «это ТА САМАЯ ошибка?» — сквозь любое число обёрток. errors.As отвечает на вопрос «в цепочке есть ошибка такого-то ТИПА?» — и отдаёт её в руки со всеми полями. Глагол %w в fmt.Errorf — то, что делает цепочку возможной: он вкладывает причину внутрь новой ошибки, сохранив ссылку, а не только текст. Вокруг этой тройки строится всё остальное: метод Unwrap(), по которому ошибки разворачиваются, собственные типы ошибок с методами Is и As, и errors.Join — способ честно сказать «сломалось сразу несколько вещей».

План урока: обёртка с %w и без него; sentinel-ошибки и errors.Is; механика Unwrap; errors.As и кастомный тип ошибки; свой тип с методами Unwrap, Is и As; errors.Join; шпаргалка выбора формы. Двенадцатый урок научил нас спрашивать тип у пары «динамический тип + значение» через ok-форму — сегодня увидим, во что эта привычка вырастает, когда в паре лежит ошибка. А четырнадцатый урок дал нам язык для разговора о границах пакетов: экспортируемые Err-переменные — это экспорт в чистом виде, и мы им воспользуемся.

Ошибка — значение: что уже знаем и чего не хватает#

Начнём с инвентаризации. Из базового курса у нас есть три факта. Первый: ошибка — интерфейс с одним методом Error() string; любое значение с таким методом — полноценная ошибка, которую можно вернуть, напечатать, положить в переменную. Второй: errors.New("текст") — фабрика простейшей ошибки, fmt.Errorf — та же фабрика, но с форматированием, где подставляются детали: fmt.Errorf("деление %d на %d", a, b). Третий: sentinel-ошибка — переменная уровня пакета, созданная один раз, — позволяет вернуть из разных мест один и тот же отказ и отличить его от прочих. Всё это мы уже делали руками: заводили ErrDivZero, печатали его через %w, оборачивали дважды и видели, как растёт текст.

Чего не хватает — адреса. Текст ошибки — это сообщение человеку; программа не может на него опереться. Две ошибки с одинаковым текстом — разные значения (скоро увидим, чем это грозит ==); одна и та же ошибка после обёртки — вообще другое значение с другим текстом. И если пакет, который вы пишете, возвращает наверх просто err без обёртки, вы честно теряете контекст: наверху знают, что «что-то не найдено», но не знают, кто искал и что именно. Если же оборачиваете без связи — fmt.Errorf("не найдено: " + err.Error()) — контекст есть, а связи нет: наверху никак не докажет, что внутри сидит та самая ErrNotFound, и не отличит вашу ошибку от тысячи похожих.

Именно поэтому конвенция «оборачивание ошибок go» звучит как протокол: каждый слой добавляет свой контекст и сохраняет причину. Низ знает «что сломалось» (нет такого файла), середина — «в какой операции» (загрузка настроек), верх — «к чему это привело» (сервер не стартует). Кто-то в середине цепочки — обычно тот, кто понимает предметную область, — решает, обрабатывать ли ошибку здесь или передать дальше; передача всегда через обёртку с %w.

И сразу свяжем с прошлым уроком. Sentinel-ошибка на границе пакета — это экспорт в чистом виде: var ErrNotFound = errors.New("запись не найдена") объявлена с заглавной буквы, потому что её будут сравнивать чужие пакеты, — это часть API, такое же обещание, как тип Task или конструктор New. Стандартная библиотека живёт по этому правилу: sql.ErrNoRows, io.EOF, os.ErrNotExist — все эти имена вы импортируете и сравниваете с ними свои ошибки. Мы в этом уроке заведём свой ErrNotFound и будем оборачивать его по всем правилам.

fmt.Errorf с %w и без: обёртка, которая помнит#

Сравним две строки, которые печатают одно и то же, а делают разное. Первая: fmt.Errorf("поиск юзера: %v", ErrNotFound) — глагол %v подставляет текст ошибки в шаблон и на этом заканчивает: результат — новая ошибка со вшитым текстом «поиск юзера: запись не найдена», внутри которой ничего нет. Вторая: fmt.Errorf("поиск юзера: %w", ErrNotFound) — глагол %w (wrapping, обёртывание) делает всё то же самое плюс одно незримое действие: сохраняет ссылку на причину. Печать у обоих одинакова до символа; разница живёт в рантайме и видна только инструментам.

Чтобы разница стала осязаемой, посмотрим на типы. fmt.Errorf без %w возвращает то же, что и errors.New, — простую ошибку-строку. fmt.Errorf с %w возвращает значение специального внутреннего типа, у которого есть два метода: Error() — уже знакомый текст — и Unwrap() error, возвращающий спрятанную причину. Никакой магии: обёртка — это маленькая структура с двумя полями (текст и причина) и двумя методами. Отсюда два следствия, на которых спотыкаются все, кто приходит к %w впервые.

Следствие первое: обёртка — новая ошибка. Даже с %w выражение wrapped == ErrNotFound вернёт false: слева значение-обёртка, справа — sentinel; это разные значения с разным типом. == сравнивает верхний уровень цепочки и на нём останавливается. Ровно поэтому нужен errors.Is — он умеет заглядывать внутрь. Ожидание «я обернул с %w, значит == заработает» — самая частая ошибка начинающих; проверьте себя предиктом ниже.

Следствие второе: %v не заменяет %w никогда. Формат «текст: %v» и формат «текст: %w» дают одинаковую печать, но первая строка отрезает ошибке прошлое: внутри нет ссылки, разворачивать нечего, errors.Is и errors.As увидят пустоту. Правило гигиены: если вы пишете fmt.Errorf и внутри формата есть ошибка — используйте %w. %v (или %s) для ошибки законен ровно в одном случае: когда вы сознательно хотите создать независимую ошибку с текстом-цитатой и готовы объяснить почему. Склейка строк err.Error() — та же потеря связи, только руками.

И последняя деталь механики: обёртки вкладываются друг в друга. fmt.Errorf("слой 2: %w", fmt.Errorf("слой 1: %w", ErrNotFound)) — цепочка из трёх ошибок, где каждая помнит предыдущую. Печать разворачивает весь стек текстом; Unwrap() достаёт ровно одно звено — предыдущее. По этой лестнице ходят errors.Is и errors.As — об этом половина урока.

Проверьте себя

Чем обёртка fmt.Errorf("поиск: %w", err) отличается от fmt.Errorf("поиск: %v", err), если печать у них одинаковая?

Что выведет пара обёрток?

Прочитайте код и выберите, что он выведет:

package main

import (
        "errors"
        "fmt"
)

var ErrNotFound = errors.New("запись не найдена")

func main() {
        a := fmt.Errorf("поиск: %v", ErrNotFound)
        b := fmt.Errorf("поиск: %w", ErrNotFound)
        fmt.Println(a)
        fmt.Println(b)
        fmt.Println("== после %v?", a == ErrNotFound)
        fmt.Println("== после %w?", b == ErrNotFound)
}

errors.Is: sentinel-ошибки и сравнение по личности#

Разберём errors.Is(err, target) по частям. Сигнатура: принимает ошибку-кандидата и цель; возвращает bool. Ответ true означает: где-то в цепочке err (включая её верхний уровень) лежит именно та самая цель — не похожая, не с тем же текстом, а та самая. Механика честная и простая: пока err не nil, Is сравнивает err == target; если не совпало и у err есть метод Unwrap — подставляет err = err.Unwrap() и повторяет; цепочка кончилась — false. Is — это «сравнение == в цикле по лестнице Unwrap».

Зачем цикл, если есть ==? Потому что после первого же %w сравнение верхнего уровня бесполезно: err — обёртка, target — sentinel, они никогда не равны. Is проходит слои и сравнивает КАЖДЫЙ уровень с target: верхнюю обёртку — нет, среднюю — нет, корень — совпало, true. Отсюда правило: сравнивайте с sentinel-ом только через errors.Is — == годится лишь в редких местах, где вы точно знаете, что ошибка не оборачивалась, а это знание хрупкое: сегодня не оборачивалась, завтра коллега добавил контекст — и == тихо сломалось, а errors.Is продолжил работать.

Теперь про sentinel-ы подробнее, потому что сравнивать имеет смысл только с ними. Sentinel-ошибка — переменная уровня пакета: var ErrNotFound = errors.New("запись не найдена"). Конвенция имени — Err с заглавным продолжением: ErrNotFound, ErrEmpty, ErrPermissionDenied. Экспортированный (заглавная буква!) sentinel — часть API пакета: пользователь вашего пакета пишет if errors.Is(err, task.ErrNotFound) — и это работает, потому что обе стороны указывают на одно и то же значение, созданное один раз при инициализации пакета. Стандартная библиотека набита такими: sql.ErrNoRows, io.EOF, fs.ErrNotExist — и все они сравниваются именно через errors.Is.

Два честных граничных случая. Первый: errors.Is(nil, ErrNotFound) — false всегда: nil не совпадает ни с чем, отдельной проверки не нужно. Второй, коварный: двойник. errors.New("запись не найдена") создаёт НОВОЕ значение; у него тот же текст, что у ErrNotFound, но другая личность — Is ответит false. Ошибки сравниваются по личности (адресу значения), а не по тексту; текст — для людей, адрес — для программы. Квиз ниже закрепит это на вопросе, а ранимый пример — на глазах.

И последнее: Is работает и на необёрнутых ошибках — цепочка длины один — это просто == с запасной лестницей. Поэтому в новом коде нет причин писать == с sentinel-ами вовсе: errors.Is покрывает все случаи, включая будущие обёртки.

Sentinel и ==: сравнение по личности

Запустите: find вернул sentinel как есть, без обёртки — поэтому == узнаёт его: true. Третья строка — пустая ошибка: nil не совпадает ни с чем. Теперь эксперимент, который покажет главную ловушку ==: добавьте внутрь find обёртку — return "", fmt.Errorf("поиск юзера %d: %w", id, ErrNotFound) — и запустите снова. Печать ошибки вырастет и станет полезнее, а == тихо сменится на false: сравнение видит только верхний уровень. Лечится это errors.Is — ранимый пример с ним ждёт ниже, а пока запомните сам факт поломки.

Вывод

Запустите код, чтобы увидеть вывод.

Unwrap: дверь между слоями обёртки#

Механика цепочки стоит на одном интерфейсе, у которого даже нет имени в обычном смысле. Любой тип с методом Unwrap() error считается разворачиваемым: errors.Is, errors.As и errors.Unwrap пользуются им, чтобы спуститься на уровень ниже. У обёрток от %w этот метод встроен; у ваших собственных типов ошибок его пишет ваша рука — и это полное право, а не хак.

Три инструмента ходят по этой лестнице по-разному. errors.Unwrap(err) — одиночный шаг: достаёт одно звено ниже или nil, если дальше идти некуда (или если err сам nil). Удобен, когда вы точно знаете форму цепочки: err := fmt.Errorf("страница: %w", inner); errors.Unwrap(err) — это inner. На корневой ошибке — той, что создана errors.New, — Unwrap вернёт nil: под корнем ничего нет. errors.Is — цикл сравнения до конца цепочки (о нём предыдущий пункт). errors.As — тот же цикл, но сравнивает не личность, а тип (о нём — через два раздела). То есть Unwrap — примитив, а Is и As — надстройки над ним; в прикладном коде Unwrap руками зовут редко, зато понимание «Is = == по слоям» снимает с функции всю мистику.

Полезная привычка — читать цепочку ошибок как стек вызовов наоборот. Внутренний слой сообщает факт: «open config.yaml: нет такого файла». Каждый следующий добавляет контекст операции: «не могу прочитать файл: …», «не могу загрузить настройки: …». Разматывая назад через Unwrap, вы получаете путь от симптома к причине; печатая ошибку целиком, — готовый отчёт. Оборачивание без причины — когда каждый слой добавляет только текст «сбой: %w» без своей специфики — создаёт шум, в котором контекст тонет; хорошее обёртывание отвечает на вопрос «что делали, когда сломалось», а не повторяет «сломалось» пять раз.

И тонкость, которую стоит знать заранее, до раздела о своих типах: сравнение Is по слоям работает не потому, что цепочка «вообще есть», а потому, что у каждого звена доступен Unwrap. Если свой тип ошибок не объявит Unwrap() error, для Is цепочка закончится на нём — даже если внутри поля Err сидит настоящий sentinel. Двери нет — дальше не пройти. Мы вернёмся к этому в блоке о собственных типах, а пока проверим errors.Is в бою: два уровня %w, nil и двойник.

errors.Is: достаём sentinel сквозь два слоя %w

Запустите: печать показывает весь стек текстом, а errors.Is отвечает true — sentinel найден на дне цепочки из трёх значений. Сравните с прошлым раннером: там == на той же обёрнутой ошибке давало false. Третья строка — nil не совпадает ни с чем. Четвёртая — главный урок: twin создан errors.New с ТОЧНО тем же текстом, но Is отвечает false: сравнение по личности, а не по строке. Честная оговорка: резервный движок песочницы не знает errors.Is — на нём этот раннер ответит «у пакета errors нет члена Is». Основной движок исполняет как настоящий Go.

Вывод

Запустите код, чтобы увидеть вывод.

Проверьте себя ещё раз

a := errors.New("запись не найдена") и b := errors.New("запись не найдена") — одинаковый текст. Что вернёт errors.Is(a, b) в настоящем Go?

Восстановите sentinel и обёртку

Впишите: фабрику sentinel-ошибки, глагол обёртки и оператор прямого сравнения.

package main import ( "errors" "fmt" ) var ErrNotFound = ("запись не найдена") func find(id int) (string, error) { if id != 7 { return "", ErrNotFound } return "Анна", nil } func page(id int) (string, error) { name, err := find(id) if err != nil { return "", fmt.Errorf("страница юзера: ", err) } return "Страница " + name, nil } func main() { _, err := page(5) fmt.Println(err) _, direct := find(5) fmt.Println("== sentinel?", direct ErrNotFound) }

errors.As: достаём тип и поля из цепочки#

errors.Is отвечает на вопрос «чья это ошибка?» — но sentinel умеет хранить только текст. А если к ошибке нужно привязать детали: какой файл не открылся, какой путь не нашёлся, какой лимит переполнен? Для этого в Go заводят кастомный тип ошибки — свою структуру с методом Error() — и ищут её в цепочке через errors.As.

Сигнатура: errors.As(err, &target). err — цепочка, target — указатель на тип, который вас интересует (или на интерфейс). As идёт по цепочке ровно как Is, но на каждом уровне спрашивает не «это та же личность?», а «это значение такого типа?» — и при совпадении присваивает его в *target: вы получаете ошибку в её настоящем типе, со всеми полями и методами. Возврат true означает «нашёл и присвоил».

Зачем это, если есть ok-форма утверждения типа из двенадцатого урока? perr, ok := err.(PathError) работает и выглядит похоже — но видит только верхний уровень цепочки. Как только кто-то обернул вашу ошибку хоть раз — а в приличной программе это происходит всегда, — верхним уровнем станет обёртка, и ok-форма честно ответит false, хотя ваша PathError сидит на дне и жива. errors.As делает тот же вопрос, но в цикле по слоям: «есть ли где-то в цепочке PathError?». Это ровно та же эволюция, которую мы видели с Is: == вырастает в errors.Is, когда появляются обёртки; err.(T) вырастает в errors.As по той же причине. Сама привычка спросить-и-не-паниковать из двенадцатого урока остаётся — меняется инструмент.

Правила обращения с As. Первое: target — обязательно указатель: var perr *fs.PathError; errors.As(err, &perr) — As записывает результат по этому адресу, поэтому передача значения без & не соберётся. Второе: тип под указателем обязан реализовывать error (или быть интерфейсом) — иначе в чём смысл искать ошибку такого типа. Третье: As(nil, &target) — false без паники; пустая цепочка не совпадает ни с чем. Четвёртое: если тип найден, использовать его можно смело — As гарантирует, что присвоено значение именно этого типа, паники interface conversion не будет никогда: неудача выражается флагом, как и в ok-форме.

Канонический пример из стандартной библиотеки — fs.PathError: структура с полями Op (операция), Path (путь) и Err (причина), методом Error() и методом Unwrap(), отдающим Err. Файловые операции возвращают именно её; обёрнутую — на любой глубине — достают через errors.As и принимают решение по полям. Ниже — полный листинг с собственным типом в том же стиле и честная оговорка, почему в песочнице он показан статично.

Кастомный тип ошибки: *PathError и errors.As в настоящем Go

GO
package main

import (
        "errors"
        "fmt"
)

// ErrNotFound — sentinel пакета: факт «не найдено» без деталей.
var ErrNotFound = errors.New("запись не найдена")

// PathError — кастомный тип ошибки: факт + детали операции.
type PathError struct {
        Op   string // какая операция упала: open, stat, remove
        Path string // с чем работали
        Err  error  // причина ниже по цепочке
}

// Error делает структуру полноценной ошибкой.
func (e *PathError) Error() string {
        return e.Op + " " + e.Path + ": " + e.Err.Error()
}

// Unwrap открывает дверь: Is и As проходят сквозь наш тип к причине.
func (e *PathError) Unwrap() error { return e.Err }

func load(path string) error {
        return fmt.Errorf("загрузка настроек: %w",
                &PathError{Op: "open", Path: path, Err: ErrNotFound})
}

func main() {
        err := load("config.yaml")
        fmt.Println(err) // печать разворачивает весь стек

        var perr *PathError
        if errors.As(err, &perr) { // ищем ТИП по всей цепочке
                fmt.Println("операция:", perr.Op, "| путь:", perr.Path)
        }
        fmt.Println("это наш sentinel?", errors.Is(err, ErrNotFound))
}

В настоящем Go программа печатает «загрузка настроек: open config.yaml: запись не найдена», затем «операция: open | путь: config.yaml», затем true. errors.As нашёл *PathError на втором уровне цепочки и присвоил в perr — с полями можно работать. errors.Is в той же цепочке находит и sentinel: As и Is ходят по одной лестнице Unwrap. В песочнице этот листинг не исполняется: резервный движок не знает errors.As, а основной соглашается только на типы стандартной библиотеки — подробности в плашке ниже, а живой пример с fs.PathError ждёт в раннере.

Свой тип ошибки с методами: Unwrap, Is и As#

Структура PathError из листинга — шаблон, который стоит запомнить целиком, потому что в нём три решения, каждое со своим смыслом. Решение первое — Error() string: без него тип не ошибка. Формат текста — ваше полное право: склейка полей, русский или английский, детали или краткость; текст едят логи и люди, поэтому полезно, чтобы он был однозначным и без «сбой сбоя». Решение второе — Unwrap() error: дверь для Is и As. Пишете её, когда внутри структуры сидит причина, которую верхним уровням нужно видеть (как ErrNotFound в примере); не пишете, когда ваш тип — корень и детальнее ничего нет. Стандартная библиотека следует тому же: у fs.PathError Unwrap есть, у чисто текстовых ошибок — нет.

Решение третье — самое тонкое: методы Is(target error) bool и As(target any) bool на своём типе. Это крючки, которые errors.Is и errors.As дёргают при сравнении: если у уровня цепочки есть свой Is, то вместо == Is зовёт его и спрашивает «тебе подходит такой target?». Зачем? Когда равенство по личности слишком строгое. Классика — ошибки-лимиты: type OverflowErr struct{ Limit int }. Два OverflowErr с разными лимитами — разные значения, == и обычный Is скажут false. А вам хочется отвечать true на вопрос «это вообще переполнение?». Пишется метод: func (e OverflowErr) Is(target error) bool { _, ok := target.(OverflowErr); return ok } — «считаю равным любой своей сестру». Аналогично As-метод позволяет типу заявить «меня можно интерпретировать как вот этот target». Это взрослая техника; пользуйтесь ею точечно, когда стандартного сравнения по личности мало, — но знать о ней нужно, потому что так устроены ошибки с семантикой «одинаковые по смыслу».

Практический масштаб: sentinel-ы — когда ошибка одна и без деталей; свой тип — когда деталей много и по ним принимаются решения (какой файл, какой лимит, какой код); Join — когда причин несколько. И ни один из этих механизмов не требует дженериков: система ошибок Go сознательно держится на интерфейсе из одного метода и паре функций — простота здесь не недоработка, а проект. Осталось честно поговорить о том, что из этого исполняет песочница.

errors.As: достаём *fs.PathError из обёрнутой цепочки

Запустите: errors.As прошёл сквозь обёртку «стартуем: …», нашёл *fs.PathError, присвоил в perr — и мы напечатали поля: операция open и путь config.yaml. Строчкой ниже errors.Is на той же цепочке узнаёт os.ErrNotExist — Is и As ходят по одной лестнице Unwrap. Обратите внимание на форму вызова: target — указатель (&perr), а тип под ним реализует error. Почему fs.PathError, а не своя структура: основной движок ищет As-ом только типы стандартной библиотеки, на своих останавливается с «*target must be interface or implement error», а резервный движок не знает As вовсе — «у пакета errors нет члена As». Настоящий Go исполняет и свой тип — листинг выше показывает как.

Вывод

Запустите код, чтобы увидеть вывод.

Что выведет ok-форма на пустой ошибке?

Прочитайте код и выберите, что он выведет:

package main

import "fmt"

type RepoError struct{ Op string }

func (e *RepoError) Error() string { return e.Op }

func lookup(id int) error {
        if id == 0 {
                return &RepoError{Op: "нулевой id"}
        }
        return nil
}

func main() {
        err := lookup(7)
        if re, ok := err.(*RepoError); ok {
                fmt.Println("своя ошибка:", re.Op)
        } else {
                fmt.Println("не наша или пустая:", err)
        }
}

errors.Join: когда причин несколько#

Все механизмы выше описывали цепочку из одной причины: ошибка помнит одну ошибку, та — ещё одну. Но иногда честнее сказать: сломалось сразу несколько вещей. Пакетная загрузка, в которой пять файлов, и четыре не прочитались; закрытие ресурса, при котором упали и запись буфера, и закрытие соединения. До Go 1.20 такой ситуации в языке не существовало: возвращали первую ошибку, молча теряя остальные, либо склеивали тексты, теряя связи. errors.Join появился в Go 1.20 и решает именно это: errors.Join(err1, err2, ...) собирает все не-nil аргументы в одну ошибку-набор.

Что умеет набор. Печать: метод Error() склеивает тексты причин переводом строки — fmt.Println покажет список, по строке на причину; удобно для отчёта «что именно не смогло». Поиск: errors.Is и errors.As проверяют КАЖДОГО члена набора — если хоть в одном сидит ваш sentinel или ваш тип, ответ true. Пустой набор: Join(nil, nil) возвращает nil — это делает идиому «собери ошибки цикла и верни Join» безопасной без проверок: накапливаете причины в срез, в конце return errors.Join(errs...) — нет ошибок, вернётся nil, есть — вернётся честный набор. С 1.20 легален и второй путь: несколько глаголов %w в одном fmt.Errorf — такая ошибка разворачивается в список причин; но для набора из одного источника читаемее Join, а для пачки однородных причин — почти всегда он.

Где Join в иерархии урока? Это третья форма после «верни sentinel как есть» и «оберни с %w». Первая — когда причина одна и контекст не нужен. Вторая — когда причина одна и контекст нужен: обёртка. Третья — когда причин несколько и потерять любую было бы враньём. Отдельная честность: Join не заменяет цепочку — он именно набор; причина внутри набора может быть обёрнутой ошибкой со своей цепочкой, и Is раскрутит и её (в ранимом примере ниже sentinel найден сквозь Join внутри %w). В стандартном сценарии «накопил — вернул» это даёт полную глубину: ошибки участников остаются живыми целиком.

Оговорка про песочницу здесь короче всех: errors.Join — функция Go 1.20, резервный движок её не знает (тот же отказ «нет члена Join»), основной исполняет честно. Пример ниже — статичный листинг с русскими комментариями; его стоит прочитать глазами и воспроизвести локально: пять строк кода, мгновенная проверка настоящим go run.

errors.Join: набор причин и Is по всем членам

GO
package main

import (
        "errors"
        "fmt"
)

var ErrDisk = errors.New("падение диска")
var ErrNet = errors.New("сеть недоступна")

func main() {
        // набор из двух причин: не-nil собираются, порядок сохраняется
        batch := errors.Join(ErrDisk, ErrNet)
        fmt.Println(batch.Error()) // печать через перевод строки

        // Is находит ЛЮБОГО члена набора
        fmt.Println("в наборе ErrDisk?", errors.Is(batch, ErrDisk))
        fmt.Println("в наборе ErrNet?", errors.Is(batch, ErrNet))

        // Join внутри обёртки: Is раскручивает и это
        wrapped := fmt.Errorf("ночью: %w", batch)
        fmt.Println("Is сквозь обёртку?", errors.Is(wrapped, ErrDisk))

        // пустой набор — честный nil
        fmt.Println("Join(nil, nil) == nil?", errors.Join(nil, nil) == nil)
}

Настоящий Go печатает: «падение диска» и «сеть недоступна» отдельными строками (Error() набора склеивает через \n), затем true, true, true — Is находит обоих членов, в том числе сквозь %w-обёртку, — и true: Join из одних nil-ов честно даёт nil. Идиома накопления: собираете ошибки цикла в срез, возвращаете errors.Join(errs...) — без ошибок получите nil без единой проверки. В песочнице не исполняется: у резервного движка нет члена Join, основной исполняет — проверьте локальным go run.

Соберите sentinel-проверку по шагам

Соберите программу: sentinel-переменная, функция-поиск, возвращающая её как есть, и main с прямым сравнением и печатью.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Шпаргалка: инструменты вокруг ошибок#

ИнструментВопрос, на который отвечаетПримерОсторожно
errors.Newкак создать sentinelvar ErrNotFound = errors.New("запись не найдена")двойник с тем же текстом — другое значение
%wкак обернуть, сохранив причинуfmt.Errorf("страница: %w", err)%v печатает так же, но связь рвёт навсегда
==та ли это ошибка на верхнем уровнеerr == ErrNotFoundfalse после любой обёртки; в резервном движке врёт на двойниках
errors.Isта ли это ошибка в глубине цепочкиerrors.Is(err, ErrNotFound)сравнение по личности: двойник — false; nil — false
errors.Asесть ли в цепочке такой типvar perr *fs.PathError; errors.As(err, &perr)target — указатель; тип обязан реализовывать error
Unwrap()что лежит слоем нижеerrors.Unwrap(err) — одно звенона корне вернёт nil; свой тип без Unwrap запирает цепочку
errors.Joinкак вернуть несколько причинreturn errors.Join(errs...)Go 1.20; пустой набор — честный nil
err.(T)какого типа верхний уровеньif perr, ok := err.(*RepoError); okвидит только вершину; для глубины — errors.As

Правила гигиены оборачивания#

  • Оборачивайте с %w, а не с %v: печать одинакова, но только %w оставляет программе дорогу к причине через Unwrap.
  • Текст обёртки — контекст операции: «страница юзера: %w», а не «ошибка: %w»; каждый слой добавляет слово, а не повторяет «сбой».
  • Sentinel — на границе пакета: ErrNotFound с заглавной буквы, созданный один раз, — часть API, как тип или конструктор.
  • Сравнение с sentinel-ом — всегда errors.Is: == ломается от первой же обёртки, а гарантий «обёртки не будет» не даёт никто.
  • Нужен факт «та ли это ошибка» — errors.Is; нужны поля и детали — errors.As с кастомным типом; нужны обе — зовите оба на одной цепочке.
  • Свой тип ошибки — структура с Error(), при необходимости с Unwrap(); методы Is и As — точечная настройка сравнения, когда равенства по личности мало.
  • Несколько причин — errors.Join: накапливайте в срез и возвращайте Join(errs...); nil-набор вернётся сам, без проверок.
  • Не пишите цепи «для галочки»: обёртка без своего контекста — шум, который удлиняет печать и ничего не добавляет ни человеку, ни errors.Is.

Что дальше: проверяем ошибки тестами#

Система ошибок собрана целиком. Обёртка с %w хранит причину; Unwrap — дверь между слоями; errors.Is находит sentinel по личности сквозь любое число обёрток; errors.As достаёт из цепочки тип с полями. Показательно, что дженерик-конструкции тринадцатого урока остались в стороне: один интерфейсный метод и две функции закрыли вопрос целиком — простота здесь проект, а не недоработка. Ошибки на границе пакета мы объявили экспортируемыми Err-переменными — та же заглавная буква из четырнадцатого урока, только теперь она работает на протокол честности: чужой пакет сравнивает ваш sentinel с помощью errors.Is и остаётся в порядке, даже когда вы добавляете обёртки внутри.

Шестнадцатый урок — тестирование: package testing, go test, t.Errorf и табличные тесты. Связь с сегодняшним материалом прямая: тесты — первое место, где ошибки сравнивают программно, и там errors.Is против sentinel-ов станет повседневным инструментом, а тестирование собственной ошибки-структуры с полями — привычной проверкой на прочность. До встречи на странице курса: а если хочется освежить фундамент, на котором стоит всё сегодняшнее, — вернитесь к уроку про type assertion и перечитайте раздел про ok-форму: теперь вы знаете, во что она вырастает.

Карточки урока

Термин

%w против %v в fmt.Errorf

нажмите, чтобы перевернуть
Определение

Печать одинакова. %v вшивает только текст — связь с причиной теряется навсегда. %w сохраняет ссылку на причину: у обёртки появляется Unwrap(), а errors.Is и errors.As могут ходить по цепочке. Оборачивайте ошибки только с %w.

1 / 6

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Вывод

Запустите код, чтобы увидеть вывод.

Коротко: что запомнить

  • Ошибка — значение; текст — сообщение человеку, а адрес для программы — сама переменная. Оборачивание — протокол: каждый слой добавляет контекст и сохраняет причину.
  • fmt.Errorf с %w и без %w печатают одинаково, но %w сохраняет ссылку на причину (Unwrap), а %v вшивает только текст — связь теряется навсегда.
  • Обёртка — новая ошибка: == к sentinel-у даёт false даже с %w; == видит только верхний уровень цепочки.
  • errors.Is — сравнение по личности в цикле по Unwrap: находит sentinel на любой глубине; Is(nil, …) — false; двойник errors.New с тем же текстом — false.
  • Sentinel-ошибка — экспортируемая Err-переменная пакета, созданная один раз; сравнивать с ней прилично только через errors.Is.
  • errors.As ищет в цепочке ТИП: присваивает совпадение в указатель-target и отдаёт поля кастомной ошибки; target обязан указывать на тип с error.
  • ok-форма err.(T) из двенадцатого урока видит только верхний уровень — после первой обёртки её заменяет errors.As; ok-форма и As одинаково спокойно переносят nil.
  • Свой тип ошибки: поля + Error(); Unwrap() открывает цепочку, методы Is/As переопределяют сравнение; стандартный образец — fs.PathError с полями Op/Path/Err.
  • errors.Join (Go 1.20) собирает несколько причин: печать через перевод строки, Is/As по всем членам, Join(nil, nil) — nil; идиома накопления — Join(errs...).
  • В песочнице: резервный движок знает только errors.New; основной исполняет Is/Unwrap/Join честно, но As — только для типов стандартной библиотеки.

Домашнее задание

Часть первая — sentinel и обёртка: объявите var ErrNotFound = errors.New("запись не найдена") и две функции: find(id) возвращает sentinel как есть, page(id) оборачивает его fmt.Errorf("страница: %w", err). Убедитесь настоящим go run, что печать обёрнутой ошибки даёт «страница: запись не найдена», err == ErrNotFound — false, а errors.Is(err, ErrNotFound) — true; добавьте второй уровень обёртки и повторите замеры. Часть вторая — свой тип: опишите type PathError struct { Op, Path string; Err error } с методами Error() и Unwrap(); в main достаньте её из дважды обёрнутой цепочки через errors.As и напечатайте поля; проверьте, что без метода Unwrap тот же errors.Is(err, ErrNotFound) отвечает false — цепочка заперта. Часть третья — двойники: создайте два errors.New с одинаковым текстом и сравните их == и errors.Is; объясните письменно, почему ответ false, и почему sentinel объявляют переменной, а не создают на месте. Часть четвёртая — Join: соберите срез из трёх ошибок (одна nil), верните errors.Join(errs...), напечатайте результат и найдите каждый не-nil sentinel через errors.Is; проверьте, что Join(nil, nil) == nil. Комментарии в коде — по-русски.

Личные заметкиПусто
Хранятся только в вашем браузере (Go Pro · Ошибки: оборачивание, errors.Is и errors.As)

Вопросы и ответы по уроку

Чем errors.Is лучше == при сравнении с sentinel-ошибкой?

== сравнивает только верхний уровень цепочки: любая обёртка fmt.Errorf с %w делает сравнение false, даже если внутри сидит именно ваш sentinel. errors.Is проходит по лестнице Unwrap и сравнивает каждый уровень, поэтому находит sentinel на любой глубине. Кроме того, Is уважает собственные Is-методы типов, а == о них не знает. == законно лишь там, где вы гарантируете отсутствие обёрток, — а такую гарантию хрупко держать.

Почему fmt.Errorf с %v нельзя заменить на %w, если печать одинаковая?

Потому что печать — единственное, что у них общего. %v создаёт ошибку, внутри которой ничего нет: ни Unwrap, ни доступа к причине, — errors.Is и errors.As увидят пустоту. %w сохраняет ссылку на причину. Замена %w на %v ломает сравнение и извлечение типов тихо, без ошибок компиляции — именно поэтому правило «в fmt.Errorf для ошибки — всегда %w».

Когда делать sentinel-ошибку, а когда свой тип ошибки?

Sentinel — когда ошибка выражается фактом без деталей: «не найдено», «кончились данные», «нет прав». Свой тип — когда детали важны для решения: какой файл, какой лимит, какой код ответа — поля достаются через errors.As. Часто они живут вместе: тип ошибки в поле Err хранит sentinel, и callers проверяют и то (Is), и другое (As) на одной цепочке.

Зачем errors.Join, если можно вернуть первую ошибку?

Потому что «первая» — это потеря информации: при пакетной операции упало четыре файла из пяти, и вызывающий должен видеть все причины. Join собирает не-nil причины в одну ошибку, печатает их списком и честно отвечает errors.Is по каждому члену. Пустой набор даёт nil, поэтому идиома «накопил в срез — вернул Join(errs...)» не требует проверок.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Следующая ступень учебного пути

Fiber25 уроков~13 чпрактика25 уроков уже доступны

Fiber: экспресс-подобный веб-фреймворк на Fasthttp

Курс Fiber для Go: маршруты, middleware, JSON, CORS, WebSocket, шаблоны, лимитеры и тестирование. 25 уроков с примерами кода и итоговым REST API.

  • Поднимать HTTP-сервер на Fiber за минуты
  • Проектировать маршруты, группы и версии API
  • Писать middleware: логи, CORS, лимиты, recovery
  • Принимать и отдавать JSON с валидацией