Деление на ноль, сбой сети, недостаток средств на счету пользователя для совершения оплаты – все это примеры проблемных ситуаций, требующих специальной обработки. Обработка исключений и ошибок является неотъемлемой частью программирования, но в разных языках такие ситуации могут обозначаться разными терминами и обрабатываться различными методами.
В 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() }