$ sudo teach IT
МОДУЛЬ 4 · УРОК 4.2

Обработка ошибок — философия Go

Почему Go не использует исключения, как создавать и оборачивать ошибки, и когда допустимо вызывать panic

Ошибки в программировании — это не что-то плохое. Это нормальная часть работы программы. Файл не найден, сервер не ответил, пользователь ввёл букву вместо числа — такие ситуации случаются постоянно. Вопрос в том, как язык предлагает с ними справляться. Большинство языков (Python, Java, C#, JavaScript) используют механизм исключений — try/catch. Go пошёл другим путём: здесь ошибки — это обычные значения, которые вы возвращаете из функций и проверяете вручную. На первый взгляд это кажется многословным и неудобным, но на практике этот подход делает код предсказуемым, читаемым и надёжным. Сегодня мы разберём всё: от создания простых ошибок до их оборачивания и сравнения, а также узнаем, для чего существуют panic и recover.

🧠 Почему Go отказался от исключений

В языках вроде Python или Java ошибки обрабатываются через try/catch. Идея такая: вы оборачиваете «опасный» код в блок try, и если где-то внутри произойдёт ошибка, управление «прыгнет» в блок catch. Вот как это выглядит на условном псевдокоде:

// Псевдокод (НЕ Go!)
try {
    file = openFile("data.txt")
    data = readFile(file)
    result = parseData(data)
} catch (error) {
    print("Что-то пошло не так:", error)
}

Выглядит компактно. Но у этого подхода есть серьёзные проблемы:

1. Скрытый поток управления. Читая код, вы не видите, какая именно строка может «выбросить» ошибку. Исключение может прилететь откуда угодно — из вызванной функции, из функции внутри той функции и так далее.

2. Легко забыть обработать. Если вы не напишете try/catch, программа просто упадёт. Компилятор не напомнит вам об этом (в большинстве языков).

3. «Покемон-обработка». Программисты часто ленятся и пишут один общий catch на всё, скрывая реальные проблемы.

Создатели Go (Роб Пайк, Кен Томпсон, Роберт Грисмер) решили: ошибки — это не «исключительные ситуации», а нормальная часть потока программы. Поэтому в Go ошибка — это обычное значение, которое функция возвращает наряду с результатом. Вы обязаны явно его проверить.

Вот тот же пример на Go:

file, err := os.Open("data.txt")
if err != nil {
    fmt.Println("Не удалось открыть файл:", err)
    return
}
// Работаем с file — мы точно знаем, что он открыт успешно

Да, это длиннее. Но зато вы точно видите, где может произойти ошибка, и точно решаете, что с ней делать. Никакой магии, никаких скрытых прыжков.

📦 Тип error — интерфейс с одним методом

В Go ошибка — это любое значение, которое реализует интерфейс error. Этот интерфейс определён в стандартной библиотеке и выглядит предельно просто:

type error interface {
    Error() string
}

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

Когда функция может завершиться с ошибкой, она возвращает два значения: результат и ошибку. Это соглашение, которого придерживается весь код на Go:

func divide(a, b float64) (float64, error) {
    if b == 0 {
        return 0, errors.New("деление на ноль")
    }
    return a / b, nil
}

Обратите внимание: если ошибки нет, функция возвращает nil в качестве второго значения. В Go nil для ошибки означает «всё хорошо».

💡 Подсказка: По соглашению в Go ошибка всегда идёт последним возвращаемым значением. Если вы видите функцию func doSomething() (Result, error) — это стандартный паттерн. Никогда не ставьте ошибку первой.

🛠 Создание ошибок: errors.New и fmt.Errorf

В Go есть два основных способа создать ошибку. Первый — функция errors.New, которая принимает строку и возвращает ошибку:

package main

import (
    "errors"
    "fmt"
)

func validateAge(age int) error {
    if age < 0 {
        return errors.New("возраст не может быть отрицательным")
    }
    if age > 150 {
        return errors.New("возраст слишком большой")
    }
    return nil // Всё в порядке
}

func main() {
    err := validateAge(-5)
    if err != nil {
        fmt.Println("Ошибка:", err)
    }

    err = validateAge(25)
    if err != nil {
        fmt.Println("Ошибка:", err)
    } else {
        fmt.Println("Возраст корректный")
    }
}

Вывод программы:

Ошибка: возраст не может быть отрицательным
Возраст корректный

Второй способ — fmt.Errorf. Он работает как fmt.Sprintf, но возвращает ошибку вместо строки. Это удобно, когда в сообщение нужно подставить динамические данные:

package main

import "fmt"

func validateScore(score int) error {
    if score < 0 || score > 100 {
        return fmt.Errorf("недопустимый балл: %d (ожидается 0-100)", score)
    }
    return nil
}

func main() {
    err := validateScore(150)
    if err != nil {
        fmt.Println("Ошибка:", err)
    }
    // Вывод: Ошибка: недопустимый балл: 150 (ожидается 0-100)
}

errors.New("текст") — для простых, статичных сообщений об ошибках

fmt.Errorf("формат", аргументы...) — когда нужно подставить значения в сообщение

✅ Паттерн if err != nil — почему это правильно

Если вы читали чужой код на Go, вы наверняка заметили, что конструкция if err != nil встречается буквально на каждом шагу. Вот типичный пример:

package main

import (
    "fmt"
    "os"
    "strconv"
)

func main() {
    // Шаг 1: открыть файл
    file, err := os.Open("config.txt")
    if err != nil {
        fmt.Println("Не удалось открыть файл:", err)
        return
    }
    defer file.Close()

    // Шаг 2: прочитать содержимое
    buf := make([]byte, 100)
    n, err := file.Read(buf)
    if err != nil {
        fmt.Println("Не удалось прочитать файл:", err)
        return
    }

    // Шаг 3: преобразовать в число
    text := string(buf[:n])
    num, err := strconv.Atoi(text)
    if err != nil {
        fmt.Println("Не удалось преобразовать в число:", err)
        return
    }

    fmt.Println("Число из файла:", num)
}

Три операции — три проверки ошибок. Новичкам это кажется избыточным: «Зачем столько кода? В Python это был бы один try/catch!». Но давайте посмотрим, что даёт этот подход:

1. Явность. Глядя на код, вы мгновенно видите все точки, где может произойти ошибка. Нет скрытых «прыжков».

2. Конкретная обработка. Каждую ошибку вы обрабатываете отдельно. Сообщение «Не удалось открыть файл» гораздо полезнее, чем «Что-то пошло не так».

3. Контроль потока. После каждой проверки вы решаете: вернуть ошибку выше, попробовать снова, использовать значение по умолчанию или завершить программу.

4. Нет сюрпризов. Код выполняется строго сверху вниз. Никакая ошибка не «пролетит» мимо вас незамеченной.

Когда вы пишете функцию, которая вызывает другие функции, типичный паттерн — «пробросить» ошибку наверх:

func readConfig(filename string) (string, error) {
    data, err := os.ReadFile(filename)
    if err != nil {
        return "", err // Передаём ошибку вызывающему коду
    }
    return string(data), nil
}

💡 Подсказка: Никогда не игнорируйте ошибки! Конструкция result, _ := someFunc() (с подчёркиванием вместо err) допустима только если вы абсолютно уверены, что ошибки не будет, или она для вас не важна. В продакшен-коде это редкость.

🔗 Обёртка ошибок: %w, errors.Is, errors.As

Представьте цепочку вызовов: main вызывает loadUser, та вызывает readFile, а readFile получает ошибку от операционной системы. Если просто пробрасывать ошибку как есть, в main мы увидим только «file not found» без понимания контекста. Обёртка ошибок решает эту проблему — каждый уровень добавляет свой контекст.

Для обёртки используется глагол %w в fmt.Errorf:

package main

import (
    "errors"
    "fmt"
    "os"
)

func readFile(filename string) ([]byte, error) {
    data, err := os.ReadFile(filename)
    if err != nil {
        // Оборачиваем: добавляем контекст, но сохраняем оригинальную ошибку
        return nil, fmt.Errorf("readFile(%s): %w", filename, err)
    }
    return data, nil
}

func loadConfig() ([]byte, error) {
    data, err := readFile("config.json")
    if err != nil {
        return nil, fmt.Errorf("loadConfig: %w", err)
    }
    return data, nil
}

func main() {
    _, err := loadConfig()
    if err != nil {
        fmt.Println(err)
        // loadConfig: readFile(config.json): open config.json: no such file or directory
    }
}

Теперь сообщение об ошибке содержит всю цепочку: loadConfig → readFile → ОС. Это невероятно полезно при отладке.

Важно: используйте именно %w, а не %v или %s. Только %w создаёт настоящую обёртку, которую можно «развернуть» обратно. Форматы %v и %s просто подставляют текст ошибки в строку, и связь с оригиналом теряется.

errors.Is — проверяет, есть ли конкретная ошибка где-то в цепочке обёрток:

package main

import (
    "errors"
    "fmt"
    "os"
)

func openDatabase(path string) error {
    _, err := os.Open(path)
    if err != nil {
        return fmt.Errorf("открытие базы данных: %w", err)
    }
    return nil
}

func main() {
    err := openDatabase("/несуществующий/путь/db.sqlite")

    // Проверяем: где-то в цепочке ошибок есть ErrNotExist?
    if errors.Is(err, os.ErrNotExist) {
        fmt.Println("Файл базы данных не найден — создаём новую")
    } else if err != nil {
        fmt.Println("Неизвестная ошибка:", err)
    }
}

Без errors.Is пришлось бы сравнивать строки, а это ненадёжно. Функция errors.Is «разворачивает» всю цепочку обёрток и ищет совпадение на каждом уровне.

errors.As — работает аналогично, но извлекает ошибку определённого типа. Это полезно, когда ошибка содержит дополнительные данные:

package main

import (
    "errors"
    "fmt"
    "os"
)

func main() {
    _, err := os.Open("/несуществующий/файл.txt")

    var pathErr *os.PathError
    if errors.As(err, &pathErr) {
        fmt.Println("Операция:", pathErr.Op)   // open
        fmt.Println("Путь:", pathErr.Path)      // /несуществующий/файл.txt
        fmt.Println("Ошибка:", pathErr.Err)     // no such file or directory
    }
}

%w — оборачивает ошибку, сохраняя связь с оригиналом

errors.Is(err, target) — ищет конкретную ошибку в цепочке обёрток

errors.As(err, &target) — извлекает ошибку конкретного типа из цепочки

🏗 Собственные типы ошибок

Иногда строкового сообщения об ошибке недостаточно. Вы хотите передать вместе с ошибкой дополнительную информацию: код ошибки, проблемное значение, имя поля. Для этого вы создаёте свой тип, который реализует интерфейс error:

package main

import "fmt"

// Собственный тип ошибки
type ValidationError struct {
    Field   string
    Message string
}

// Реализуем интерфейс error
func (e *ValidationError) Error() string {
    return fmt.Sprintf("поле '%s': %s", e.Field, e.Message)
}

func validateUser(name string, age int) error {
    if name == "" {
        return &ValidationError{
            Field:   "name",
            Message: "имя не может быть пустым",
        }
    }
    if age < 0 || age > 150 {
        return &ValidationError{
            Field:   "age",
            Message: fmt.Sprintf("недопустимый возраст: %d", age),
        }
    }
    return nil
}

func main() {
    err := validateUser("", 25)
    if err != nil {
        fmt.Println(err) // поле 'name': имя не может быть пустым

        // Извлекаем подробности через errors.As
        var valErr *ValidationError
        if errors.As(err, &valErr) {
            fmt.Println("Проблемное поле:", valErr.Field)
        }
    }
}

Это мощный приём. Вызывающий код может обработать ошибку как обычную строку (err.Error()), а может извлечь дополнительные данные через errors.As. Каждый уровень работает на своём уровне детализации.

🚦 Сигнальные ошибки (sentinel errors)

Иногда ошибка не содержит динамических данных — это просто фиксированный «сигнал». В Go такие ошибки объявляются как переменные уровня пакета и по соглашению начинаются с Err:

package main

import (
    "errors"
    "fmt"
)

// Сигнальные ошибки — объявляются на уровне пакета
var (
    ErrNotFound     = errors.New("элемент не найден")
    ErrAccessDenied = errors.New("доступ запрещён")
    ErrEmpty        = errors.New("список пуст")
)

func findUser(id int) (string, error) {
    // Имитируем поиск
    if id == 0 {
        return "", ErrNotFound
    }
    if id < 0 {
        return "", ErrAccessDenied
    }
    return "Алексей", nil
}

func main() {
    _, err := findUser(0)

    // Сравниваем через errors.Is (безопасно с обёртками)
    if errors.Is(err, ErrNotFound) {
        fmt.Println("Пользователь не найден")
    } else if errors.Is(err, ErrAccessDenied) {
        fmt.Println("Нет доступа")
    } else if err != nil {
        fmt.Println("Неизвестная ошибка:", err)
    }
}

Вы уже встречали сигнальные ошибки в стандартной библиотеке Go: io.EOF (конец файла), os.ErrNotExist (файл не найден), sql.ErrNoRows (запрос не вернул строк). Это стандартный паттерн.

💥 panic и recover — когда и зачем

Помимо обычных ошибок в Go существует механизм panic. Это аварийное завершение программы, похожее на неперехваченное исключение в других языках. Когда вызывается panic, программа начинает «раскручивать» стек вызовов, выполняя все отложенные функции (defer), и затем завершается с выводом стека вызовов.

package main

func main() {
    fmt.Println("Начало")
    panic("что-то совсем пошло не так!")
    fmt.Println("Эта строка никогда не выполнится")
}

Вывод:

Начало
goroutine 1 [running]:
main.main()
        /main.go:6 +0x68
exit status 2

Когда использовать panic? Очень редко. Panic — это сигнал о том, что произошло нечто, из чего программа не может восстановиться:

Баг в программе — обращение к nil-указателю, выход за границы массива (Go делает panic автоматически)

Невозможное состояние — то, что «не должно случиться никогда», но случилось

Инициализация — если при старте программы не удалось загрузить критически важный ресурс

💡 Правило: Если ошибку можно обработать — используйте error. Если программа попала в состояние, из которого нет выхода, — panic. В 99% случаев это error.

recover — позволяет «поймать» panic и не дать программе упасть. Работает только внутри defer:

package main

import "fmt"

func safeDivide(a, b int) (result int, err error) {
    // defer с recover — ловим panic
    defer func() {
        if r := recover(); r != nil {
            err = fmt.Errorf("паника перехвачена: %v", r)
        }
    }()

    return a / b, nil // Если b == 0, Go вызовет panic (деление на ноль для int)
}

func main() {
    result, err := safeDivide(10, 2)
    fmt.Println(result, err) // 5 

    result, err = safeDivide(10, 0)
    fmt.Println(result, err) // 0 паника перехвачена: runtime error: integer divide by zero
}

Разберём, что здесь происходит. Функция defer откладывает выполнение анонимной функции до момента выхода из safeDivide. Если происходит panic, recover() возвращает значение, переданное в panic, и останавливает раскрутку стека. Мы преобразуем это в обычную ошибку.

Типичное применение recover: веб-серверы используют recover в middleware, чтобы panic в одном обработчике запроса не уронил весь сервер. Вместо падения сервер возвращает ошибку 500 и продолжает работать.

🎯 Практика: парсер конфигурации с нормальной обработкой ошибок

Давайте соберём все изученные концепции в одном реальном примере. Напишем парсер простого конфигурационного формата «ключ=значение». Формат файла:

host=localhost
port=8080
debug=true
max_connections=100

Наш парсер должен:

1. Разбирать строки формата ключ=значение

2. Сообщать о строках с неправильным форматом (с номером строки)

3. Пропускать пустые строки и комментарии (начинающиеся с #)

4. Возвращать понятные обёрнутые ошибки

package main

import (
    "errors"
    "fmt"
    "strings"
)

// --- Собственный тип ошибки ---

type ParseError struct {
    Line    int
    Content string
    Err     error
}

func (e *ParseError) Error() string {
    return fmt.Sprintf("строка %d (%q): %v", e.Line, e.Content, e.Err)
}

// Unwrap позволяет errors.Is и errors.As работать с обёрткой
func (e *ParseError) Unwrap() error {
    return e.Err
}

// --- Сигнальные ошибки ---

var (
    ErrEmptyKey   = errors.New("ключ не может быть пустым")
    ErrEmptyValue = errors.New("значение не может быть пустым")
    ErrNoEquals   = errors.New("отсутствует символ '='")
)

// --- Парсер ---

// parseLine разбирает одну строку формата "ключ=значение"
func parseLine(line string) (key, value string, err error) {
    // Убираем пробелы по краям
    line = strings.TrimSpace(line)

    // Ищем знак "="
    eqIndex := strings.Index(line, "=")
    if eqIndex == -1 {
        return "", "", ErrNoEquals
    }

    key = strings.TrimSpace(line[:eqIndex])
    value = strings.TrimSpace(line[eqIndex+1:])

    if key == "" {
        return "", "", ErrEmptyKey
    }
    if value == "" {
        return "", "", ErrEmptyValue
    }

    return key, value, nil
}

// parseConfig разбирает весь конфиг (многострочную строку)
func parseConfig(input string) (map[string]string, error) {
    config := make(map[string]string)
    lines := strings.Split(input, "\n")

    for i, line := range lines {
        lineNum := i + 1 // Нумерация с 1, как в редакторе

        // Убираем пробелы
        trimmed := strings.TrimSpace(line)

        // Пропускаем пустые строки и комментарии
        if trimmed == "" || strings.HasPrefix(trimmed, "#") {
            continue
        }

        // Парсим строку
        key, value, err := parseLine(trimmed)
        if err != nil {
            // Оборачиваем ошибку, добавляя номер строки и содержимое
            return nil, &ParseError{
                Line:    lineNum,
                Content: trimmed,
                Err:     err,
            }
        }

        config[key] = value
    }

    return config, nil
}

// --- Точка входа ---

func main() {
    // Правильный конфиг
    goodConfig := `
# Настройки сервера
host=localhost
port=8080
debug=true

# Лимиты
max_connections=100
`

    config, err := parseConfig(goodConfig)
    if err != nil {
        fmt.Println("Ошибка:", err)
    } else {
        fmt.Println("Конфиг загружен:")
        for key, value := range config {
            fmt.Printf("  %s = %s\n", key, value)
        }
    }

    fmt.Println()

    // Конфиг с ошибкой
    badConfig := `
host=localhost
port=8080
неправильная строка
debug=true
`

    _, err = parseConfig(badConfig)
    if err != nil {
        fmt.Println("Ошибка:", err)
        // строка 4 ("неправильная строка"): отсутствует символ '='

        // Проверяем конкретный тип ошибки
        var parseErr *ParseError
        if errors.As(err, &parseErr) {
            fmt.Printf("Ошибка на строке %d\n", parseErr.Line)
        }

        // Проверяем внутреннюю ошибку
        if errors.Is(err, ErrNoEquals) {
            fmt.Println("Причина: пропущен знак '='")
        }
    }

    fmt.Println()

    // Ещё один плохой конфиг
    badConfig2 := `
=значение_без_ключа
`

    _, err = parseConfig(badConfig2)
    if err != nil {
        fmt.Println("Ошибка:", err)
        // строка 2 ("=значение_без_ключа"): ключ не может быть пустым

        if errors.Is(err, ErrEmptyKey) {
            fmt.Println("Причина: пустой ключ")
        }
    }
}

Вывод программы:

Конфиг загружен:
  host = localhost
  port = 8080
  debug = true
  max_connections = 100

Ошибка: строка 4 ("неправильная строка"): отсутствует символ '='
Ошибка на строке 4
Причина: пропущен знак '='

Ошибка: строка 2 ("=значение_без_ключа"): ключ не может быть пустым
Причина: пустой ключ

Давайте разберём, какие приёмы мы здесь использовали:

Сигнальные ошибки (ErrNoEquals, ErrEmptyKey, ErrEmptyValue) — фиксированные ошибки для конкретных ситуаций

Собственный тип ошибки (ParseError) — хранит номер строки, содержимое и внутреннюю ошибку

Метод Unwrap — позволяет errors.Is и errors.As «заглядывать внутрь» обёртки

errors.As — извлекаем *ParseError, чтобы узнать номер строки

errors.Is — проверяем конкретную причину ошибки (ErrNoEquals, ErrEmptyKey)

Именно так выглядит профессиональная обработка ошибок в Go: каждый уровень добавляет контекст, а вызывающий код может получить любой уровень детализации.

⚠️ Типичные ошибки новичков

Вот самые частые ошибки при работе с ошибками в Go (звучит забавно, но это реальность):

1. Игнорирование ошибки

// ПЛОХО — ошибка молча игнорируется
data, _ := os.ReadFile("config.txt")
// Если файл не найден, data будет nil, и программа упадёт позже
// с непонятной ошибкой

// ХОРОШО — явная обработка
data, err := os.ReadFile("config.txt")
if err != nil {
    log.Fatal("Не удалось прочитать конфиг:", err)
}

2. Сравнение ошибок через == вместо errors.Is

// ПЛОХО — не работает с обёрнутыми ошибками
if err == os.ErrNotExist {
    // Не сработает, если ошибка обёрнута через fmt.Errorf("%w")
}

// ХОРОШО — проверяет всю цепочку обёрток
if errors.Is(err, os.ErrNotExist) {
    // Сработает даже для обёрнутой ошибки
}

3. Использование panic вместо error

// ПЛОХО — panic для обычных ситуаций
func findUser(id int) User {
    user, err := db.Find(id)
    if err != nil {
        panic(err) // Не делайте так!
    }
    return user
}

// ХОРОШО — возвращаем ошибку
func findUser(id int) (User, error) {
    user, err := db.Find(id)
    if err != nil {
        return User{}, fmt.Errorf("findUser(%d): %w", id, err)
    }
    return user, nil
}

4. Потеря контекста при обёртке

// ПЛОХО — оригинальная ошибка потеряна
return fmt.Errorf("ошибка чтения: %v", err) // %v — просто текст

// ХОРОШО — ошибка обёрнута, errors.Is и errors.As работают
return fmt.Errorf("ошибка чтения: %w", err) // %w — настоящая обёртка

📝 Шпаргалка по обработке ошибок

Все ключевые паттерны в одном месте:

// === Создание ошибок ===
err := errors.New("простая ошибка")
err := fmt.Errorf("ошибка: значение %d вне диапазона", val)

// === Проверка и обработка ===
result, err := someFunc()
if err != nil {
    return fmt.Errorf("контекст: %w", err) // Оборачиваем и пробрасываем
}

// === Сигнальные ошибки ===
var ErrNotFound = errors.New("не найдено")

if errors.Is(err, ErrNotFound) {
    // Обрабатываем конкретную ошибку
}

// === Собственный тип ошибки ===
type MyError struct {
    Code    int
    Message string
}
func (e *MyError) Error() string { return e.Message }

var myErr *MyError
if errors.As(err, &myErr) {
    fmt.Println(myErr.Code) // Доступ к полям ошибки
}

// === panic / recover ===
panic("критическая ошибка")            // Аварийное завершение

defer func() {                          // Перехват panic
    if r := recover(); r != nil {
        fmt.Println("перехвачено:", r)
    }
}()

📋 Итоги урока

  • Go не использует исключения (try/catch) — ошибки являются обычными значениями, которые возвращаются из функций
  • Интерфейс error содержит единственный метод Error() string — любой тип с этим методом является ошибкой
  • errors.New создаёт простую ошибку, fmt.Errorf — ошибку с форматированным сообщением
  • Паттерн if err != nil — основа обработки ошибок в Go: явно, предсказуемо, без скрытых прыжков
  • Глагол %w в fmt.Errorf оборачивает ошибку, сохраняя связь с оригиналом
  • errors.Is ищет конкретную ошибку в цепочке обёрток, errors.As извлекает ошибку определённого типа
  • Сигнальные ошибки (var ErrXxx = errors.New(...)) используются для фиксированных, заранее известных ситуаций
  • panic — аварийное завершение для действительно критических ситуаций; recover позволяет перехватить панику
  • В 99% случаев используйте error, не panic

В следующем уроке мы научимся работать с файлами — одной из самых частых задач в программировании. Вы узнаете, как читать и записывать файлы, работать с путями, обходить директории и применять все навыки обработки ошибок из этого урока на практике. Ведь именно при работе с файлами ошибки случаются чаще всего: файл не найден, нет прав доступа, диск переполнен. Урок 4.3 — работа с файлами.

Обработка ошибок — Язык Go – для начинающих! | $ sudo teach IT