Skip to content

Обработка ошибок

В Go существует три уровня исключений:

  • error: нормальная ошибка потока, требует обработки, игнорирование не приведёт к падению программы
  • panic: серьёзная проблема, программа должна завершиться после обработки
  • fatal: критическая проблема, программа должна немедленно завершиться

Точнее, в Go нет исключений — они выражаются через ошибки. В Go нет конструкций try-catch-finally. Основатель Go хочет контролировать ошибки, не требуя вложенных try-catch. Поэтому ошибки обычно возвращаются как значения функций:

go
func main() {
  // Открытие файла
  if file, err := os.Open("README.txt"); err != nil {
    fmt.Println(err)
        return
  }
    fmt.Println(file.Name())
}

Код открывает файл README.txt. При неудаче возвращается ошибка, выводится сообщение. Если nil — файл открыт успешно, выводится имя.

Кажется проще, чем try-catch. Но при множестве вызовов функций код заполняется if err != nil:

go
func main() {
  sum, err := checksum("main.go")
  if err != nil {
    fmt.Println(err)
    return
  }
  fmt.Println(sum)
}

func checksum(path string) (string, error) {
  file, err := os.Open(path)
  if err != nil {
    return "", err
  }
  defer file.Close()

  hash := sha256.New()
  _, err = io.Copy(hash, file)
  if err != nil {
    return "", err
  }

  var hexSum [64]byte
  sum := hash.Sum(nil)
  hex.Encode(hexSum[:], sum)

  return string(hexSum[:]), nil
}

Это критикуется в Go. Исходный код Go содержит много if err != nil. Rust также возвращает ошибки, но никто не критикует — он использует синтаксический сахар. В Go синтаксического сахара почти нет.

Но нужно рассматривать диалектически. Преимущества обработки ошибок Go:

  • Малая ментальная нагрузка: есть ошибка — обработать, нет — вернуть
  • Читаемость: обработка проста, код легко понять
  • Отладка: ошибки генерируются возвращаемыми значениями функций, можно отследить источник

Недостатки:

  • Нет информации стека (требуется сторонний пакет или собственная обёртка)
  • Некрасиво, много повторяющегося кода (зависит от предпочтений)
  • Пользовательские ошибки объявляются через var — это переменные, не константы (неправильно)
  • Проблема затенения переменных

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

TIP

Две статьи команды Go об обработке ошибок:

error

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

go
type error interface {
   Error() string
}

История error имела изменения. В версии 1.13 команда Go представила цепочечные ошибки и улучшенный механизм проверки.

Создание

Создание error:

  1. Использование функции New пакета errors:
go
err := errors.New("это ошибка")
  1. Использование функции Errorf пакета fmt для форматированной ошибки:
go
err := fmt.Errorf("это ошибка с %d параметром", 1)

Полный пример:

go
func sumPositive(i, j int) (int, error) {
   if i <= 0 || j <= 0 {
      return -1, errors.New("должно быть положительное целое")
   }
   return i + j, nil
}

Для лучшей поддерживаемости обычно не создают error временно, а используют как глобальные переменные. Пример из файла os/errors.go:

go
var (
  ErrInvalid = fs.ErrInvalid // "invalid argument"

  ErrPermission = fs.ErrPermission // "permission denied"
  ErrExist      = fs.ErrExist      // "file already exists"
  ErrNotExist   = fs.ErrNotExist   // "file does not exist"
  ErrClosed     = fs.ErrClosed     // "file already closed"

  ErrNoDeadline       = errNoDeadline()       // "file type does not support deadline"
  ErrDeadlineExceeded = errDeadlineExceeded() // "i/o timeout"
)

Все определены через var.

Пользовательские ошибки

Реализация метода Error() позволяет легко создать пользовательский error. Например, errorString в пакете errors:

go
func New(text string) error {
   return &errorString{text}
}

// структура errorString
type errorString struct {
   s string
}

func (e *errorString) Error() string {
   return e.s
}

Поскольку errorString слишком проста для выражения, многие библиотеки и официальные пакеты создают пользовательские error для различных ошибок.

Передача

Иногда вызываемая функция возвращает ошибку, но вызывающий не обрабатывает её, а возвращает вышестоящему вызывающему. Это называется передачей. Ошибки могут упаковываться多层. При определении типа ошибки верхний вызывающий может не распознать или ошибочно определить тип. Цепочные ошибки решают эту проблему.

go
type wrapError struct {
   msg string
   err error
}

func (e *wrapError) Error() string {
   return e.msg
}

func (e *wrapError) Unwrap() error {
   return e.err
}

wrapError реализует интерфейс error и добавляет метод Unwrap, возвращающий ссылку на исходную ошибку. Многослойная упаковка формирует цепочку ошибок. Поскольку структура не экспортируется, используется fmt.Errorf:

go
err := errors.New("это исходная ошибка")
wrapErr := fmt.Errorf("ошибка, %w", err)

Требуется форматный глагол %w, параметр — один допустимый error.

Обработка

Последний шаг обработки ошибок — проверка. Пакет errors предоставляет удобные функции.

go
func Unwrap(err error) error

errors.Unwrap() распаковывает цепочку ошибок:

go
func Unwrap(err error) error {
   u, ok := err.(interface { // утверждение типа
      Unwrap() error
   })
   if !ok { // нет реализации — базовый error
      return nil
   }
   return u.Unwrap() // вызов Unwrap
}

После распаковки возвращается обёрнутая ошибка, которая может быть цепочкой. Для поиска значения или типа в цепочке используется рекурсия. Стандартная библиотека предоставляет готовые функции.

go
func Is(err, target error) bool

errors.Is проверяет наличие указанной ошибки в цепочке:

go
var originalErr = errors.New("this is an error")

func wrap1() error { // упаковка исходной ошибки
   return fmt.Errorf("wrapp error %w", wrap2())
}

func wrap2() error { // исходная ошибка
   return originalErr
}

func main() {
   err := wrap1()
   if errors.Is(err, originalErr) // if err == originalErr будет false
      fmt.Println("original")
   }
}

При проверке ошибок не следует использовать ==, лучше errors.Is().

go
func As(err error, target any) bool

errors.As ищет первое совпадение типа в цепочке и присваивает значение target. Иногда нужно преобразовать error в конкретный тип для деталей. Утверждение типа для цепочки неэффективно, так как исходная ошибка обёрнута. Поэтому нужна функция As:

go
type TimeError struct { // пользовательский error
   Msg  string
   Time time.Time // время возникновения ошибки
}

func (m TimeError) Error() string {
   return m.Msg
}

func NewMyError(msg string) error {
   return &TimeError{
      Msg:  msg,
      Time: time.Now(),
   }
}

func wrap1() error { // упаковка исходной ошибки
   return fmt.Errorf("wrapp error %w", wrap2())
}

func wrap2() error { // исходная ошибка
   return NewMyError("original error")
}

func main() {
   var myerr *TimeError
   err := wrap1()
   // Проверка наличия *TimeError в цепочке
   if errors.As(err, &myerr) { // вывод времени TimeError
      fmt.Println("original", myerr.Time)
   }
}

target должен быть указателем на error. Поскольку структура возвращает указатель, error — тип *TimeError, target**TimeError.

Официальный пакет errors недостаточен — нет информации стека. Рекомендуется усиленный пакет:

github.com/pkg/errors

Пример:

go
import (
  "fmt"
  "github.com/pkg/errors"
)

func Do() error {
  return errors.New("error")
}

func main() {
  if err := Do(); err != nil {
    fmt.Printf("%+v", err)
  }
}

Вывод:

some unexpected error happened
main.Do
        D:/WorkSpace/Code/GoLeran/golearn/main.go:9
main.main
        D:/WorkSpace/Code/GoLeran/golearn/main.go:13
runtime.main
        D:/WorkSpace/Library/go/root/go1.21.3/src/runtime/proc.go:267
runtime.goexit
        D:/WorkSpace/Library/go/root/go1.21.3/src/runtime/asm_amd64.s:1650

Форматированный вывод показывает стек. По умолчанию стек не выводится. Пакет — усиленная версия стандартного errors, также написан официально, но не включён в стандартную библиотеку.

panic

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

Пример записи в nil map вызывает panic:

go
func main() {
   var dic map[string]int
   dic["a"] = 'a'
}
panic: assignment to entry in nil map

TIP

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

Создание

Явное создание panic просто — встроенная функция panic:

go
func panic(v any)

panic принимает параметр v типа any, который выводится при стеке. Пример:

go
func main() {
  initDataBase("", 0)
}

func initDataBase(host string, port int) {
  if len(host) == 0 || port == 0 {
    panic("недопустимые параметры подключения")
  }
    // ... другая логика
}

При неудаче подключения к базе данных программа не должна запускаться. Следует выбросить panic:

panic: недопустимые параметры подключения

Завершение работы

Перед выходом из-за panic выполняются завершающие работы, например defer:

go
func main() {
   defer fmt.Println("A")
   defer fmt.Println("B")
   fmt.Println("C")
   panic("panic")
   defer fmt.Println("D")
}

Вывод:

C
B
A
panic: panic

defer вышестоящих функций также выполняются:

go
func main() {
   defer fmt.Println("A")
   defer fmt.Println("B")
   fmt.Println("C")
   dangerOp()
   defer fmt.Println("D")
}

func dangerOp() {
   defer fmt.Println(1)
   defer fmt.Println(2)
   panic("panic")
   defer fmt.Println(3)
}

Вывод:

C
2
1
B
A
panic: panic

defer может содержать вложенный panic:

go
func main() {
  defer fmt.Println("A")
  defer func() {
    func() {
      panic("panicA")
      defer fmt.Println("E")
    }()
  }()
  fmt.Println("C")
  dangerOp()
  defer fmt.Println("D")
}

func dangerOp() {
  defer fmt.Println(1)
  defer fmt.Println(2)
  panic("panicB")
  defer fmt.Println(3)
}

Порядок выполнения вложенного panic в defer одинаков. При panic последующая логика не выполняется.

C
2
1
A
panic: panicB
        panic: panicA

При panic функция немедленно завершается, выполняются завершающие работы (defer), затем ошибка поднимается выше, вышестоящая функция выполняет завершающие работы до остановки программы.

При panic в дочерней горутине завершающие работы текущей горутины не触发. Если panic не восстановлен до выхода горутины, программа завершится.

go
var waitGroup sync.WaitGroup

func main() {
  demo()
}

func demo() {
  waitGroup.Add(1)
  defer func() {
    fmt.Println("A")
  }()
  fmt.Println("C")
  go dangerOp()
  waitGroup.Wait() // родительская горутина ждёт дочернюю
  defer fmt.Println("D")
}
func dangerOp() {
  defer fmt.Println(1)
  defer fmt.Println(2)
  panic("panicB")
  defer fmt.Println(3)
  waitGroup.Done()
}

Вывод:

C
2
1
panic: panicB

defer в demo() не выполнены, программа завершилась. Без waitGroup для блокировки родительской горутины выполнение demo() может быть быстрее дочерней, вывод будет запутанным:

go
func main() {
  demo()
}

func demo() {
  defer func() {
    // завершающие работы родительской горутины занимают 20ms
    time.Sleep(time.Millisecond * 20)
    fmt.Println("A")
  }()
  fmt.Println("C")
  go dangerOp()
  defer fmt.Println("D")
}
func dangerOp() {
  // дочерняя горутина выполняет логику, занимает 1ms
  time.Sleep(time.Millisecond)
  defer fmt.Println(1)
  defer fmt.Println(2)
  panic("panicB")
  defer fmt.Println(3)
}

Вывод:

C
D
2
1
panic: panicB

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

Восстановление

При panic встроенная функция recover() обрабатывает и обеспечивает продолжение работы. Должна использоваться в defer:

go
func main() {
   dangerOp()
   fmt.Println("программа нормально завершилась")
}

func dangerOp() {
   defer func() {
      if err := recover(); err != nil {
         fmt.Println(err)
         fmt.Println("panic восстановлен")
      }
   }()
   panic("произошёл panic")
}

Вызывающий не знает о panic внутри dangerOp(), программа выполняет остальную логику и нормально завершается:

произошёл panic
panic восстановлен
программа нормально завершилась

Но recover() имеет скрытые ловушки. Например, использование recover во вложенном замыкании defer:

go
func main() {
  dangerOp()
  fmt.Println("программа нормально завершилась")
}

func dangerOp() {
  defer func() {
    func() {
      if err := recover(); err != nil {
        fmt.Println(err)
        fmt.Println("panic восстановлен")
      }
    }()
  }()
  panic("произошёл panic")
}

Функция замыкания считается вызовом функции. panic передаётся вверх, а не вниз, поэтому замыкание не может восстановить panic:

panic: произошёл panic

Ещё экстремальный случай — параметр panic() равен nil:

go
func main() {
   dangerOp()
   fmt.Println("программа нормально завершилась")
}

func dangerOp() {
   defer func() {
      if err := recover(); err != nil {
         fmt.Println(err)
         fmt.Println("panic восстановлен")
      }
   }()
   panic(nil)
}

panic восстанавливается, но ошибка не выводится:

программа нормально завершилась

Особенности recover:

  1. Должен использоваться в defer
  2. Многократное использование восстановит только один panic
  3. Замыкание recover не восстановит panic внешней функции
  4. Параметр panic запрещён как nil

fatal

fatal — критическая проблема, требующая немедленной остановки программы без завершающих работ. Обычно вызывается функция Exit пакета os:

go
func main() {
  dangerOp("")
}

func dangerOp(str string) {
  if len(str) == 0 {
    fmt.Println("fatal")
    os.Exit(1)
  }
  fmt.Println("нормальная логика")
}

Вывод:

fatal

Проблемы уровня fatal редко触发 явно, обычно пассивно.

Golang by www.golangdev.cn edit