Во многих языках программирования ошибки обрабатываются с помощью исключений. Код, в котором может возникнуть ошибка, оборачивают в блок try. Если во время его выполнения возникнет исключение, обычное выполнение кода прерывается, а управление переходит в блок catch в Jave или except в Python. Там эта ошибка обрабатывается.
Язык Go предлагает другой подход: функция, которая не смогла выполнить операцию, возвращает значение error вместе с основным результатом. Например, если программе не удалось открыть файл или выполнить другой запрос. Далее вызывающий код проверяет значение ошибки и сам решает, как поступить. Так ошибки помогают управлять поведением программы. Например, вы можете повторить операцию или использовать запасной сценарий. По ошибке можно понять причину сбоя и отреагировать на разные ситуации — это делает поток выполнения кода предсказуемым.
Эта статья познакомит вас с типом error в Go, а также базовыми и продвинутыми приемами работы с ошибками. Вы узнаете механизмы оборачивания и проверки типов и разберетесь с panic, defer и recover.
Как устроен тип error в Go
В Go ошибка — это значение типа, реализующего интерфейс error. Например, io.EOF сообщает, что данные закончились, fs.ErrNotExist — что файл или каталог не найден, а context.DeadlineExceeded — что операция не успела завершиться за отведенное время.
// любой тип с методом Error() автоматически считается ошибкой
type error interface {
Error() string
}
Ошибка в Go — это значение, с которым можно работать как с любой другой переменной: передавать в функции, хранить в структурах, сравнивать и оборачивать.
Можно создать собственную структуру ошибки с дополнительными полями, например, кодом или контекстом.
Базовая обработка ошибок: проверка err != nil
В Go принято обрабатывать ошибки, проверяя возвращаемое значение err на неравенство nil.
// strconv.Atoi пытается преобразовать строку в число
number, err := strconv.Atoi("123")
if err != nil {
fmt.Println("не удалось преобразовать строку в число")
return
}
fmt.Println(number)
В этом случае err будет равен nil, и программа выведет результат number, то есть 123. Однако, если строка будет вида “abc”, преобразовать ее в число не получится, и err будет содержать ошибку.
Такую конструкцию можно использовать, когда нужно проверить, успешно ли выполнилась операция. По коду сразу видно, где может возникнуть проблема и что программа делает в этом случае.
Создание ошибок в Go
Язык Go предоставляет два основных способа создания ошибок: через пакет errors и с помощью форматированных строк.
- Функция errors.New. Принимает строковое сообщение и возвращает значение типа error. Это удобно для случаев, когда достаточно фиксированного текстового описания проблемы.
import "errors"
func divide(a, b float64) (float64, error) {
if b == 0 {
// Возвращаем значение и ошибку с текстовым описанием
return 0, errors.New("деление на ноль")
}
// При успехе возвращаем результат и nil вместо ошибки
return a / b, nil
}
- Функция fmt.Errorf. Когда нужно добавить к ошибке динамическую информацию, например, имя файла, полученное значение или контекст вызова, используют fmt.Errorf. Она работает аналогично fmt.Sprintf, то есть форматирует сообщение с подстановкой значения, но возвращает error вместо строки.
import "fmt"
func readConfig(path string) error {
if path == "" {
// Возвращаем ошибку с помощью форматированной строки
return fmt.Errorf("путь к файлу конфигурации не указан")
}
// …
// %s подставляет путь, %w оборачивает исходную ошибку err
return fmt.Errorf("не удалось прочитать файл %s: %w", path, err)
}
Также стоит обратить внимание на спецификатор %w. Он не просто подставляет текст ошибки, но и оборачивает исходную ошибку, сохраняя цепочку причин. Об оборачивании подробнее в следующем разделе.
Пользовательские типы ошибок
В более сложных сценариях можно создать собственный тип ошибки. Для этого достаточно определить структуру и реализовать метод Error():
type ValidationError struct {
Field string
Message string
}
// Error реализует интерфейс error
func (e *ValidationError) Error() string {
return fmt.Sprintf("ошибка валидации поля %s: %s", e.Field, e.Message)
}
Пользовательские типы позволяют по-разному обрабатывать ошибки разных категорий, а также нести дополнительную информацию.
Оборачивание ошибок в Go
Ошибка часто проходит через несколько уровней вызова. Например, функция чтения базы данных вызывает сетевой клиент, он обращается к операционной системе, а та возвращает ошибку соединения. Если передать наверх только исходную ошибку, будет непонятно, на каком этапе и в какой операции возникла проблема.
Оборачивание решает эту проблему. Стандартная библиотека поддерживает цепочку ошибок через спецификатор %w в fmt.Errorf:
func getUser(id int) (*User, error) {
row, err := db.Query("SELECT * FROM users WHERE id = ?", id)
if err != nil {
// Добавляем контекст, но исходная err сохраняется благодаря %w
return nil, fmt.Errorf("getUser(%d): запрос к БД: %w", id, err)
}
// ...
}
В результате формируется цепочка ошибок, где каждый уровень добавляет свой контекст: “getUser(42): запрос к БД: connection refused”. При этом исходная ошибка не теряется, ее можно извлечь и проверить программно.
Функция errors.Unwrap позволяет получить внутреннюю ошибку:
inner := errors.Unwrap(err)
Это полезно, когда нужно пройти по цепочке обернутых ошибок и найти корневую причину.
Проверка типа ошибок: errors.Is и errors.As
Бывают ситуации, когда требуется отловить конкретную ошибку, так как от ее причины зависит поведение программы. Например, io.EOF означает, что данные закончились — такое поведение не всегда считается багом и может быть просто логической точкой операции чтения.
Язык Go предоставляет в таком случае два метода: errors.Is и errors.As.
- errors.Is — проверка по значению. Метод проверяет, совпадает ли ошибка (или любая из обернутых ошибок в цепочке) с заданным значением.
if errors.Is(err, io.EOF) {
fmt.Println("достигнут конец файла")
}
Такой вариант безопаснее, чем прямое сравнение err == io.EOF, потому что errors.Is проходит по всей цепочке обернутых ошибок. Даже если исходная ошибка io.EOF была обернута несколько раз, errors.Is ее найдет.
Метод чаще применяется в проверках на стандартные ошибки: io.EOF, sql.ErrNoRows, os.ErrNotExist и подобные.
- errors.As — проверка по типу. Метод извлекает из цепочки первую ошибку, соответствующую заданному типу:
// Объявляем переменную нужного типа
var pathErr *os.PathError
// errors.As ищет ошибку типа *os.PathError.
// Если находит, записывает ее в pathErr и возвращает true.
if errors.As(err, &pathErr) {
fmt.Println("ошибка пути:", pathErr.Path)
}
В отличие от errors.Is, который сравнивает по значению, errors.As работает с типами. Метод проверяет совпадение и присваивает переменной найденную ошибку нужного типа, предоставляя доступ к ее полям и методам. Он особенно полезен при работе с пользовательскими типами ошибок, когда нужно извлечь дополнительную информацию.
Библиотеки для работы с ошибками в Go
Стандартный пакет errors покрывает большинство задач, но иногда его возможностей не хватает. Существует несколько популярных библиотек, расширяющих стандартный подход.
hashicorp/go-multierror — библиотека для работы с множественными ошибками. Позволяет объединять несколько ошибок в одну, что полезно при параллельном выполнении задач или валидации с несколькими проверками.
Стандартная библиотека новых версий Go поддерживает множественное оборачивание через fmt.Errorf с несколькими %w и предоставляет errors.Join, поэтому нет острой необходимости во внешних библиотеках для многих сценариев.
Аварии в Go: panic, defer и recover
Паника (panic) — это механизм аварийного завершения программы в Go.
Когда вызывается panic, нормальное выполнение функции прекращается, запускаются все отложенные функции (defer) и программа завершается с выводом стека вызовов:
func main() {
fmt.Println("начало")
panic("что-то пошло не так")
fmt.Println("конец") // эта строка не выполнится
}
Паника может возникать явно — через вызов panic() — или неявно, например, при обращении к nil-указателю, выходе за границы массива и делении на ноль.
numbers := []int{10, 20}
fmt.Println(numbers[5]) // panic: index out of range
Между ошибкой и паникой есть отличия:
- Ошибка (error) — это ожидаемая ситуация, например, если файл не найден, сеть недоступна или пользователь ввел некорректные данные. Они являются частью нормального потока выполнения приложения и обрабатываются через возвращаемые значения.
- Паника — это исключительная ситуация, при которой продолжение работы программы невозможно или не имеет смысла. Паника сигнализирует о баге в программе, а не об обычном сбое.
Для ожидаемых проблем стоит использовать error, а panic оставить только для ситуаций, которые никогда не должны возникать в корректной программе.
Механизм defer
defer — это важный инструмент для логирования, закрытия файлов, соединений и мьютексов, потому что он гарантирует освобождение ресурсов при любом возврате из функции, в том числе при панике. Он откладывает выполнение функции до момента возврата из текущей функции. Отложенные вызовы выполняются в порядке «последним вошел — первым вышел» (LIFO):
func handleFile(path string) error {
f, err := os.Open(path)
if err != nil {
return fmt.Errorf("не удалось открыть файл %s: %w", path, err)
}
// defer гарантирует, что файл будет закрыт при любом выходе
defer f.Close()
return nil
}
Recover: восстановление после паники
Функция recover позволяет перехватить панику и не дать программе завершиться. Она работает внутри функции, вызванной через defer.
func main() {
defer func() {
if r := recover(); r != nil {
fmt.Println("перехвачена паника:", r)
}
}()
panic("что-то пошло не так")
}
Когда вызывается panic, обычное выполнение функции прекращается и запускаются отложенные вызовы defer. В одном из них можно вызвать recover и получить значение, переданное в panic.
Но recover не стоит использовать вместо обычной обработки ошибок. Если ситуацию можно ожидаемо обработать, лучше вернуть error.
Panic и recover предназначены для действительно исключительных ситуаций.
Советы по работе с ошибками в Go
Всегда обрабатывайте ошибки. Под обработкой подразумевается не только вывод сообщения об ошибке, но и правильное реагирование на ошибку. В зависимости от ситуации программа может завершить операцию, вернуть ошибку, повторить попытку или записать информацию в лог. Старайтесь не использовать _ для игнорирования ошибки, если у вас нет на то веских оснований.
Добавляйте контекст при оборачивании. Сообщение connection refused бесполезно без информации о том, к чему именно пытались подключиться. Оборачивайте ошибки с указанием функции и параметров.
Не дублируйте логирование. Не стоит логировать и одновременно возвращать ошибку на каждом уровне. Логируйте ошибку только на том уровне, где вы ее окончательно обрабатываете.
Используйте sentinel-ошибки осознанно. Переменные вроде
var ErrNotFound = errors.New("not found")
удобны, но становятся частью публичного API-пакета. Создавайте их только для ошибок, которые должен различать вызывающий код.
Предпочитайте error паникам. Если сомневаетесь, возвращайте ошибку. Panic оправдан только при нарушении внутренних инвариантов программы.
Проверяйте ошибки через errors.Is и errors.As. Прямое сравнение == или type assertion ломаются при оборачивании ошибок. Используйте функции из пакета errors, чтобы проверка работала по всей цепочке.
Заключение
Система обработки ошибок в Go использует концепцию, в которой ошибка является значением, а не исключением. Тип error как интерфейс, механизм оборачивания через %w, методы errors.Is и errors.As формируют подход, который предпочитает явную и аккуратную работу с ошибками. Аварии через panic и recover существуют для исключительных ситуаций, но основным все-таки является возвращаемое значение err.