Все мы знаем: Go — это классный язык программирования.
Простота, ясность, скорость компиляции — мечта разработчика.
Но вот одна вещь может довести до белого каления — это обработка ошибок.
В отличие от языков вроде Java или Python, где ошибки обрабатываются с помощью конструкции try-catch, Go предпочитает явный подход: большинство функций возвращают ошибку в виде второго значения, и разработчик обязан проверять её после каждого вызова.


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


if err != nil {
    return err
}

Сначала это кажется не таким уж страшным. Но когда проект становится больше, эти проверки начинают заполнять каждую строчку кода. Программисты вынуждены постоянно проверять ошибки, что замедляет разработку и делает код менее читаемым.
Более того, с использованием сторонних библиотек бывают случаи, когда ошибки не просто возвращаются, а выбрасываются в виде паники, что ещё больше усложняет обработку.


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


Что не так с if err != nil?


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


Рассмотрим простой пример:


func LoadJSON(rawURL string) (map[string]any, error) {
    resp, err := http.Get(rawURL)
    if err != nil {
        return nil, fmt.Errorf("failed to fetch data: %w", err)
    }
    defer resp.Body.Close()

    if resp.StatusCode != http.StatusOK {
        return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
    }

    data, err := io.ReadAll(resp.Body)
    if err != nil {
        return nil, fmt.Errorf("failed to read response body: %w", err)
    }

    var result map[string]any
    if err = json.Unmarshal(data, &result); err != nil {
        return nil, fmt.Errorf("failed to unmarshal response body: %w", err)
    }

    return result, nil
}

Этот код типичен для Go: куча проверок ошибок, и лишь малая часть занимается реальной задачей — загрузкой JSON. Бесконечные условия отвлекают от сути. А если ещё и нужно обрабатывать паники, код становится совсем громоздким.


Но выход есть: предлагаю вашему вниманию библиотеку try, которая помогает упростить код и значительно уменьшить количество повторяющегося кода.


Элегантное решение: пакет try


Чтобы упростить обработку ошибок и сделать код чище, мной была создана библиотека try.
Она предлагает более лаконичный и гибкий способ управления ошибками и паниками. Главная идея состоит в том, чтобы уменьшить количество рутинных проверок и предоставить механизм для автоматического перехвата ошибок.


Библиотека try избавляет от необходимости писать одинаковые проверки на ошибки после каждого вызова функции. С её помощью код становится проще и легче для понимания. Вместо множества проверок, вы пишете логику программы, как будто всё идёт по плану, а try сам обработает возможные ошибки.


Посмотрим, как можно переписать наш пример с использованием библиотеки try:


func LoadJSON(rawURL string) (result map[string]any, err error) {
    defer try.Catch(&err)

    resp := try.Val(http.Get(rawURL))
    defer resp.Body.Close()

    try.Require(resp.StatusCode == http.StatusOK, "unexpected status code")

    data := try.Val(io.ReadAll(resp.Body))
    try.Check(json.Unmarshal(data, &result))
    return
}

В этом коде больше не нужно вручную проверять ошибки. Вместо этого мы используем try.Val, try.Check и try.Require. Если одна из этих функций обнаружит ошибку, она вызовет панику, которая будет перехвачена try.Catch, и ошибка вернётся в качестве обычного результата. Это избавляет нас от постоянных проверок if err != nil и делает код проще и компактнее.


Основные функции библиотеки try


try.Catch


  • try.Catch(*error)
    Перехватывает панику и сохраняет её в переданную переменную. Аналогично конструкции catch в других языках. Используется вместе с defer. Например:

func Foo() (err error) {
    defer try.Catch(&err)
    // ...
}

Если возникнет паника, она будет перехвачена, преобразована в ошибку и записана в переменную err, которую можно вернуть.


try.Check


  • try.Check(err error) или try.OK(err error)
    Проверяет ошибку, и если она не равна nil, вызывает панику.
    try.Check(json.Unmarshal(data, &v))

try.Val


  • try.Val(value T, err error) T
    Возвращает результат функции или вызывает панику, если произошла ошибка.

resp := try.Val(http.Get(rawURL))

Если http.Get вернёт ошибку, try.Val бросит панику, избавляя вас от необходимости проверять результат вручную.
Название Val в данном случае это что-то среднее от слова value и validate: "Вернуть значение и провалидировать на ошибку".


try.Val2, try.Val3


  • try.Val2(v1 T1, v2 T2, err error) (T1, T2)
  • try.Val3(v1 T1, v2 T2, v3 T3, err error) (T1, T2, T3)
    Используются аналогично try.Val, но для функций, которые возвращают два или три значения и ошибку.

Например:


line, isPrefix := try.Val2(buf.ReadLine())

try.Require


  • try.Require(ok bool, err any)
    Проверяет условие, и если оно ложное, вызывает панику с заданным сообщением.

try.Require(resp.StatusCode == 200, "Bad request")

Важно: Функции try.Check, try.Val, try.Val2, try.Val3 и try.Require автоматически добавляют контекст выполнения при вызове паники. В случае ошибки они не просто инициируют панику, но и добавляют информацию о файле и номере строки, где произошла ошибка, что значительно упрощает отладку и анализ кода. Однако стоит учитывать, что это делает панику более "тяжёлой" операцией, что может повлиять на производительность, особенно если ошибки возникают часто, и такие дополнительные вычисления могут оказаться избыточными.


try.Handle


  • try.Handle(errorHandler func(error))

Позволяет задать функцию для обработки паники, например для логирования ошибок. Аналогично конструкции try.Catch используется вместе с defer.


  • Пример:
    defer try.Handle(func(err error) {
        log.Printf("An error occurred: %v", err)
    })

try.Mute


  • try.Mute()

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


  • Пример:
    func foo() {
        defer try.Mute()
        // код, который может вызвать панику, но вы хотите её игнорировать
    }

try.Call


  • try.Call(fn func()) error

Выполняет любую функцию и возвращает ошибку, если внутри произошла паника.


  • Пример:
    err := try.Call(func() {
      // Ваш код
    })
    if err != nil {
      log.Printf("Error: %v", err)
    }

try.Go


  • try.Go(fn func())

Безопасно запускает горутину. Если в горутине возникает паника, программа не крашится.


  • Пример:
    try.Go(func() {
      // код в горутине
    })

try.Async


  • try.Async(fn ...func()) error

Выполняет несколько функций параллельно и ожидает их завершения. Возвращает ошибку (или совокупность ошибок), если произошла паника в одной или нескольких функциях.


  • Пример:
    err := try.Async(loadData1, loadData2, loadData3)
    if err != nil {
      log.Printf("Errors occurred: %v", err)
    }

Когда стоит использовать try?


Несмотря на все преимущества, библиотека try не всегда является идеальным решением. Важно понимать, что паника — это довольно тяжёлая операция, и её использование оправдано только в тех случаях, когда ошибка является редким и непредвиденным событием. Например, в случаях работы с внешними API, запросов к сети или парсинга данных, когда ошибки нечасты, try отлично справляется.


Однако, если ошибки возникают регулярно (например, при валидации данных), или если ваш код работает в высоконагруженной среде, где критична производительность, стандартный подход с проверкой if err != nil будет предпочтительнее. В таких случаях паники могут стать слишком дорогими для производительности и привести к замедлению работы приложения.


Таким образом, библиотека try идеально подходит для случаев, когда обрабатываются редкие или исключительные ошибки. А там, где ошибки — это часть обычного рабочего процесса, лучше придерживаться стандартного подхода Go.


История о рефакторинге


Как-то раз в одном из проектов я решил провести рефакторинг кода, который буквально утопал в проверках ошибок. Каждая операция — будь то чтение из базы данных, выполнение HTTP-запроса или десериализация данных — сопровождалась этими вечными строками if err != nil. Логика программы терялась среди многочисленных проверок, и это вызывало не только раздражение, но и затрудняло сопровождение кода.


После внедрения библиотеки try, мне удалось сократить количество кода на 23%. Часто получение и передача значений из функции в функцию, которые раньше записывались в несколько строк, теперь удавалось свести к одной строке. Сложные выражения легко заменялись на что-то вроде return try.Val(...), без создания дополнительных переменных и проверок ошибок.


Например, код вида:


a, err := fn1(...)
if err != nil {
    // обработка ошибки
}
b, err := fn2(a)
if err != nil {
    // обработка ошибки
}
return b

Легко заменялся на однострочное выражение:


return try.Val(fn2(try.Val(fn1(...))))

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


Как установить? Где почитать?


Скачать и ознакомиться с пакетом можно на гитхабе: https://github.com/goldic/try.


go get github.com/goldic/try

Пакет очень простой и не использует зависимостей. Буду рад любым замечаниям и предложениям.


В заключение


Библиотека try (https://github.com/goldic/try) значительно упрощает работу с ошибками в Go, делая код чище и избавляя от рутинных проверок. Она даёт программисту возможность писать код более линейно и логично, при этом сохраняя возможность перехватывать паники и аккуратно обрабатывать ошибки.


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


��спользуя try, вы сможете сфокусироваться на логике приложения, не отвлекаясь на мелочи вроде проверок на ошибки. С try ваш код станет проще и элегантнее, а жизнь разработчика — чуточку легче!