Содержание урока · 14 разделов
- errors.Is и errors.As в Go: адрес, который появляется у ошибки
- Ошибка — значение: что уже знаем и чего не хватает
- fmt.Errorf с %w и без: обёртка, которая помнит
- errors.Is: sentinel-ошибки и сравнение по личности
- Unwrap: дверь между слоями обёртки
- errors.As: достаём тип и поля из цепочки
- Свой тип ошибки с методами: Unwrap, Is и As
- errors.Join: когда причин несколько
- Шпаргалка: инструменты вокруг ошибок
- Правила гигиены оборачивания
- Что дальше: проверяем ошибки тестами
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
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-ошибки, глагол обёртки и оператор прямого сравнения.
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
GOpackage 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.New: на errors.Is, errors.As, errors.Join и errors.Unwrap он отказывается до исполнения — «у пакета errors нет члена Is» (аналогично As, Join, Unwrap). Основной движок исполняет errors.Is, errors.Unwrap и errors.Join как настоящий Go, но имеет две оговорки. Первая: errors.As согласен искать только типы стандартной библиотеки — на вашем собственном типе он остановится с сообщением «*target must be interface or implement error», поэтому кастомный тип показан статичным листингом, а в ранимом примере работает fs.PathError. Вторая: %w на кастомную ошибку основной движок печатает мусором вида %!w(...), так что свои типы в ранимом коде не оборачиваются; печать же кастомной ошибки в резервном движке даёт мусор с «&»-префиксом («&текст» вместо Error()), поэтому в ранимых блоках свои ошибки напрямую не печатаются — в predict-е ниже печатается только nil. Есть и встречная особенность резервного движка: он сравнивает ошибки по содержимому поля, поэтому два errors.New с одинаковым текстом у него отвечают == true, тогда как настоящий Go и основной движок — false; квиз выше учит именно настоящий ответ. Всё, что говорит урок о поведении настоящего 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 по всем членам
GOpackage 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 | как создать sentinel | var ErrNotFound = errors.New("запись не найдена") | двойник с тем же текстом — другое значение |
| %w | как обернуть, сохранив причину | fmt.Errorf("страница: %w", err) | %v печатает так же, но связь рвёт навсегда |
| == | та ли это ошибка на верхнем уровне | err == ErrNotFound | false после любой обёртки; в резервном движке врёт на двойниках |
| 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-форму: теперь вы знаете, во что она вырастает.
Карточки урока
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +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. Комментарии в коде — по-русски.
Личные заметкиПусто
Вопросы и ответы по уроку
Чем 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...)» не требует проверок.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Fiber: экспресс-подобный веб-фреймворк на Fasthttp
Курс Fiber для Go: маршруты, middleware, JSON, CORS, WebSocket, шаблоны, лимитеры и тестирование. 25 уроков с примерами кода и итоговым REST API.
- Поднимать HTTP-сервер на Fiber за минуты
- Проектировать маршруты, группы и версии API
- Писать middleware: логи, CORS, лимиты, recovery
- Принимать и отдавать JSON с валидацией