Ronald Davilla 03 июня 2021

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

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

Хочешь уверенно проходить IT-интервью?

Готовься к IT-собеседованиям уверенно с AI-тренажёром T1!

Мы понимаем, как сложно подготовиться: стресс, алгоритмы, вопросы, от которых голова идёт кругом. Но с AI тренажёром всё гораздо проще.

💡 Почему Т1 тренажёр — это мастхэв?

  • Получишь настоящую обратную связь: где затык, что подтянуть и как стать лучше
  • Научишься не только решать задачи, но и объяснять своё решение так, чтобы интервьюер сказал: "Вау!".
  • Освоишь все этапы собеседования, от вопросов по алгоритмам до диалога о твоих целях.

Зачем листать миллион туториалов? Просто зайди в Т1 тренажёр, потренируйся и уверенно удиви интервьюеров. Мы не обещаем лёгкой прогулки, но обещаем, что будешь готов!

Реклама. ООО «Смарт Гико», ИНН 7743264341. Erid 2VtzqwP8vqy


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

В 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()
}

    

МЕРОПРИЯТИЯ

Комментарии

ВАКАНСИИ

Добавить вакансию

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