🐹 Go

Tratamento de Erros e Exceções em Go (Golang): Um Guia Detalhado

Что это такое и зачем? (аналогия из реальной жизни)

Представь, что ты — капитан космического корабля. Твой корабль летит через астероидное поле. Внезапно датчики показывают: «Внимание! Впереди огромный астероид!» Что ты сделаешь? Если ты просто проигнорируешь предупреждение и продолжишь лететь прямо, корабль разобьётся. Если же у тебя есть план действий на такой случай — ты включишь щиты, изменишь курс или активируешь лазерную пушку — корабль будет спасён.

В программировании происходит то же самое. Программа работает, выполняет команды, но вдруг что-то идёт не так: файл, который нужно открыть, не найден; интернет-соединение оборвалось; пользователь ввёл буквы вместо цифр. Если программа не знает, как реагировать на такие «аварийные ситуации», она просто «разобьётся» — аварийно завершится, потеряв все данные.

Обработка ошибок и исключений — это система «аварийных планов» для программы. Вместо того чтобы падать при первой же проблеме, программа может:

  • Узнать, что именно пошло не так
  • Принять решение: попробовать снова, использовать запасной вариант или вежливо сообщить пользователю о проблеме
  • Продолжить работу дальше, не теряя данные
Важно: В Go нет «исключений» в классическом понимании (как в Python или Java). Вместо этого Go использует явную проверку ошибок — это как если бы каждая функция возвращала не только результат, но и «флажок аварии». Программист обязан проверить этот флажок. Это сделано специально, чтобы ошибки не оставались незамеченными.

Теория: как это работает изнутри

Давай заглянем «под капот» Go и поймём, как устроена обработка ошибок на уровне языка.

Тип error — встроенный интерфейс

В Go ошибка — это не какое-то магическое слово, а обычный тип данных. Он называется error и является интерфейсом (набором правил, которым должен соответствовать любой тип, чтобы считаться ошибкой).

Интерфейс error требует всего одного метода:

type error interface {
    Error() string
}

Любой тип, у которого есть метод Error(), возвращающий строку, автоматически считается ошибкой. Это значит, что ты можешь создавать свои собственные типы ошибок с любой дополнительной информацией.

Принцип: «Ошибка — это значение»

В Go нет механизма try-catch, как в Python. Вместо этого функции, которые могут завершиться неудачей, возвращают ошибку как одно из возвращаемых значений. Обычно это последнее возвращаемое значение.

Стандартный паттерн выглядит так:

result, err := someFunction()
if err != nil {
    // Обрабатываем ошибку
    return err // или делаем что-то другое
}
// Продолжаем работу с result

Почему так сделали? Разработчики Go считают, что явная проверка ошибок делает код надёжнее. Программист не может «забыть» обработать ошибку — если он проигнорирует возвращаемое значение, компилятор может даже выдать предупреждение (а в некоторых случаях код просто не скомпилируется).

Паника (panic) — крайний случай

В Go есть аналог исключений — это паника (panic). Но её используют только в действительно критических ситуациях, когда программа не может продолжать работу: например, если произошло деление на ноль или обращение к несуществующему элементу массива.

Панику можно «поймать» с помощью recover, но это делают редко — обычно только в серверах, чтобы один упавший запрос не обрушил всю программу.

Запомни: В Go 99% ошибок обрабатываются через возврат значения error. Паника — это для «пожаров», когда всё действительно плохо.

Синтаксис в Go (Golang): все формы и варианты

Давай разберём все инструменты, которые даёт Go для работы с ошибками.

1. Создание простой ошибки

Самый простой способ создать ошибку — использовать функцию errors.New() из стандартной библиотеки:

import "errors"

err := errors.New("что-то пошло не так")

2. Форматирование ошибки с дополнительной информацией

Если нужно включить в сообщение об ошибке какие-то данные (например, имя файла или число), используй fmt.Errorf():

import "fmt"

filename := "data.txt"
err := fmt.Errorf("файл %s не найден", filename)
// err.Error() вернёт: "файл data.txt не найден"

3. Обёртывание ошибок (wrapping)

В Go 1.13 появилась возможность «оборачивать» одну ошибку в другую, добавляя контекст. Это как матрёшка: внутри одной ошибки может быть другая, более конкретная.

import "fmt"

// Создаём базовую ошибку
baseErr := errors.New("ошибка базы данных")

// Оборачиваем её в более общую
wrappedErr := fmt.Errorf("не удалось получить пользователя: %w", baseErr)

// Теперь можно проверить, является ли wrappedErr "обёрткой" для baseErr
if errors.Is(wrappedErr, baseErr) {
    fmt.Println("Да, это ошибка базы данных!")
}

Символ %w (а не %s) — это специальный маркер, который говорит: «оберни эту ошибку». Благодаря этому мы можем потом «развернуть» матрёшку и узнать первопричину.

4. Проверка типа ошибки

Иногда нужно узнать не просто «произошла ли ошибка», а какого она типа. Для этого используют errors.As():

type NotFoundError struct {
    FileName string
}

func (e *NotFoundError) Error() string {
    return "файл " + e.FileName + " не найден"
}

// Где-то в коде:
err := someFunction()
var notFound *NotFoundError
if errors.As(err, ¬Found) {
    fmt.Println("Не найден файл:", notFound.FileName)
}

5. Паника и восстановление (panic/recover)

Используется редко, но знать нужно:

func safeDivision(a, b int) (result int, err error) {
    // Отложенная функция (defer) выполнится даже при панике
    defer func() {
        if r := recover(); r != nil {
            // Превращаем панику в обычную ошибку
            err = fmt.Errorf("произошла паника: %v", r)
        }
    }()
    
    if b == 0 {
        panic("деление на ноль!")
    }
    return a / b, nil
}

Примеры кода — от простого к сложному

Пример 1: Самая простая проверка ошибки

package main

import (
    "errors"
    "fmt"
)

func main() {
    // Создаём ошибку
    err := errors.New("что-то пошло не так")
    
    // Проверяем: если ошибка не равна nil (пустоте), значит, проблема есть
    if err != nil {
        fmt.Println("Обнаружена ошибка:", err)
        // Вывод: Обнаружена ошибка: что-то пошло не так
    } else {
        fmt.Println("Всё хорошо!")
    }
}

Объяснение: Это базовый шаблон. nil в Go означает «ничего» или «пусто». Если функция возвращает nil вместо ошибки — значит, всё прошло успешно.

Пример 2: Функция, которая возвращает ошибку

package main

import (
    "fmt"
    "strconv" // Пакет для преобразования строк в числа
)

// Функция, которая может вернуть ошибку
func stringToInt(s string) (int, error) {
    // Atoi преобразует строку в число. Если не получается — возвращает ошибку
    num, err := strconv.Atoi(s)
    if err != nil {
        // Возвращаем 0 (значение по умолчанию) и ошибку
        return 0, fmt.Errorf("не удалось преобразовать '%s' в число: %w", s, err)
    }
    // Всё хорошо — возвращаем число и nil вместо ошибки
    return num, nil
}

func main() {
    // Пробуем преобразовать корректную строку
    number, err := stringToInt("42")
    if err != nil {
        fmt.Println("Ошибка:", err)
        return // Прерываем выполнение функции
    }
    fmt.Println("Число:", number) // Вывод: Число: 42
    
    // Пробуем преобразовать некорректную строку
    number2, err := stringToInt("не число")
    if err != nil {
        fmt.Println("Ошибка:", err)
        // Вывод: Ошибка: не удалось преобразовать 'не число' в число: ...
        return
    }
    fmt.Println("Число 2:", number2) // Эта строка не выполнится
}

Пример 3: Свой тип ошибки

package main

import "fmt"

// Определяем свой тип ошибки — структуру с дополнительными полями
type DivisionError struct {
    Dividend int // Делимое
    Divisor  int // Делитель
    Message  string
}

// Реализуем метод Error(), чтобы DivisionError соответствовал интерфейсу error
func (e *DivisionError) Error() string {
    return fmt.Sprintf("%s: %d / %d", e.Message, e.Dividend, e.Divisor)
}

// Функция деления с проверкой
func divide(a, b int) (int, error) {
    if b == 0 {
        // Возвращаем ошибку нашего типа
        return 0, &DivisionError{
            Dividend: a,
            Divisor:  b,
            Message:  "деление на ноль",
        }
    }
    return a / b, nil
}

func main() {
    result, err := divide(10, 0)
    if err != nil {
        // Пробуем привести ошибку к нашему типу
        if divErr, ok := err.(*DivisionError); ok {
            fmt.Printf("Ошибка деления: %s (делимое=%d, делитель=%d)
",
                divErr.Message, divErr.Dividend, divErr.Divisor)
        } else {
            fmt.Println("Неизвестная ошибка:", err)
        }
        return
    }
    fmt.Println("Результат:", result)
}

Пример 4: Обёртывание ошибок (errors.Is)

package main

import (
    "errors"
    "fmt"
)

// Определяем "базовые" ошибки
var ErrNotFound = errors.New("запись не найдена")
var ErrPermission = errors.New("нет доступа")

// Функция, которая оборачивает ошибку
func getUser(id int) (string, error) {
    if id <= 0 {
        // Оборачиваем ErrNotFound в более конкретную ошибку
        return "", fmt.Errorf("получение пользователя %d: %w", id, ErrNotFound)
    }
    if id == 1 {
        return "Администратор", nil
    }
    return "", fmt.Errorf("пользователь %d: %w", id, ErrNotFound)
}

func main() {
    _, err := getUser(-5)
    
    // Проверяем, является ли ошибка (или любая из её "обёрток") ErrNotFound
    if errors.Is(err, ErrNotFound) {
        fmt.Println("Это ошибка 'не найдено'")
    }
    
    // Проверяем, является ли ошибка ErrPermission
    if errors.Is(err, ErrPermission) {
        fmt.Println("Это ошибка доступа")
    } else {
        fmt.Println("Это НЕ ошибка доступа")
    }
    
    // Выводим полное сообщение
    fmt.Println("Полная ошибка:", err)
}

Пример 5: Паника и recover в реальном сервере

package main

import (
    "fmt"
    "net/http"
)

// Middleware для восстановления после паники
func recoveryMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if rec := recover(); rec != nil {
                // Логируем панику, но не даём упасть всему серверу
                fmt.Printf("ПАНИКА: %v (запрос: %s)
", rec, r.URL.Path)
                http.Error(w, "Внутренняя ошибка сервера", http.StatusInternalServerError)
            }
        }()
        next.ServeHTTP(w, r)
    })
}

// Обработчик, который может запаниковать
func riskyHandler(w http.ResponseWriter, r *http.Request) {
    // Имитируем панику при определённом условии
    if r.URL.Path == "/crash" {
        panic("кто-то вызвал /crash!")
    }
    fmt.Fprintf(w, "Всё работает!")
}

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/", riskyHandler)
    
    // Оборачиваем наш обработчик в middleware восстановления
    server := recoveryMiddleware(mux)
    
    fmt.Println("Сервер запущен на :8080")
    http.ListenAndServe(":8080", server)
}

Пример 6: Множественные ошибки (errors.Join)

package main

import (
    "errors"
    "fmt"
)

// Функция валидации, которая собирает все ошибки
func validateUser(name string, age int) error {
    var errs []error // Срез для сбора ошибок
    
    if name == "" {
        errs = append(errs, errors.New("имя не может быть пустым"))
    }
    if age < 0 {
        errs = append(errs, fmt.Errorf("возраст %d не может быть отрицательным", age))
    }
    if age > 150 {
        errs = append(errs, fmt.Errorf("возраст %d подозрительно большой", age))
    }
    
    // Join объединяет все ошибки в одну (доступно с Go 1.20)
    return errors.Join(errs...)
}

func main() {
    err := validateUser("", -5)
    if err != nil {
        fmt.Println("Ошибки валидации:")
        // Выводим каждую ошибку отдельно
        for _, e := range errors.Unwrap(err) {
            fmt.Println(" -", e)
        }
    }
}

Пример 7: Отложенная обработка ошибок (defer + error)

package main

import (
    "fmt"
    "os"
)

// Функция, которая гарантированно закрывает файл, даже при ошибке
func readFile(filename string) ([]byte, error) {
    file, err := os.Open(filename)
    if err != nil {
        return nil, fmt.Errorf("не удалось открыть файл: %w", err)
    }
    
    // Откладываем закрытие файла — оно выполнится при любом исходе
    defer func() {
        if closeErr := file.Close(); closeErr != nil {
            // Если при закрытии произошла ошибка — логируем её
            fmt.Printf("Предупреждение: ошибка при закрытии файла: %v
", closeErr)
        }
    }()
    
    // Читаем файл (может произойти ошибка)
    data := make([]byte, 100)
    n, err := file.Read(data)
    if err != nil {
        // Файл закроется автоматически благодаря defer
        return nil, fmt.Errorf("ошибка чтения файла: %w", err)
    }
    
    return data[:n], nil
}

func main() {
    data, err := readFile("несуществующий_файл.txt")
    if err != nil {
        fmt.Println("Ошибка:", err)
        return
    }
    fmt.Println("Прочитано байт:", len(data))
}

Сравнение с Python: чем похоже и чем отличается

Аспект Go Python
Основной механизм Возврат ошибки как значения (error) Исключения (try/except)
Как узнать об ошибке Проверить if err != nil Поймать исключение в except
Обязательность обработки Высокая (компилятор не даст проигнорировать) Низкая (можно забыть обработать)
Создание ошибки errors.New(), fmt.Errorf() Exception(), raise ValueError()
Типизация ошибок Через интерфейс error и свои структуры Через классы-исключения
Обёртывание %w + errors.Is(), errors.As() raise ... from ...
Критические ошибки panic / recover raise / try-except (единый механизм)
Производительность Высокая (нет накладных расходов на стек) Ниже (исключения строят стек-трейс)
Читаемость Много if err != nil (может быть шумно) Чище (логика отделена от обработки ошибок)
Главное различие: В Python ты говоришь «попробуй сделать это, и если что-то пойдёт не так — перехвати». В Go ты говоришь «сделай это и скажи мне, получилось ли. Если нет — я сам решу, что делать». Go делает обработку ошибок явной и неизбежной.

Типичные ошибки и подводные камни

1. Игнорирование ошибки через подчёркивание

// ПЛОХО: игнорируем ошибку
result, _ := someFunction()

// ХОРОШО: хотя бы логируем
result, err := someFunction()
if err != nil {
    log.Printf("Предупреждение: %v", err)
}

2. Сравнение ошибок через == (когда нужно errors.Is)

// ПЛОХО: не работает с обёрнутыми ошибками
if err == ErrNotFound { ... }

// ХОРОШО: работает с любым уровнем обёртки
if errors.Is(err, ErrNotFound) { ... }

3. Забыли проверить ошибку после defer

// ПЛОХО: ошибка закрытия файла потеряна
defer file.Close()

// ХОРОШО: проверяем ошибку закрытия
defer func() {
    if err := file.Close(); err != nil {
        log.Printf("Ошибка закрытия: %v", err)
    }
}()

4. Паника в горутине без recover

// ПЛОХО: паника убьёт всю программу
go func() {
    riskyOperation()
}()

// ХОРОШО: восстанавливаемся внутри горутины
go func() {
    defer func() {
        if r := recover(); r != nil {
            log.Printf("Горутина упала: %v", r)
        }
    }()
    riskyOperation()
}()

5. Слишком общая обработка ошибок

// ПЛОХО: все ошибки обрабатываются одинаково
if err != nil {
    return err // Просто передаём наверх, теряя контекст
}

// ХОРОШО: добавляем контекст
if err != nil {
    return fmt.Errorf("обработка запроса пользователя: %w", err)
}

Лучшие практики и советы профессионалов

  1. Всегда проверяйте ошибки. Даже если вы уверены, что ошибки не будет — проверьте. Компилятор Go не заставит вас это сделать, но это вопрос профессиональной этики.
  2. Добавляйте контекст при обёртывании. Вместо return err пишите return fmt.Errorf("что делали: %w", err). Это спасёт часы отладки.
  3. Используйте errors.Is и errors.As вместо приведения типов. Они работают с обёрнутыми ошибками любой глубины.
  4. Не используйте panic в библиотечном коде. Паника — для программ, а не для библиотек. Библиотеки должны возвращать ошибки.
  5. Создавайте свои типы ошибок для разных ситуаций. Это позволяет обрабатывать разные ошибки по-разному.
  6. Логируйте ошибки один раз. Если вы обернули ошибку и передали дальше — не логируйте её на каждом уровне. Логируйте там, где принимаете решение.
  7. Используйте sentinel-ошибки (var ErrXxx = errors.New(...)). Это константы-ошибки, которые можно сравнивать через errors.Is.
  8. Для критических секций используйте defer + recover. Особенно в HTTP-серверах и долго работающих горутинах.
Золотое правило Go: «Не просто обрабатывайте ошибки — обрабатывайте их осмысленно». Каждая ошибка должна быть либо обработана, либо передана выше с добавлением контекста. Нет третьего варианта.

Вопросы для самопроверки

  1. Вопрос: Какой тип данных имеет ошибка в Go?
    Ответ: Это интерфейс error, который требует наличия метода Error() string.
  2. Вопрос: Чем отличается errors.New() от fmt.Errorf()?
    Ответ: errors.New() создаёт простую ошибку с фиксированным текстом. fmt.Errorf() позволяет форматировать текст (вставлять переменные) и оборачивать ошибки через %w.
  3. Вопрос: Что такое «обёртывание ошибок» и зачем оно нужно?
    Ответ: Это добавление контекста к ошибке (например, «что делали: исходная ошибка»). Позволяет сохранить цепочку причин и использовать errors.Is() для проверки первопричины.
  4. Вопрос: В каком случае используют panic вместо возврата ошибки?
    Ответ: В критических ситуациях, когда программа не может продолжать работу (деление на ноль, выход за границы массива). В библиотеках panic использовать не рекомендуется.
  5. Вопрос: Как «поймать» панику и превратить её в ошибку?
    Ответ: Использовать defer с вызовом recover(). Если recover() вернул не nil — значит, была паника, и её можно обработать.
  6. Вопрос: Чем errors.Is() отличается от прямого сравнения ==?
    Ответ: errors.Is() проверяет всю цепочку обёрток (распаковывает матрёшку), а == сравнивает только верхний слой.
  7. Вопрос: Как создать ошибку с дополнительными полями (например, кодом ошибки)?
    Ответ: Создать свою структуру, реализовать метод Error() string, и использовать errors.As() для проверки типа при обработке.
  8. Вопрос: Почему в Go нет try-catch, как в Python?
    Ответ: Разработчики Go считают, что явная проверка ошибок делает код надёжнее и заставляет программиста думать об обработке каждой ошибки. Это философия языка.
  9. Вопрос: Что произойдёт, если не обработать ошибку, возвращённую функцией?
    Ответ: Код скомпилируется, но программа может работать неправильно или упасть позже. Компилятор Go не требует обязательной обработки (в отличие от некоторых других языков), но это считается плохой практикой.
  10. Вопрос: Как объединить несколько ошибок в одну?
    Ответ: Использовать errors.Join() (доступно с Go 1.20). Он принимает несколько ошибок и возвращает одну, которая содержит их все.

Итог и что изучить дальше

Мы прошли большой путь: от понимания, зачем нужны ошибки, до создания собственных типов ошибок и обработки паники. Ты теперь знаешь, что в Go ошибка — это не страшное исключение, а просто значение, которое нужно проверить.

Ключевые выводы:

  • Ошибки в Go — это значения типа error
  • Основной паттерн: result, err := function(); if err != nil { ... }
  • Используй fmt.Errorf() с %w для обёртывания ошибок
  • Проверяй обёрнутые ошибки через errors.Is() и errors.As()
  • Паника — для крайних случаев, используй recover в горутинах и серверах
  • Создавай свои типы ошибок для богатой обработки

Что изучить дальше:

  • Логирование: пакет log и популярные библиотеки (logrus, zap) — как правильно записывать ошибки
  • Тестирование ошибок: как писать тесты, которые проверяют, что функция возвращает правильную ошибку
  • Ошибки в горутинах: паттерны сбора ошибок из нескольких горутин (errgroup, каналы ошибок)
  • Продвинутое обёртывание: пакет errors из стандартной библиотеки — функции Unwrap(), Join()
  • Graceful shutdown: как корректно завершать программу при ошибках (перехват сигналов ОС)
Помни: Хорошая программа — это не та, которая никогда не ошибается, а та, которая достойно справляется с ошибками. Go даёт тебе все инструменты для этого. Используй их с умом!