// гофер пытается найти логику среди обработки ошибок
+-------+-------+-------+-------+-------+-------+
| | err | | err | | err |
| ,_,,, | | | | |
| (◉ _ ◉) | | | | |
| /) (\ | | | |
| "" "" | | | |
+ +-------+ +-------+ +-------+
| | err | err | | err |
| | | | | |
| | | | | |
+-------+ +-------+ +-------+ +
| err | | err |
| | | |
| | | |
+ +-------+ + +-------+ +
| | err | | err | logic |
| | | | | |
| | | | | |
+-------+-------+-------+-------+-------+-------+
Я пишу на Go несколько лет, в Каруне многие вещи сделаны на нём; язык мне нравится своей простотой, незамысловатой прямолинейностью и приличной эффективностью. На других языках я писать не хочу.
Но сорян, к бесконечным if err != nil
я до конца привыкнуть так и не смог.
Да-да, я знаю все аргументы: явное лучше неявного, язык Go многословен, зато понятен, и всё такое. Но, блин, на мой взгляд Го-вэй Го-вэю рознь.
Читабельность
Одно дело отказываться от магических ORM или всеобъемлющих фреймворков — мы получаем больше контроля, а платим за это неким "лишним" техническим кодом, который особо никому и не мешает. Немного медитативного набивания кода — это даже приятно.
Но совсем другое дело, когда бизнес-логика, которая и так бывает очень сложна, замусоривается обработкой ошибок. Дело вообще не в том, что мне лень написать бойлерплейт, я с этим абсолютно ок. Дело в читабельности.
Возьмем самый-самый простой пример (псевдокод):
Вместо такого
прочитали из базы()
обработали()
записали результат()
Мы имеем
прочитали из базы()
if err != nil {
return fmt.Errorf("не смогли прочитать из базы: %w", err)
}
обработали()
if err != nil {
return fmt.Errorf("не смогли обработать: %w", err)
}
записали результат()
if err != nil {
return fmt.Errorf("не смогли записать результат: %w", err)
}
Какой из двух кусков кода боле читабелен?
Да, второй более explicit, и да, он досконально проверяет всю ошибочную фигню, но зато в первом примере сразу понятно, что происходит, а во втором приходится продираться через мусор, потому что сама логика происходящего занимает намного меньше площади экрана, чем ошибочные реакции. А если сама логика сложна и содержит какие-то хитроумные условия и подсчёты? Там капец просто.
И это, знаете ли, big fucking deal. Как вы, наверно, знаете, при программировании люди тратят на чтение кода 80-90% времени, а на написание совсем чуть-чуть. Т.е. сначала надо разобраться, что уже происходит, и лишь потом добавлять новое. Так вот, с чтением кода в Go совсем беда. И беда эта связана только с обработкой ошибок, всё остальное — в пределах нормы.
Стек трейс
Стандартный пакет errors не сохраняет стек вызовов, поэтому, когда вы в конце концов на самом высоком уровне получили ошибку и записали её в лог, в логе вы просто так не поймёте, где изначально была проблема. Например, ошибка была "ошибка SQL запроса". Где sql, какой именно запрос из сотен? А трейс есть только на момент записи лога, остальной стек уже потерян. Именно поэтому люди вынуждены выкручиваться: использовать сторонние пакеты или прояснять ошибку вручную, добавляя информацию на каждом слое (через fmt.Errorf) или логировать прямо в месте ошибки (ещё больше захламляя логику).
В общем, на практике вместо хотя бы
if err != nil {
return nil, err
}
чаще всего идёт оборачивание
if err != nil {
return nil, fmt.Errorf("мы тут делали то-то и то-то, а нам вернули ошибку: %w", err)
}
Причём это объяснение в 99% нужно не для лучшего понимания происходящего, а тупо для того, чтобы потом по сообщению в логе найти место возникшей проблемы.
Что делать?
Есть несколько десятков proposals, которые предлагают разные способы упрощения языка.
Например, ключевое слово try перед вызовом функции, которое работает практически как макрос, неявно добавляющий if err != nil {return nil, err}.
или так:
callSomeFunction() orfail
(см здесь)
Тысячи их. Как по мне, надо сделать этот самый try. Но его недостаточно, потому что никто не возвращает просто ошибку, её возвращают с пояснениями. Поэтому нужен try, запоминающий цепочку вызовов. И вот это уже будет бомба.
Статья написана по мотивам поста из канала Cross Join.
Комментарии (13)
vadimr
23.07.2024 11:48Стектрейс накладывает значительные ограничения на оптимизацию кода компилятором.
askharitonov
23.07.2024 11:48А если среди целей создания Go было создать такой язык программирования, код на котором легче использовать для машинного обучения? Наличие в языке исключений означает, что код иногда нужно просматривать сильно вперёд (до блока обработки исключений) для того, чтобы понимать, что он делает. Но, если обработка ошибки происходит примерно там, где она может случиться, то может на таком коде проще обучать нейросети?
Free_ze
23.07.2024 11:48+3Например, ключевое слово try перед вызовом функции
1 в 1 путь Rust с его макросом
try!
. Тогда сразу стоит сразу внедрять оператор?
, чтобы избежать конфликтов имен и избавиться от ада скобочек:
try(try(try(foo()).bar()).baz())
->foo()?.bar()?.baz()?
ЗЫ дизайн-доки для закрытой дискуссии нагляднее.
qiper
23.07.2024 11:48+1if err != nil {
return fmt.Errorf("не смогли прочитать из базы: %w", err)
}
Или хотя бы позволяли писать как-то так:
if err != nil return fmt.Errorf("не смогли прочитать из базы: %w", err)
jonic
23.07.2024 11:48Мне кажется что как бы функция прочитать из базы и должна говорить что именно пошло не так, не?
Почему нельзя сделать контекст вызова, который передавать внутрь функций и его же получать обратно? Если контекст стал в ошибку все следующие функции прокатываются впустую, наша функция где мы это делаем возвращает в итоге контекст с конкретной ошибкой. Как паразитное явление в контекст можно еще и результат записывать и тут же предавать его дальше по цепочке. А еще мокать контекст можно. или сериализовывать. Блин я похоже придумал как мне может понравится го.
ponikrf
23.07.2024 11:48Тоже гемор какой то. Вобще я согласен отчасти с автором статьи, но отчасти нет. Тут 2 стула - как не крути. Либо мы добавляем абстракции в виде сахара или паттернов. Либо мы оставляем простоту, но пишем больше нудятины.
jonic
23.07.2024 11:48Ну не знаю, было
прочитали из базы() if err != nil { return fmt.Errorf("не смогли прочитать из базы: %w", err) } обработали() if err != nil { return fmt.Errorf("не смогли обработать: %w", err) } записали результат() if err != nil { return fmt.Errorf("не смогли записать результат: %w", err) }
Стало
прочитали из базы(ctx) обработали(ctx) записали результат(ctx) return ctx
Dair_Targ
Этот ваш "try, запоминающий цепочку вызовов" давно придумали, монады называются.
Вообще вариантов, как сделать
придумали примерно два: либо через исключения, либо через монады. Они, конечно, стектрейс сами-по-себе не запоминают, но всегда можно сделать специальный вариант
Either
, у которогоLeft
будет хранить хоть стектрейс, хоть вообще весь контекст.GospodinKolhoznik
Ещё можно через алгебраические эффекты.