В .NET сбой — это исключение: вы throw-аете, и оно «всплывает» по стеку, пока кто-нибудь его не поймает в catch. Поток ошибок невидим в сигнатуре метода: по int Parse(string s) нельзя сказать, что внутри может вылететь FormatException. В Go всё ровно наоборот. Ошибка — это обычное значение, которое функция возвращает наряду с результатом. Обрабатывать его или нет — решает вызывающий, прямо в коде, явной проверкой. Никакого скрытого всплытия.
Это первая крупная перенастройка мышления для .NET-разработчика в теме ошибок: перестать «бросать и ловить» и начать «возвращать и проверять». Эта глава ставит правильную модель и показывает весь инструментарий пакета errors.
В Go нет исключений для штатных ошибок. Вместо этого функция, которая может завершиться неудачей, возвращает дополнительное значение типа error — по конвенции последним в списке возврата:
func ReadConfig(path string) (Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return Config{}, err // пробрасываем ошибку наверх как значение
}
// ... разбор data ...
return cfg, nil // nil-ошибка означает «успех»
}Вызывающий обязан на месте решить, что делать с err. Отсюда вездесущая идиома Go:
cfg, err := ReadConfig("app.yaml")
if err != nil {
return err // или залогировать, обернуть, вернуть дефолт — но решаем здесь
}
// сюда мы попадаем, только если err == nil
useConfig(cfg)error — это всего лишь интерфейс из стандартной библиотеки, и он предельно мал:
type error interface {
Error() string
}Любой тип с методом Error() string является ошибкой. nil означает «ошибки нет». Это не специальный синтаксис языка — это обычный интерфейс (см. Раздел 2: Интерфейсы и Duck Typing), и error ведёт себя как любой другой интерфейс: его можно хранить, сравнивать с nil, оборачивать, приводить к конкретному типу.
Многословность if err != nil — частый объект критики, но за ней стоит сознательный выбор:
- Поток управления виден. Каждая точка, где может произойти сбой, помечена в коде. Нет «невидимых» выходов из функции в произвольном месте, как при исключении.
- Ошибка — часть контракта. Сигнатура
func(...) (T, error)честно говорит: «я могу не справиться». В C# проверяемых исключений нет, и о возможномthrowвы узнаёте из документации или падения в проде. - Обработка — обычный код. Ошибку можно положить в переменную, передать в функцию, накопить в слайсе, обернуть — это значение, а не управляющая конструкция.
- Дёшево. Возврат значения не разворачивает стек и не аллоцирует объект исключения со стектрейсом. В Go нет стоимости «исключение в горячем пути».
Платой за это служит дисциплина: ошибку легко молча проигнорировать (val, _ := ...), и линтеры (errcheck, встроенный go vet) специально следят за необработанными ошибками.
Параллель с .NET: идиома
if err != nil { return ..., err }— это то, что в C# вы делаете неявно через распространение исключения вверх по стеку. Разница в видимости: в .NET точка выброса и точка перехвата разнесены и не видны друг другу, в Go каждый «прыжок» ошибки наверх написан буквой. Ближе всего по духу — стиль сResult<T>/Eitherиз функциональных библиотек (LanguageExt) илиTryParse(out ...)-паттерн, но в Go это единственный штатный способ, а не альтернатива.
Два базовых конструктора живут в стандартной библиотеке.
errors.New — для простой текстовой ошибки:
import "errors"
err := errors.New("соединение разорвано")fmt.Errorf — когда в текст нужно подставить данные:
import "fmt"
err := fmt.Errorf("не удалось открыть файл %q: код %d", name, code)Текст ошибки по конвенции пишут со строчной буквы и без точки в конце — потому что ошибки часто склеиваются в цепочку («внешний контекст: внутренняя ошибка»), и заглавные буквы/точки посреди такой строки выглядят неряшливо. go vet это проверяет.
Ключевая возможность пакета errors (с Go 1.13) — оборачивание. Когда ошибка проходит через несколько слоёв, каждый слой может добавить контекст, не теряя исходную ошибку. Делает это глагол %w в fmt.Errorf:
func loadUser(id int) (*User, error) {
row, err := db.Query(id)
if err != nil {
// %w оборачивает err, сохраняя его внутри новой ошибки
return nil, fmt.Errorf("loadUser(%d): %w", id, err)
}
// ...
}В отличие от %v (который просто вставит текст ошибки и «забудет» оригинал), %w создаёт обёртку, помнящую исходную ошибку. Так формируется цепочка: внешняя ошибка → её обёрнутая причина → её причина и т.д. Это прямой аналог InnerException в .NET, только цепочка строится явно в момент возврата.
flowchart LR
OUT["fmt.Errorf(\"handler: %w\")"] -->|Unwrap| MID["fmt.Errorf(\"loadUser: %w\")"]
MID -->|Unwrap| SENT["sql.ErrNoRows<br/>(sentinel в основании цепочки)"]
OUT -. "errors.Is(err, sql.ErrNoRows)" .-> SENT
OUT -. "errors.As(err, &pgErr)" .-> MID
Параллель с .NET:
fmt.Errorf("...: %w", err)≈new MyException("контекст", inner)— вы заворачиваете нижнюю ошибку в верхнюю, сохраняя причину. Обход цепочки черезerrors.Unwrap≈ обходex.InnerException. Разница: в .NET обёртывание — это конструктор исключения, в Go — форматная строка с%w.
Раз ошибки складываются в цепочку, нужен способ её разбирать. Пакет errors даёт четыре функции.
errors.Is(err, target) проходит всю цепочку обёрток и проверяет, совпадает ли какое-либо её звено с target. Это правильный способ сравнения с заранее объявленной ошибкой-маркером (sentinel) — вместо err == target, который сломается, как только ошибку обернут:
data, err := loadUser(42)
if errors.Is(err, sql.ErrNoRows) { // ищет sql.ErrNoRows в любом звене цепочки
return defaultUser, nil // штатная ситуация «не найдено»
}
if err != nil {
return nil, err // любая другая ошибка
}if err == sql.ErrNoRows сработал бы только для «голой» ошибки; после fmt.Errorf("...: %w", err) прямое сравнение даст false, а errors.Is — по-прежнему true.
errors.As(err, &target) ищет в цепочке звено конкретного типа и, если находит, записывает его в target — давая доступ к полям. Это аналог catch (SpecificException e):
var pathErr *fs.PathError
if errors.As(err, &pathErr) {
// нашли *fs.PathError в цепочке — можем читать его поля
fmt.Println("сбойная операция:", pathErr.Op, "путь:", pathErr.Path)
}Второй аргумент — всегда указатель на переменную нужного типа (&pathErr), куда As положит найденное звено.
errors.Unwrap(err) возвращает следующую (обёрнутую) ошибку или nil, если её нет. На практике вы редко вызываете его вручную — за вас это делают Is/As, обходя цепочку. Кастомный тип ошибки может реализовать Unwrap() error, чтобы встроиться в эту механику.
errors.Join(errs...) объединяет несколько ошибок в одну. Это нужно, когда сбоев может быть несколько и важны все — например, при валидации формы или при закрытии нескольких ресурсов:
func validate(u User) error {
var errs []error
if u.Name == "" {
errs = append(errs, errors.New("имя обязательно"))
}
if u.Age < 0 {
errs = append(errs, errors.New("возраст не может быть отрицательным"))
}
return errors.Join(errs...) // вернёт nil, если errs пуст
}Результат Join дружит с errors.Is/errors.As — они проверят каждую вложенную ошибку. Join игнорирует nil-элементы и возвращает nil, если все аргументы nil, — поэтому его удобно вызывать безусловно.
Параллель с .NET:
errors.Is≈ проверка на конкретный экземпляр/тип исключения, но по всей цепочкеInnerException.errors.As≈catch (SpecificException e)— поймать определённый тип и получить доступ к его полям, опять же сквозь всю цепочку.errors.Join≈AggregateException, который агрегирует несколько исключений (как вTask.WhenAllилиParallel.ForEach); разница в том, чтоJoin— это обычная функция над значениями, а не специальный тип, который надо «разворачивать» черезInnerExceptions/Flatten().
Есть два способа сделать ошибку, которую вызывающий сможет распознать. Выбор между ними — частый вопрос на ревью.
Sentinel — это заранее объявленная переменная-маркер уровня пакета. Вызывающий сравнивает с ней через errors.Is:
package store
import "errors"
var ErrNotFound = errors.New("запись не найдена")
func (s *Store) Get(id int) (Item, error) {
item, ok := s.data[id]
if !ok {
return Item{}, ErrNotFound // отдаём маркер
}
return item, nil
}// у вызывающего
item, err := store.Get(id)
if errors.Is(err, store.ErrNotFound) {
// конкретная штатная ветка
}Sentinel подходит, когда ошибка — это факт без дополнительных данных («не найдено», «доступ запрещён», «достигнут конец»). Канонические примеры из stdlib: io.EOF, sql.ErrNoRows, os.ErrNotExist.
Когда ошибка должна нести данные (код, поле, значение), создают тип-структуру с методом Error():
type ValidationError struct {
Field string
Value any
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("поле %q: недопустимое значение %v", e.Field, e.Value)
}Вызывающий извлекает его через errors.As и читает поля:
var vErr *ValidationError
if errors.As(err, &vErr) {
fmt.Println("проблемное поле:", vErr.Field)
}Чтобы кастомный тип встроился в цепочку обёрток (и errors.Is мог дотянуться до обёрнутой им причины), он может реализовать метод Unwrap() error, возвращающий вложенную ошибку.
| Критерий | Sentinel (var ErrX = errors.New) |
Кастомный тип (struct + Error()) |
|---|---|---|
| Несёт ли данные | нет, только факт | да, любые поля |
| Как проверяют | errors.Is(err, ErrX) |
errors.As(err, &target) |
| Когда выбрать | ошибка без контекста | нужны код/поле/детали для обработки |
| Аналог в .NET | конкретный экземпляр/маркерное исключение | свой класс-наследник Exception с полями |
Параллель с .NET: кастомный тип ошибки ≈ собственный класс-наследник
Exceptionсо своими свойствами, который вы ловите черезcatch (ValidationException e)и читаетеe.Field. Sentinel-ошибка ближе к проверке кода/маркера (какHttpRequestExceptionс конкретнымStatusCode, или сравнение с известным экземпляром) — данных нет, важен сам факт «эта ситуация».
В Go есть механизм, внешне похожий на исключения, — panic/recover. Но это не инструмент штатной обработки ошибок, и путать их с try/catch — типичная ошибка новичка из .NET.
panic разворачивает стек, выполняя по пути все отложенные defer, и, если его не перехватить, роняет всю программу. Он предназначен для действительно исключительных, невосстановимых ситуаций: нарушенный инвариант, программная ошибка, повреждённое состояние. Сам рантайм паникует при разыменовании nil, выходе за границы слайса, делении на ноль.
func mustParseTemplate(s string) *template.Template {
t, err := template.New("").Parse(s)
if err != nil {
// шаблон зашит в код: если он не парсится — это баг, программу нет смысла продолжать
panic(fmt.Sprintf("невалидный встроенный шаблон: %v", err))
}
return t
}recover перехватывает панику — но только внутри defer. Он останавливает разворачивание стека и возвращает значение, переданное в panic. Использовать его уместно на границах: например, в HTTP-middleware, чтобы паника в одном обработчике не уронила весь сервер, а превратилась в ответ 500.
func recoverMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
log.Printf("паника в обработчике: %v", rec)
http.Error(w, "internal server error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r) // если здесь случится panic — recover превратит её в 500
})
}Правило простое:
- ✅
panic— для багов и невосстановимых нарушений инвариантов; дляMust*-хелперов на этапе инициализации. - ✅
recover— на границах (middleware, корень горутины), чтобы локализовать сбой. - ❌
panic/recoverкак замена возвратуerrorдля ожидаемых сбоев (файл не найден, невалидный ввод, сетевой таймаут) — это анти-паттерн.
Паника не пересекает границу горутины: recover в одной горутине не поймает panic из другой. Поэтому каждая долгоживущая фоновая горутина, способная паниковать, должна иметь свой defer recover(), иначе её паника уронит процесс.
defer откладывает вызов до выхода из функции — и выполняется в любом случае: при обычном return, при возврате ошибки и при panic. Это идиоматичный способ освобождать ресурсы рядом с местом их захвата:
func processFile(name string) error {
f, err := os.Open(name)
if err != nil {
return err
}
defer f.Close() // выполнится при любом выходе из функции
// ... работаем с f; даже при panic или раннем return файл закроется ...
return nil
}Несколько defer выполняются в порядке LIFO (последний отложенный — первым). defer также позволяет менять именованный возвращаемый error уже после return — частый приём для обёртывания или для конвертации recover обратно в ошибку.
Параллель с .NET:
defer— это гибридfinallyиusing. Какfinally, он гарантированно выполняется при выходе из функции, включая «всплытие» паники; какusing/IDisposable, он привязывает освобождение ресурса к месту его получения (defer f.Close()≈using var f = ...). А параpanic/recover— это «аварийный» аналогthrow/catch, но зарезервированный именно для исключительных ситуаций: повседневные ошибки в Go не бросают, а возвращают.
- В Go ошибки — это значения: функция возвращает
errorпоследним, а вызывающий явно проверяет его черезif err != nil. Никакого скрытого всплытия по стеку. error— это интерфейс с единственным методомError() string;nilозначает «ошибки нет».- Создание:
errors.New(текст) иfmt.Errorf(с данными). Оборачивание глаголом%wсохраняет исходную ошибку и строит цепочку — аналогInnerException. - Распаковка:
errors.Is(сравнение с sentinel сквозь всю цепочку),errors.As(извлечение конкретного типа и его полей),errors.Unwrap(шаг по цепочке),errors.Join(несколько ошибок в одной, Go 1.20). - Sentinel (
var ErrX = errors.New) — когда важен лишь факт ошибки; кастомный тип (struct+Error()) — когда ошибка несёт данные. panic/recover— не замена ошибкам, а механизм для невосстановимых ситуаций и границ (recover в middleware).deferгарантирует очистку при любом выходе, включая панику.
Дальше — встроенный инструмент тестирования Go: пакет testing, идиома table-driven тестов, бенчмарки, Example-тесты и фаззинг.
⌂ Главная · ↑ Раздел · ← Предыдущий: Раздел 14 · → Следующий: defer, panic и recover