Ошибки в Go: одна буква, которая рвёт цепочку, и утверждение типа, которое перестанет работать
Собеседование идёт лестницей: что такое error — чем %w отличается от %v — почему == не работает после обёртки — чем Is отличается от As — что делает Join — и сколько всё это стоит. Замерено: errors.Is на цепочке из двадцати обёрток дороже в 8,2 раза, а сама обёртка — это два выделения и 162 нс на пути ошибки.
Полное техническое изложение
TL;DR
Ошибка в Go — обычное значение, которое возвращают наружу. Никакого
отдельного механизма с прыжком через стек нет: функция отдаёт ошибку последним
результатом, вызывающий либо разбирается с ней сам, либо добавляет к ней
контекст и передаёт выше, а кто-то наверху решает, что с ней делать. error —
обычный интерфейс с одним методом; всё остальное — соглашения и пакет
errors.
Главное следствие: всё зависит от того, сохранился ли путь к исходной
ошибке. %w сохраняет ссылку на неё, %v — нет; разница в одну букву,
сообщение в логе одинаковое, а errors.Is даёт true и false
соответственно. По той же причине == не разворачивает обёртку: после
fmt.Errorf("...: %w", err) сравнение с исходной ошибкой даёт false.
А утверждение типа смотрит только на верхний уровень: на одной и той же ошибке
errors.As даёт true, а err.(*NotFoundError) — false, и такой код
работает ровно до чужой правки, добавившей обёртку уровнем выше. Оборачивать
при этом — значит брать обязательство: обёрнутая ошибка становится частью
интерфейса пакета, по ней начнут проверять.
Дальше — граничные случаи и цена. errors.Join — это дерево, а не обёртка:
Is находит любую из объединённых ошибок, а одноместный Unwrap для него не
определён и возвращает nil. Обёртка стоит 162 нс и два выделения, но
платится только на пути ошибки; проверка платится всегда и растёт с глубиной —
errors.Is на цепочке из двадцати обёрток дороже в 8,2 раза, чем на одной.
Числа сняты в одном прогоне на одной машине.
- функция может закончиться неудачей, и вызывающему надо об этом узнать;
- в Go функция возвращает несколько значений, и последним из них обычно идёт признак неудачи;
- интерфейс — это требование к поведению: тип подходит, если у него есть нужный метод.
- что такое обёртка ошибки, цепочка и разворачивание;
%w,errors.Is,errors.As,errors.Join, sentinel-ошибки, типизированныйnil.
Что здесь на самом деле спрашивают
Лестница почти всегда такая:
- «Что такое
errorв Go?» — разминка: интерфейс с одним методом. - «Чем
%wотличается от%v?» — здесь отваливается первая половина. - «Почему
err == ErrNotFoundперестало работать?» — про обёртку. - «
IsилиAs?» — и почему утверждение типа хуже обоих. - «Что такое sentinel-ошибка и когда её не надо?» — про обязательства.
- «Сколько это стоит?» — вопрос на честность.
Дальше урок идёт по этой лестнице. Спина у неё одна: обёртка — это ссылка вниз, и всё поведение зависит от того, есть ли она.
База: ошибка — это значение, которое передают выше
В Go нет отдельного механизма отказа: неудача сообщается обычным значением. Функция, которая может не справиться, возвращает его последним результатом, и дальше всё делается вручную — оттого поток работы с ошибкой виден целиком.
Выглядит он так, и других участников в нём нет:
- функция обнаружила неудачу и вернула ошибку вызывающему;
- вызывающий проверил её и решает: разобраться самому или передать выше;
- если передавать — он добавляет контекст: где и зачем он это делал, — и возвращает результат дальше;
- кто-то наверху принимает решение: ответить клиенту, повторить попытку,
записать в лог. Решает один, и обычно это обработчик запроса, воркер или
main.
Ключевой здесь третий шаг. Ошибка идёт снизу вверх и по дороге обрастает
контекстом: open /etc/app.yaml: no such file превращается в
load config: open /etc/app.yaml: no such file, а потом и в
serve: load config: …. В логе это читается как один путь — от места решения
до места отказа.
И вот главный вопрос урока: что происходит с исходной ошибкой, когда к ней добавляют контекст? Наверху ведь надо не просто напечатать текст, а понять, что случилось: «файла нет» — это одно решение, «нет доступа» — другое. Значит, добавление контекста обязано сохранить то, по чему наверху будут различать.
Ответ такой: новая ошибка может держать ссылку на старую, а может не держать. В обоих случаях в логе получается одна и та же строка — и это единственная разница, из которой вырастает вся остальная тема.
Этого уже достаточно, чтобы ответить на базовый вопрос собеседования: ошибка — это значение, его возвращают, по дороге вверх к нему добавляют контекст, а наверху классифицируют причину. Дальше — про то, чем добавляют контекст, как классифицируют, что происходит, когда отказов сразу несколько, и во что всё это обходится.
Механизм 1: три задачи, которые решает error
У работы с ошибками ровно три задачи, и почти вся путаница берётся из того, что их не разделяют:
| задача | чем решается |
|---|---|
| вернуть ошибку вызывающему | тип с методом Error() string |
| добавить контекст, не потеряв причину | fmt.Errorf с %w |
| классифицировать причину у вызывающего | errors.Is и errors.As |
Каждая следующая строка опирается на предыдущую: классифицировать можно только то, что не потеряно при добавлении контекста. Урок идёт по этим трём задачам.
Начнём с первой, и она короче всех:
type error interface {
Error() string
}Всё. Отсюда сразу три следствия, которые стоит назвать:
- Ошибка — обычное значение. Её можно положить в переменную, сравнить, передать, вернуть — и она ничем не отличается от других значений.
- Любой тип с методом
Error() string— ошибка. Никакой регистрации не нужно. error— интерфейс, а значит, на него распространяется ловушка типизированногоnil. Функция, объявленная возвращающейerror, но возвращающая переменную конкретного типа, вернёт «ошибку» и тогда, когда её нет.
Про оформление есть соглашение, которое проверяют на ревью:
Error strings should not be capitalized (unless beginning with proper nouns or
acronyms) or end with punctuation, since they are usually printed following
other context.
Строки ошибок не должны начинаться с заглавной буквы (если только это не имена собственные или аббревиатуры) и не должны заканчиваться знаком препинания, поскольку обычно они печатаются вслед за другим контекстом.
Отсюда и форма обёртки: fmt.Errorf("load config: %w", err) — глагол и
двоеточие, а не законченное предложение. Читается это снизу вверх как один
путь: serve: load config: open /etc/app.yaml: no such file.
Механизм 2: одна буква решает всё
Две строки, отличающиеся одним символом:
fmt.Errorf("ctx: %w", err) // цепочка сохранена
fmt.Errorf("ctx: %v", err) // цепочка разорванаСообщение в логе у них одинаковое. Разница только в том, держит ли новая ошибка ссылку на старую:
If the format specifier includes a %w verb with an error operand, the returned
error will implement an Unwrap method returning the operand.
Если в строке формата есть глагол %w с операндом-ошибкой, возвращаемая ошибка будет реализовывать метод Unwrap, возвращающий этот операнд.
Переключайте случаи — видно, есть ли под верхней ошибкой ссылка вниз:
Проверено прогоном:
errors.Is(err, base) | errors.Unwrap(err) | |
|---|---|---|
%w | true | base |
%v | false | nil |
И сразу третья строка того же прогона: wrapped() == errNotFound даёт
false. Сравнение == смотрит на само значение, а после обёртки это уже
другое значение. Отсюда правило: sentinel-ошибки сравнивают через
errors.Is, а не через == — иначе код ломается от чужой правки, добавившей
обёртку двумя уровнями ниже.
Механизм 3: Is и As — это обход цепочки, а не магия
Прежде чем смотреть на сигнатуры, стоит сказать, что эти функции делают, — иначе они запоминаются как две волшебные проверки, которые «как-то умеют».
Обе делают одно: идут по цепочке Unwrap от внешней ошибки к внутренней и
на каждом шаге задают свой вопрос.
errors.Is спрашивает «это то самое значение?», errors.As — «это тот самый
тип?». Всё остальное — детали: обе останавливаются на первом «да», обе
возвращают false, дойдя до конца цепочки.
Отсюда сразу понятно, почему %v из предыдущего раздела ломает обе: без
Unwrap цепочки нет, и обходить нечего — первый же шаг упирается в nil.
Разница между Is и As простая, если запомнить, что каждый ищет.
errors.Is ищет конкретное ЗНАЧЕНИЕ — обычно sentinel вроде
os.ErrNotExist:
Is unwraps its first argument sequentially looking for an error that matches the
second.
Is последовательно разворачивает свой первый аргумент, ища ошибку, соответствующую второму.
errors.As ищет конкретный ТИП и заодно достаёт значение, чтобы можно было
прочитать поля:
var pathErr *fs.PathError
if errors.As(err, &pathErr) {
log.Println(pathErr.Path) // ради этого As и нужен
}А хуже обоих — утверждение типа. Оно смотрит только на верхний уровень:
| результат | |
|---|---|
errors.As(err, &target) | true |
err.(*NotFoundError) | false |
Это одна и та же ошибка. Разница в том, что между ней и типом в основании оказались две обёртки. Опаснее всего здесь не сам факт, а то, как он проявляется: код с утверждением типа работает ровно до первой правки, которая добавит обёртку уровнем выше — и перестаёт работать молча, без ошибки сборки и без падения тестов.
Отсюда правило: в обработке ошибок утверждение типа не используют вовсе.
Есть errors.As, и он делает то же самое, только по всей цепочке.
Механизм 4: Join — это дерево, а не обёртка
errors.Join появился в Go 1.20 и путается с обёрткой чаще всего.
err := errors.Join(errA, errB)
errors.Is(err, errA) // true
errors.Is(err, errB) // true
errors.Unwrap(err) // nil ← вот здесь ошибаютсяПричина в том, что Join реализует Unwrap() []error, а не
Unwrap() error. Одноместный errors.Unwrap для него не определён и честно
возвращает nil. Разворачивать такую ошибку надо через Is/As, которые
умеют обходить дерево.
То же самое, кстати, делает fmt.Errorf с несколькими %w: в документации
это сказано прямо — при нескольких глаголах возвращается Unwrap() []error.
Где Join уместен. Там, где ошибок действительно несколько и все они
равноправны: проверка конфигурации, разбор формы, закрытие нескольких ресурсов.
Там, где ошибка одна, а нужен контекст, — это %w, а не Join.
Механизм 5: оборачивать — значит брать обязательство
Самый неочевидный ответ этой темы, и его стоит уметь дать.
Whether to wrap an error is a decision about whether to expose the underlying
error to the caller. Wrapping an error makes it part of your API.
Оборачивать ли ошибку — это решение о том, раскрывать ли вызывающему нижележащую ошибку. Обёртка делает её частью вашего интерфейса.
Читать это надо так: как только вы обернули sql.ErrNoRows через %w,
вызывающий может написать errors.Is(err, sql.ErrNoRows) — и он это сделает.
Смена базы данных или переход на другой драйвер сломает чужой код, хотя ваша
сигнатура не менялась.
If you don't want to commit to supporting the error as part of your API in the
future, you shouldn't wrap the error.
Если вы не готовы поддерживать эту ошибку как часть вашего интерфейса в будущем, оборачивать её не следует.
Практическое правило получается такое: внутри пакета оборачивайте %w
свободно; на границе пакета решайте осознанно. Наружу обычно выпускают свои
sentinel-ошибки (repo.ErrNotFound), а чужие переводят в них — так внутренняя
кухня не становится обязательством.
Что выбрать: таблица решения
Четыре способа сообщить о причине, и выбирают между ними не по вкусу:
| нужно | чем | цена |
|---|---|---|
| вызывающий отличает один известный случай | sentinel: var ErrNotFound = errors.New(…) | значение становится частью API навсегда |
| вызывающему нужны подробности причины | свой тип + errors.As | тип и его поля становятся частью API |
| надо добавить контекст, сохранив причину | fmt.Errorf("…: %w", err) | причина становится наблюдаемой частью контракта |
| надо сообщить о нескольких отказах сразу | errors.Join | дерево вместо цепочки; Is обходит все ветви |
| причина вызывающему не нужна | fmt.Errorf("…: %v", err) | цепочки нет — и это осознанный выбор, а не опечатка |
Последняя строка — единственный законный случай для %v, и она объясняет,
почему обе формы оставлены в языке. %v означает «текст причины в логе нужен, а
программная проверка — нет». Проблема не в самой форме, а в том, что её ставят
случайно: в логе обе выглядят одинаково.
Глубже: сколько это стоит
Числа — из прогона bench/goerrors/internals.go, снятого один раз на одной
машине: у вас наносекунды будут своими, а соотношения — те же по знаку.
Обёртка стоит заметно, но платится только на пути ошибки:
| время | выделений | |
|---|---|---|
| вернуть готовую ошибку | 0,65 нс | 0 |
errors.New на каждый вызов | 29,11 нс | |
fmt.Errorf с %w | 162,02 нс | 2 |
Сто шестьдесят наносекунд выглядят много ровно до тех пор, пока не сказано, когда они платятся: только когда ошибка уже случилась. На успешном пути ошибок нет, а значит, нет и этой цены.
Вторая строка — отдельный практический вывод: errors.New внутри функции
создаёт новую ошибку на каждый вызов. Sentinel объявляют один раз на уровне
пакета, и не только ради этих тридцати наносекунд, а чтобы по ней можно было
сравнивать.
А вот проверка платится всегда, и растёт с глубиной:
| глубина цепочки | errors.Is | |
|---|---|---|
| 1 | 12,79 нс | ×1,0 |
| 5 | 32,80 | ×2,6 |
| 10 | 55,45 | ×4,3 |
| 20 | 105,32 | ×8,2 |
errors.Is снимает обёртки по одной, поэтому цена почти линейна по глубине.
Отсюда практическое: оборачивать стоит там, где добавляется смысл, а не на
каждом уровне подряд. Три обёртки вида a: b: c: реальная причина полезны;
пятнадцать — это шум в логе и восемь лишних звеньев на каждой проверке.
Как отвечать на собеседовании
Короткий ответ: ошибка в Go — обычное значение, которое возвращают и передают вверх руками. Функция вернула, вызывающий добавил контекст и передал дальше, наверху кто-то один принял решение. Всё остальное в теме — про то, сохранилась ли при добавлении контекста ссылка на исходную ошибку: с ней наверху можно классифицировать причину, без неё остаётся только текст в логе.
Этого достаточно, чтобы ответить верно. Дальше — то, что добавляют, если собеседник копает.
Если интервьюер копает глубже
На «что такое error» отвечайте одной строкой определения. «Интерфейс с
методом Error() string; отсюда и то, что на него распространяется ловушка
типизированного nil».
Про %w и %v говорите про ссылку, а не про «оборачивает». «%w
сохраняет ссылку на исходную ошибку, %v вставляет её текст; сообщение
одинаковое, а errors.Is даёт true и false».
Про == назовите последствие. «После обёртки это другое значение, поэтому
сравнивают через errors.Is — иначе код ломается от чужой правки двумя
уровнями ниже».
Про Is и As скажите, что ищет каждый. «Is — значение, As — тип, и
As заодно достаёт поля. А утверждение типа не используют вовсе: оно смотрит
только на верхний уровень и молча перестаёт работать после первой же обёртки».
Про Join скажите «дерево». «Он реализует Unwrap() []error, поэтому
одноместный Unwrap возвращает nil, а разворачивать надо через Is/As».
Про цену разделите два числа. «Обёртка — сотни наносекунд и два выделения,
но только на пути ошибки. А проверка платится всегда и растёт с глубиной: на
двадцати обёртках errors.Is дороже в восемь раз».
Дальше спросят
Когда заводить sentinel-ошибку, а когда свой тип?
Sentinel — когда вызывающему достаточно знать факт: «не найдено», «нет
доступа», «уже существует». Одно значение на уровне пакета, сравнение через
errors.Is.
Свой тип — когда нужны подробности: какое поле не прошло проверку, какой
путь не открылся, сколько осталось попыток. Тогда errors.As достаёт значение,
и поля можно прочитать. Ориентир простой: если вызывающий будет писать
if errors.Is(...) и на этом всё — хватит sentinel; если после проверки он
полезет за данными — нужен тип.
Как реализовать Is или As для своего типа?
Метод Is(error) bool или As(any) bool на своём типе — их вызовет
errors.Is/errors.As при обходе цепочки.
Нужно это редко и в одном характерном случае: когда ошибки «одинаковы по
смыслу», но не по значению. Например, HTTPError{Code: 404} может считать себя
равной sentinel-у ErrNotFound — тогда вызывающему не нужно знать про коды.
Без такого метода errors.Is сравнивает значения обычным ==.
Что не так с github.com/pkg/errors сегодня?
Ничего фатального, но он больше не нужен для главного: обёртка и разворачивание
переехали в стандартную библиотеку в Go 1.13. Его errors.Wrap и Cause
решали ровно ту задачу, которую теперь решают %w и errors.Is.
Одну вещь он давал сверх этого — стек вызовов в момент создания ошибки, и её в стандартной библиотеке до сих пор нет. Если стек нужен, его добавляют своим типом ошибки; переносить ради этого весь проект на стороннюю библиотеку сегодня не принято.
Как правильно логировать ошибку, чтобы не логировать её трижды?
Правило простое: либо обработать, либо вернуть — но не оба сразу. Самая частая беда в логах — одна ошибка, записанная на каждом уровне стека, потому что каждый «на всякий случай» и залогировал, и вернул.
Логирует тот, кто принимает решение: обработчик HTTP, воркер, main.
Все промежуточные уровни только добавляют контекст через %w и возвращают
дальше. Тогда в логе одна запись, и она содержит весь путь.
Что делает errors.Is с nil?
errors.Is(nil, nil) даёт true, errors.Is(nil, target) при ненулевом
target — false. Специальных ловушек тут нет, но есть смежная: если функция
вернула типизированный nil в интерфейсе error, то err != nil истинно, а
errors.Is(err, ErrX) даст false — и код пойдёт по ветке «непонятная ошибка».
Отсюда практическое: ловушка типизированного nil в обработке ошибок проявляется не как паника, а как ошибка, которую не удаётся классифицировать.
Сколько уровней обёртки — это нормально?
Столько, сколько раз добавляется смысл. Ориентир: обёртка полезна, если её
текст говорит вызывающему что-то новое о том, где и зачем случилась ошибка.
open config: %w полезно; error: %w — нет.
Замер даёт и вторую сторону: на двадцати обёртках errors.Is дороже в 8,2
раза, чем на одной. Это не запрет, а напоминание, что каждое лишнее звено
платится при каждой проверке, а проверок обычно больше, чем обёрток.
Частые заблуждения
%w и %v делают одно и то же, просто %w красивее
%w сохраняет ссылку на исходную ошибку, %v вставляет её текст. Сообщение в логе одинаковое, а errors.Is даёт true и false. Одна буква — и цепочки нет, причём в ревью это не видно.
сравнивать sentinel-ошибку можно через ==
Только пока её никто не обернул. После fmt.Errorf("...: %w", err) это уже другое значение, и == даёт false. Поэтому сравнивают через errors.Is — он разворачивает цепочку.
утверждение типа и errors.As — это одно и то же
Утверждение типа смотрит только на верхний уровень: на одной и той же ошибке errors.As даёт true, а err.(*NotFoundError) — false. И ломается это молча: код работает, пока кто-то не добавил обёртку уровнем выше.
errors.Unwrap развернёт любую ошибку
Не развернёт две: сделанную через %v (ссылки нет) и сделанную через errors.Join или несколько %w — там Unwrap() []error, дерево, а не список. В обоих случаях одноместный Unwrap честно вернёт nil.
errors.Join — это способ обернуть ошибку с контекстом
Это способ соединить несколько равноправных ошибок в одну: проверка конфигурации, разбор формы, закрытие нескольких ресурсов. Для «ошибка одна, нужен контекст» есть %w.
обёртки дорогие, лучше возвращать ошибку как есть
Обёртка стоит 162 нс и два выделения, но платится только на пути ошибки: на успешном пути ошибок нет вовсе. Дороже другое — проверка: errors.Is на двадцати обёртках в 8,2 раза дороже, чем на одной, и вот это платится всегда.
errors.New можно вызывать где угодно
Можно, но errors.New внутри функции создаёт новую ошибку на каждый вызов — 29 нс и, что важнее, значение, с которым нельзя сравнить. Sentinel объявляют один раз на уровне пакета именно поэтому.
оборачивать всё через %w — всегда хорошо
Обёртка делает ошибку частью интерфейса пакета: вызывающий начнёт проверять её через errors.Is, и смена библиотеки внутри сломает чужой код. Блог Go говорит прямо: не готовы поддерживать — не оборачивайте. На границе пакета чужие ошибки переводят в свои.
Практика
Две задачи. Сначала ответьте, потом сверьтесь с настоящим выводом: в обеих правильный ответ взят из прогона скрипта, а не назначен.
Практика · что напечатает
fmt.Println(errors.Is(wrapped(), errNotFound)) fmt.Println(errors.Is(formatted(), errNotFound)) fmt.Println(wrapped() == errNotFound) var target *NotFoundError fmt.Println(errors.As(typedWrapped(), &target)) _, ok := typedWrapped().(*NotFoundError) fmt.Println(ok) fmt.Println(errors.Unwrap(typedWrapped()))
Практика · оцените
Проверка знаний
Функция вернула fmt.Errorf("ctx: %v", ErrNotFound). Что даст errors.Is(err, ErrNotFound) у вызывающего?
Это не пересказ и не отдельный текст: всё ниже взято из самой статьи — её выжимка, заголовки разборов, колонка «на самом деле» и таблица версий. Поэтому разойтись со статьёй эти тезисы не могут.
Суть
- Ошибка в Go — обычное значение, которое возвращают наружу. Никакого отдельного механизма с прыжком через стек нет: функция отдаёт ошибку последним результатом, вызывающий либо разбирается с ней сам, либо добавляет к ней контекст и передаёт выше, а кто-то наверху решает, что с ней делать.
error— обычный интерфейс с одним методом; всё остальное — соглашения и пакетerrors. - Главное следствие: всё зависит от того, сохранился ли путь к исходной ошибке.
%wсохраняет ссылку на неё,%v— нет; разница в одну букву, сообщение в логе одинаковое, аerrors.Isдаёт true и false соответственно. По той же причине==не разворачивает обёртку: послеfmt.Errorf("...: %w", err)сравнение с исходной ошибкой даёт false. А утверждение типа смотрит только на верхний уровень: на одной и той же ошибкеerrors.Asдаёт true, аerr.(*NotFoundError)— false, и такой код работает ровно до чужой правки, добавившей обёртку уровнем выше. Оборачивать при этом — значит брать обязательство: обёрнутая ошибка становится частью интерфейса пакета, по ней начнут проверять. - Дальше — граничные случаи и цена.
errors.Join— это дерево, а не обёртка:Isнаходит любую из объединённых ошибок, а одноместныйUnwrapдля него не определён и возвращает nil. Обёртка стоит 162 нс и два выделения, но платится только на пути ошибки; проверка платится всегда и растёт с глубиной —errors.Isна цепочке из двадцати обёрток дороже в 8,2 раза, чем на одной. Числа сняты в одном прогоне на одной машине.
На самом деле
%wсохраняет ссылку на исходную ошибку,%vвставляет её текст. Сообщение в логе одинаковое, аerrors.Isдаёт true и false. Одна буква — и цепочки нет, причём в ревью это не видно.- Только пока её никто не обернул. После
fmt.Errorf("...: %w", err)это уже другое значение, и==даёт false. Поэтому сравнивают черезerrors.Is— он разворачивает цепочку. - Утверждение типа смотрит только на верхний уровень: на одной и той же ошибке
errors.Asдаёт true, аerr.(*NotFoundError)— false. И ломается это молча: код работает, пока кто-то не добавил обёртку уровнем выше. - Не развернёт две: сделанную через
%v(ссылки нет) и сделанную черезerrors.Joinили несколько%w— тамUnwrap() []error, дерево, а не список. В обоих случаях одноместныйUnwrapчестно вернётnil. - Это способ соединить несколько равноправных ошибок в одну: проверка конфигурации, разбор формы, закрытие нескольких ресурсов. Для «ошибка одна, нужен контекст» есть
%w. - Обёртка стоит 162 нс и два выделения, но платится только на пути ошибки: на успешном пути ошибок нет вовсе. Дороже другое — проверка:
errors.Isна двадцати обёртках в 8,2 раза дороже, чем на одной, и вот это платится всегда. - Можно, но
errors.Newвнутри функции создаёт новую ошибку на каждый вызов — 29 нс и, что важнее, значение, с которым нельзя сравнить. Sentinel объявляют один раз на уровне пакета именно поэтому. - Обёртка делает ошибку частью интерфейса пакета: вызывающий начнёт проверять её через
errors.Is, и смена библиотеки внутри сломает чужой код. Блог Go говорит прямо: не готовы поддерживать — не оборачивайте. На границе пакета чужие ошибки переводят в свои.
Что разобрано
- Что здесь на самом деле спрашивают
- База: ошибка — это значение, которое передают выше
- Механизм 1: три задачи, которые решает error
- Механизм 2: одна буква решает всё
- Механизм 3: Is и As — это обход цепочки, а не магия
- Механизм 4: Join — это дерево, а не обёртка
- Механизм 5: оборачивать — значит брать обязательство
- Глубже: сколько это стоит
- Как отвечать на собеседовании
- Дальше спросят
- Частые заблуждения
- Практика
- Проверка знаний
Источники и что читать дальше
4 ИСТОЧНИКА
- Пакет errors — Is, As, Unwrap, JoinОфициальная документация. Правила, из которых выводится вся тема. Про Is: «Is unwraps its first argument sequentially looking for an error that matches the second» (Is последовательно разворачивает свой первый аргумент, ища ошибку, соответствующую второму). Про As: «As finds the first error in err's tree that matches target, and if one is found, sets target to that error value and returns true» (As находит в дереве err первую ошибку, соответствующую target, и, если такая найдена, присваивает target это значение ошибки и возвращает true). Про Join: «Join returns an error that wraps the given errors. Any nil error values are discarded. The error formats as the concatenation of the strings obtained by calling the Error method of each element of errs» (Join возвращает ошибку, оборачивающую переданные ошибки. Значения nil отбрасываются. Ошибка форматируется как объединение строк, полученных вызовом метода Error у каждого элемента errs).https://pkg.go.dev/errors
- Пакет fmt — глагол %wОфициальная документация. Место, где задана единственная разница между %w и %v: «If the format specifier includes a %w verb with an error operand, the returned error will implement an Unwrap method returning the operand. If there is more than one %w verb, the returned error will implement an Unwrap method returning a []error containing all the %w operands in the order they appear in the arguments» (Если в строке формата есть глагол %w с операндом-ошибкой, возвращаемая ошибка будет реализовывать метод Unwrap, возвращающий этот операнд. Если глаголов %w несколько, возвращаемая ошибка будет реализовывать метод Unwrap, возвращающий []error со всеми операндами %w в порядке их появления в аргументах).https://pkg.go.dev/fmt#Errorf
- Working with Errors in Go 1.13 — блог GoОфициальная документация. Совет самих авторов о том, когда оборачивать, а когда нет: «Whether to wrap an error is a decision about whether to expose the underlying error to the caller. Wrapping an error makes it part of your API» (Оборачивать ли ошибку — это решение о том, раскрывать ли вызывающему нижележащую ошибку. Обёртка делает её частью вашего интерфейса). И прямо про совместимость: «If you don't want to commit to supporting the error as part of your API in the future, you shouldn't wrap the error» (Если вы не готовы поддерживать эту ошибку как часть вашего интерфейса в будущем, оборачивать её не следует).https://go.dev/blog/go1.13-errors
- Effective Go и обзор кода Go — оформление ошибокОфициальная документация. Соглашение об оформлении, которое проверяют на ревью: «Error strings should not be capitalized (unless beginning with proper nouns or acronyms) or end with punctuation, since they are usually printed following other context» (Строки ошибок не должны начинаться с заглавной буквы (если только это не имена собственные или аббревиатуры) и не должны заканчиваться знаком препинания, поскольку обычно они печатаются вслед за другим контекстом). Отсюда и то, что обёртка пишется как «глагол: %w», а не как законченное предложение.https://go.dev/wiki/CodeReviewComments#error-strings