Ronald Davilla 03 июня 2021

🐛 Исключения в Go – это легко?

В Go (Golang) нет специального механизма обработки исключений, и создатели языка не собираются его добавлять. Попробуем разобраться, хорошо это или плохо и как лучше разрешать проблемные ситуации в приложениях.
🐛 Исключения в Go – это легко?
Деление на ноль, сбой сети, недостаток средств на счету пользователя для совершения оплаты – все это примеры проблемных ситуаций, требующих специальной обработки. Обработка исключений и ошибок является неотъемлемой частью программирования, но в разных языках такие ситуации могут обозначаться разными терминами и обрабатываться различными методами.

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

Проверка возвращаемых ошибок

Рассмотрим базовую обработку ошибок в Go на примере функции, вычисляющей частное хранящихся в двух переменных типа string чисел:

        func divide(a, b string) (int, error) {
  firstNumber, err := strconv.Atoi(a)
  if err != nil {
     return 0, fmt.Errorf("преобразовать строку %s в число: %w", a, err)
  }
  secondNumber, err := strconv.Atoi(b)
  if err != nil {
     return 0, fmt.Errorf("преобразовать строку %s в число: %w", b, err)
  }
  division := firstNumber / secondNumber
  return division, nil
}

    

Функция strconv.Atoi конвертирует строку в целое число. Переданный ей параметр может оказаться и не числом, поэтому функция возвращает два значения: первое – результат при успешном выполнении; второе – значение ошибки, если она возникла. После вызова функции проверяется, произошла ли ошибка и если да, производятся следующие действия:

  • К ошибке добавляется дополнительная информация, которая будет полезна для поиска причины ее появления (с помощью функции fmt.Errorf и специальной последовательности символов %w).
  • Функция прерывает нормальное выполнение и возвращает ошибку как значение (в Go принято возвращать ее последним значением).

Если проблем не возникло, функция продолжит работу и по завершении вернет результат (если он есть) и пустое значение ошибки. Есть и другой вариант: к примеру, strconv.Itoa преобразует число в строку и не возвращает ошибок.

Механизм паники

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

        if secondNumber == 0 {
  return 0, ErrZeroDivisionAttempt
}
division := firstNumber / secondNumber
return division, nil

    

где

        var ErrZeroDivisionAttempt = errors.New("divide by zero is not allowed")
    

По сути все сводится к возвращению из функций значений ошибок и последующей их проверке. Явная обработка упрощает разрешение проблемных ситуаций, но требует добавления многословного повторяющегося кода проверки на err != nil после вызова почти каждой функции или метода. Это увеличивает количество строк в исходных текстах и создает помехи в понимании основной логики кода. Обработка ошибок в такой форме вызывает негодование у привыкших к более традиционному подходу обработки исключений программистов.

Если в Go добавить исключения

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

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

        func divide(a, b string) int {
  firstNumber := strconv.Atoi(a)
  secondNumber := strconv.Atoi(b)
  division := firstNumber / secondNumber
  return division
}

    

Пропала проверка на возникновении ошибки. Функция strconv.Atoi теперь возвращает только одно значение и при проблемной ситуации вместо возврата ошибки бросает исключение с помощью оператора throw:

        func Atoi(s string) int {
  if s == "" {
     throw ErrEmptyArgument
  }
  // ...
}

    

где

        var ErrEmptyArgument = errors.New("empty argument")
    

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

        try {
  result := divide("15", "10")
  fmt.Println(result)
} catch (e ErrZeroDivisionAttempt) {
  fmt.Println("Делить на ноль нельзя")
} catch (e ErrEmptyArgument) {
  fmt.Println("Невозможно конвертировать пустую строку в число")
}

    

В блоке try код способный породить исключение. Если исключение будет брошено, оно перехватится одним из блоков catch и будет выполнен соответствующий типу исключения код. Если для типа исключения (или переменной в нашем случае) не найден подходящий блок catch, исключение поднимается дальше в вызывающую функцию и далее до тех пор, пока подходящий catch не будет найден или программа (поток) не завершится. Функция divide стала бы короче и легче для чтения.

Реализация исключений через панику

Примеры кода с “исключениями” не запустятся сейчас и, вероятнее всего, в будущем, поскольку их добавления нет в планах создателей Go. Однако мы можем попробовать имитировать механизм исключений сами.

Конечный результат будет выглядеть так:

        Try(func() {
  result := divide("15", "10")
  fmt.Println(result)
}, func(err error) {
  if err == ErrZeroDivisionAttempt {
     fmt.Println("Делить на ноль нельзя")
  } else if err == ErrEmptyArgument {
     fmt.Println("Невозможно конвертировать пустую строку в число")
  }
})

    

Чтобы это работало, нужно также изменить функции следующим образом:

        func divide(a, b string) int {
  firstNumber := Atoi(a)
  secondNumber := Atoi(b)
  if secondNumber == 0 {
     panic(ErrZeroDivisionAttempt)
  }
  division := firstNumber / secondNumber
  return division
}

func Atoi(s string) int {
  if s == "" {
     panic(ErrEmptyArgument)
  }
  converted, err := strconv.Atoi(s)
  if err != nil {
     panic(err)
  }
  return converted
} 
    

И код функции Try:

        var ErrNotAnError = errors.New("not an error")
func Try(code func(), Catch func(err error)) {
  defer func() {
     e := recover()
     if e != nil {
        err, ok := e.(error)
        if !ok {
           err = ErrNotAnError
        }
        Catch(err)
     }
  }()
  code()
}

    
Механизм паники в Go очень похож на исключения. Функции неявным образом завершаются и паника идет по стеку вызовов обратно, пока программа аварийно не завершится. При этом панику можно перехватить и остановить с помощью функции recover. Более того, благодаря встроенной функции panic ее несложно и вызвать. Функция принимает один аргумент типа interface{}, который можно будет получить после вызова recover. Функция recover должна вызываться в отложенной через defer функции, так как только отложенные функции исполняются даже при панике.

Используя эти знания, мы можем поступить так: при возникновении проблемы искусственно вызывать панику, передавая ей в качестве аргумента произошедшую ошибку. Чтобы отловить эту ошибку будем использовать вспомогательную функцию Try. Первым аргументом передаем анонимную функцию с нашим кодом, а вторым – функцию обработки любой возникшей ошибки. Если функция в первом аргументе паникует, то Try перехватывает панику через recover, извлекает из возвращаемого значения тип error и передает в нашу функцию обработчик.

Подход имитирует обработку исключений в Go. Функцию Try можно модифицировать таким образом, чтобы она принимала много отдельных обработчиков ошибок и сама вызывала нужный, хотя реализовать это довольно сложно. Для удобства и возможности повторного использования, функцию Try стоит вынести в отдельный пакет и импортировать через dot import: например, import . "exception/try".

Теперь у нас есть возможность использовать подход обработки исключений, но так ли все хорошо на самом деле? Обработка исключений имеет и свои минусы, хотя она отделена от логики программы и позволяет писать более короткий код.

Нужны ли в Go исключения?

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

Из дополнительных побочных эффектов исключений стоит отметить потерю производительности. Именно поэтому разработчики игр отказываются от них в пользу альтернативных решений.

***

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

Обработка ошибок – важная часть работы программиста и какой бы метод вы бы не применяли, тщательно продумывайте поведение программы в проблемных ситуациях. Удачи!

Полный листинг кода:

        package main

import (
  "errors"
  "fmt"
  "strconv"
)

func main() {
  Try(func() {
     result := divide("15", "0")
     fmt.Println(result)
  }, func(err error) {
     switch err {
     case ErrZeroDivisionAttempt:
        fmt.Println("Делить на ноль нельзя")
     case ErrEmptyArgument:
        fmt.Println("Невозможно конвертировать пустую строку в число")
     }
  })
}

var ErrZeroDivisionAttempt = errors.New("divide by zero is not allowed")
var ErrEmptyArgument = errors.New("empty argument")

func divide(a, b string) int {
  firstNumber := Atoi(a)
  secondNumber := Atoi(b)
  if secondNumber == 0 {
     panic(ErrZeroDivisionAttempt)
  }
  division := firstNumber / secondNumber
  return division
}

func Atoi(s string) int {
  if s == "" {
     panic(ErrEmptyArgument)
  }
  converted, err := strconv.Atoi(s)
  if err != nil {
     panic(err)
  }
  return converted
}

var ErrNotAnError = errors.New("not an error")

func Try(code func(), Catch func(err error)) {
  defer func() {
     e := recover()
     if e != nil {
        err, ok := e.(error)
        if !ok {
           err = ErrNotAnError
        }
        Catch(err)
     }
  }()
  code()
}

    

МЕРОПРИЯТИЯ

Комментарии

ЛУЧШИЕ СТАТЬИ ПО ТЕМЕ