// гофер пытается найти логику среди обработки ошибок
+-------+-------+-------+-------+-------+-------+
| | 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.
Комментарии (76)
vadimr
23.07.2024 11:48+2Стектрейс накладывает значительные ограничения на оптимизацию кода компилятором.
dph
23.07.2024 11:48А почему, кстати? Какие именно ограничения?
vadimr
23.07.2024 11:48+1Потому что реальное состояние стека в таком случае должно соответствовать синтаксису вызовов функций в тексте программы. Вы же ведь не хотите в стектрейсе увидеть совсем другую иерархию вызовов, чем написали в коде.
А это означает запрет инлайна, запрет оптимизации концевой рекурсии и т.п.
dph
23.07.2024 11:48+1Хм, вроде бы Scala умеет оптимизировать хвостовую рекурсию в JIT, хотя и exception там есть. В Koltin тоже есть tailrec. Да и не так часто встречается хвостовая рекурсия.
Inlining в JIT тоже есть и работает.vadimr
23.07.2024 11:48+1Эксцепшенам самим по себе оптимизация не мешает, так как является эквивалентным преобразованием кода. Мешает именно интроспективному просмотру стектрейса.
Например, в C++ есть эксцепшены и де-факто есть оптимизация концевой рекурсии, но нет стектрейса. В Питоне есть эксцепшены и стектрейс, но из-за этого нет оптимизации концевой рекурсии.
dph
23.07.2024 11:48+1Так stacktrace есть во всех указанных вариантах, и в Scala и в Kotlin и в Java.
vadimr
23.07.2024 11:48Ну и поэтому с оптимизацией возникают проблемы. Допущение о том, что вызовы функций обязательно хранятся в стеке, слишком сильное для эффективной работы кода. Хотя красивое как теоретическая концепция.
dph
23.07.2024 11:48+1Хм, а по сравнению с чем проблемы с оптимизацией в JIT?
vadimr
23.07.2024 11:48По сравнению с C++. Например, первая же глубокая рекурсия вашу джаву обрушит. Доходит до того, что у программистов создаётся представление о неэффективности рекурсии самой по себе, хотя неэффективен просто механизм вызова функций в некоторых языках.
dph
23.07.2024 11:48+1Ну, в kotlin добавили tailrec для этой задачи. Но глубокая рекурсия и в C++ штука опасная и требует аккуратности.
Но тут, вроде бы, говорили про Go, а не про C++.vadimr
23.07.2024 11:48Я предполагаю, что в Go авторы руководствовались в данном случае теми же соображениями, что и в C++.
Всё это дело индивидуальных предпочтений. Я бы лично не задумываясь предпочёл свободу рекурсии детерминированному стеку вызовов. Собственно, это основная проблема, которая меня напрягает в Питоне.
dph
23.07.2024 11:48+1Я бы предпочел возможность искажения stack trace при оптимизации рекурсии отсутствию возможности к просмотру трейса.
vadimr
23.07.2024 11:48Именно так работает упомянутый Вами tailrec в Kotlin. Однако, если почитать полемику, то, похоже, практически никто из программистов на Kotlin не понимает точной семантики и назначения этой конструкции (отчасти в этом виновато неудачное имя, больше подошло бы что-то вроде nostackframe).
Однако искажение stack trace при оптимизации способно провоцировать труднообнаружимые ошибки.
tzlom
23.07.2024 11:48в плюсах есть стектрейсы (с++23), и концевая оптимизация начиная с С++11
конечно трейс не покажет полную вложенность хвостовой рекурсии, однако для диагностики этого достаточно
aamonster
23.07.2024 11:48+1Положим, что касается хвостовой рекурсии – потеря N повторяющихся фрагментов в стектрейсе – не великая проблема.
Инлайн же сам по себе особо ситуацию не портит (если, конечно, отслеживание call stack не ведётся в лоб по stack frames).
Так что мешать оптимизации будет, но не в приведённых вами примерах :-)
vadimr
23.07.2024 11:48Для цели визуального контроля стектрейса при отладке, конечно, полная выдача информации о миллиарде инстансов рекурсивной функции не является критически необходимой. Но стектрейс является также средством автоматической интроспекции, и именно в таком качестве включается в семантику языка программирования. И поэтому строгое соответствие вызовов функций и состояния стектрейса гарантируется языком.
Допустим, с точки зрения семантики языка ничто не мешает выходить из рекурсии по росту стектрейса. Хотя я бы не стал рекомендовать такую практику.
Мнение разработчика Питона, например, состоит в том, что именно стектрейс является основным препятствием для оптимизации концевой рекурсии.
aamonster
23.07.2024 11:48Я не в курсе, для чего может использоваться "автоматическая интроспекция" в данном случае (и вообще по возможности избегаю работы через рефлексию). Знаю только, что если убрать рекурсивный вызов внутрь try-catch или ещё чего, что должно обрабатывать результат вызова – это не хвостовая рекурсия :-)
vadimr
23.07.2024 11:48Довольно сложно представить необходимость собственного обработчика try-catch на каждом уровне рекурсии.
А одному общему обработчику концевая рекурсия не мешает, как и любой другой способ реализации цикла.
Хотя вообще-то в эквивалентный цикл можно преобразовать и более общий класс рекурсий, чем только концевые, и в принципе даже try-catch на каждом уровне не мешает это сделать. Но обычно компиляторы, конечно, не оптимизируют более сложную рекурсию, чем концевая.
aamonster
23.07.2024 11:48Да, я о том же. Нормальное применение хвостовой рекурсии с эксепшнами – это эксепшн или обрабатывается внутри, или рекурсивная функция заворачивается ещё в одну нерекурсивную (всё равно часто приходится это делать для передачи в рекурсивную функцию аккумулятора), и эксепшн ловится уже в той.
Впрочем, я вообще не люблю такую рекурсию, я люблю вместо неё fold/zip/...
vadimr
23.07.2024 11:48Конечно, если есть возможность применить функции высших порядков, то лучше так и делать.
event1
23.07.2024 11:48Как-то ядро линукса справляется. Да, оптимизация действительно меняет стек, относительно написанного, но цепочка публичных вызовов видна и это лучше, чем ничего.
askharitonov
23.07.2024 11:48А если среди целей создания Go было создать такой язык программирования, код на котором легче использовать для машинного обучения? Наличие в языке исключений означает, что код иногда нужно просматривать сильно вперёд (до блока обработки исключений) для того, чтобы понимать, что он делает. Но, если обработка ошибки происходит примерно там, где она может случиться, то может на таком коде проще обучать нейросети?
TimsTims
23.07.2024 11:48А если среди целей создания Go было создать такой язык программирования, код на котором легче использовать для машинного обучения?
Вы так говорите, будто создатели Go жили 2000лет назад , и истинные цели его создания теперь окутаны
библиейтайной, никто точно не знает почему так было сделано, ведь спросить не у кого.
Free_ze
23.07.2024 11:48+10Например, ключевое слово try перед вызовом функции
1 в 1 путь Rust с его макросом
try!
. Тогда сразу стоит сразу внедрять оператор?
, чтобы избежать конфликтов имен и избавиться от ада скобочек:
try(try(try(foo()).bar()).baz())
->foo()?.bar()?.baz()?
ЗЫ дизайн-доки для закрытой дискуссии нагляднее.
qiper
23.07.2024 11:48+5if err != nil {
return fmt.Errorf("не смогли прочитать из базы: %w", err)
}
Или хотя бы позволяли писать как-то так:
if err != nil return fmt.Errorf("не смогли прочитать из базы: %w", err)
olivera507224
23.07.2024 11:48В одну строчку? Или принципиально без фигурных скобок?
UranusExplorer
23.07.2024 11:48+1Да. Так гораздо лучше выглядит, и главное логичнее: одна строка - одна операция (обработку ошибки за отдельную операцию естественно не считаем)
olivera507224
23.07.2024 11:48+1Я сам такой же и обычно так и отвечаю, поэтому поясню вопрос :)
Наличие в вопросе союза ИЛИ предполагает, что ответ будет включать в себя один из предложенных вариантов, чтобы вопрошающий мог понять с чем согласен или не согласен отвечающий.
jonic
23.07.2024 11:48+1Мне кажется что как бы функция прочитать из базы и должна говорить что именно пошло не так, не?
Почему нельзя сделать контекст вызова, который передавать внутрь функций и его же получать обратно? Если контекст стал в ошибку все следующие функции прокатываются впустую, наша функция где мы это делаем возвращает в итоге контекст с конкретной ошибкой. Как паразитное явление в контекст можно еще и результат записывать и тут же предавать его дальше по цепочке. А еще мокать контекст можно. или сериализовывать. Блин я похоже придумал как мне может понравится го.
ponikrf
23.07.2024 11:48+1Тоже гемор какой то. Вобще я согласен отчасти с автором статьи, но отчасти нет. Тут 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
olivera507224
23.07.2024 11:48Будут ли вызваны две нижние функции в случае, если ошибка на этапе выполнения верхней?
jonic
23.07.2024 11:48Будут, поэтому внутри каждой функции должно быть вначале что то типа if(ctx.isFail()) { return ctx; }
Я так понимаю можно это сделать макросом или препроцесосром, я не пишу на го, поэтому это просто полет фантазии.
olivera507224
23.07.2024 11:48+1Если бы можно было, то уже давно пресловутую конструкцию if err != nil сделали бы макросом или препроцессором.
Будут, поэтому внутри каждой функции должно быть вначале что то типа if(ctx.isFail()) { return ctx; }
И тогда мы получаем ту же самую конструкцию if err != nil, только заходим немного с другого конца :) Право на жизнь ваш концепт, несомненно, имеет, особенно в мире, где есть реализация обработки ошибок в Го. Но как по мне, всё же лучше прервать цепочку вызовов там, где она больше не может продолжаться, чем продолжить её выполнение в усечённом виде.
jonic
23.07.2024 11:48Ну она как бы прервалась, просто оверхед на вызов пустых функций. Но да, тут есть над чем подумать. Под макросом я понимал дефайн как в C, прячущий это в единую конструкцию похожую на вызов метода. Не уверен что это есть правда. Но в целом я уже целый день думаю об этом, зачем-то))
olivera507224
23.07.2024 11:48Под макросом я понимал дефайн как в C
А, понял. Не, Го не умеет в метапрограммирование, максимум на что он способен - это кодогенерация.
Но в целом я уже целый день думаю об этом, зачем-то
Потому что это интересно :)
jonic
23.07.2024 11:48Но дженерики то есть? Я вроде гуглил и были?
olivera507224
23.07.2024 11:48Дженерики есть, хоть и кастрированные. А чем они в данном контексте могут помочь?
aamonster
23.07.2024 11:48"Выполнение в усечённом виде" порой заметно упрощает код (если у вас clean-up не в деструкторах или что там вам язык предоставляет для его автоматизации и вы не можете на любую ошибку делать return).
И теоретически убирание такого усечённого выполнения – довольно понятная задачка для оптимизирующего компилятора.
olivera507224
23.07.2024 11:48порой
aamonster
23.07.2024 11:48+3Именно :-)
Очень наглядный пример – язык Objective C, с его "отправкой сообщения" вместо привычного "вызова метода". Наиболее заметное отличие – что можно отправить сообщение нулевому объекту, и он ничего не сделает. Порой это упрощает код (не надо делать 100500 проверок, что полученные нами объекты существует – к примеру, передали нам нулевое view, добываем из него нулевое же window и что-то с ним "делаем"), но порой изрядно затрудняет поиск ошибок (если такая логика не планировалась специально – лучше б упасть на первом же нулевом указателе), в результате в ObjC появились атрибуты __nullable и __nonnull. А в более поздних языках такие вещи вообще делаются явно, вызовом myObject?.myMethod(), что практически не загромождает код, но позволяет видеть, что ты делаешь.
NeoCode
23.07.2024 11:48+2Кажется, и в Rust и в Swift и в Zig применяется "легковесная" схема обработки ошибок с optional и either и оператором, который возвращает управление из вызывающей функции, если вызываемая функция вернула ошибку (префиксный try или постфиксный "?"). Данная схема выглядит весьма привлекательно и кажется, ее вполне можно имплементировать в Go. Но для этого в язык совершенно точно нужно протащить опционалы и Either. И интересно, как это можно совместить с принятой в Go схемой возврата двух значений - смыслового и кода ошибки, так чтобы старые функции без переписывания заработали по новой схеме?
domix32
23.07.2024 11:48+3Опционалы в Go фактически существуют. Проблема именно с обработкой - нет удобного способа их обработки. В Rust есть варианты, что можно сделать с ошибкой - лифтануть ошибку выше при помощи элвиса (
try_do()?
), запаниковать на месте (try_do().expect("can't do")
), конвертнуть (.map_err()
), либо вообще бесстрашно развернуть в значение (всякиеunwrap()
,unwrap_or(x)
,unwrap_or_default()
или просто.or()
), а при помощи сторонних либ (anyhow , thiserror и пр.) можно ещё и контекст ошибки явно указать и делать удобную конвертацию одних ошибок в другие, в том числе и лениво.В Zig тоже есть варианты вроде того же элвиса или
orfail
как в статье или ordefault (по аналогии с растом . При всей похожести кода zig и go наличие лаконичной обработки ошибок в zig делает его заметно приятнее.NeoCode
23.07.2024 11:48Опционалы в Go фактически существуют.
В явном виде на уровне языка все-же нет.
vadimr
23.07.2024 11:48Решение с опционалами тоже уязвимо к критике, так как, вообще говоря, ошибка при выполнении функции не обязательно означает, что у функции нет значения.
Например, если мы получили битый пакет данных, это ж не значит, что у него нет содержимого. Его ещё можно попытаться частично восстановить, несмотря на ошибку ввода-вывода. Или антипереполнение в математических операциях.
koplenov
23.07.2024 11:48А может просто вызывать метод или, если ошибка, исполнить другой код?
Например, как это сделано в V :D
https://docs.vlang.io/type-declarations.html#optionresult-types-and-error-handling
Пример из статьи выглядел бы так:
// вызываем функцию, а если ошибка - паникуем data := read_from_db() or { panic(err) } // ещё вызываем функцию, но необрабатываем ошибку если она есть - ! processed_data := process(data)! // вызываем функцию, а если ошибка, то обрабатываем её в этом блоке - например, вызываем функцию do_something с передачей ошибки в качестве аргумента write_result(processed_data) or { do_something(err) }
и никаких вам
if (err != nil)
:>koplenov
23.07.2024 11:48Конструкция
or
Может обработать ошибку и вернуть значение по умолчанию:
fn do_something(s string) !string { if s == 'foo' { return 'foo' } return error('invalid string') } a := do_something('foo') or { 'default' } // a будет 'foo' b := do_something('bar') or { 'default' } // b будет 'default' println(a) println(b)
Может раньше прервать выполнение:
user := repo.find_user_by_id(7) or { return }
Может исполнить какой-то другой код:
user := repo.find_user_by_id(7) or { log("not found", err) return }
и просто, и гибко :D
а это ещё не зашла речь про panic, recover и defer..
olivera507224
23.07.2024 11:48+2Подозреваю, что этому может препятствовать целый ряд факторов:
Функция не обязана возвращать ошибку.
Функция не обязана возвращать результат и вторым значением ошибку. Ошибка может быть на любом месте возвращаемых значений.
Функция не обязана возвращать только одну ошибку. Их может быть и больше.
Если функция возвращает ошибку, это вовсе не говорит о том, что эта ошибка "произошла" при выполнении функции. Возможно, данная функция - это всего лишь фабрика ошибок. Об этом может знать только сам разработчик.
koplenov
23.07.2024 11:48Функция не обязана возвращать ошибку.
Функция не обязана возвращать результат и вторым значением ошибку. Ошибка может быть на любом месте возвращаемых значений.
Хорошо, можно же просто пометить функцию, как не возвращающую ошибку, верно?
fn strong_function() string { return "some" }
Функция не обязана возвращать только одну ошибку. Их может быть и больше.
Go way нам сказал, что это ошибка - это просто просто значение
А значит, мы можем просто вернуть с одной функции несколько значений, верно?fn more_errors_function() (string, int) { return "i am eror", 42 } err1, err2 := more_error_func() println(err1) println(err2)
Если функция возвращает ошибку, это вовсе не говорит о том, что эта ошибка "произошла" при выполнении функции. Возможно, данная функция - это всего лишь фабрика ошибок. Об этом может знать только сам разработчик.
А значит код в блоке
or {}
- мы определяем сами, верно? :D
makarychev_13
23.07.2024 11:48user, err := repo.GetUserByID(id) if errors.Is(err, pgx.ErrNoRows) { return // } if err != nil { return // }
Такой подход плохо работает для такого случая
bromzh
23.07.2024 11:48+3А ведь решение простое - купить педали для игр, и забиндить одну из них на вставку сниппета
if err != nil
. Go-way?domix32
23.07.2024 11:48+1Так проблема в первую очередь не писать, а читать. А во вторую - конвертация одних ошибок в другие.
bromzh
23.07.2024 11:48+1На вторую педаль назначаем фолдинг этих строк. Или окрашиваем их в некотрнастные цвета, чтобы были на втором плане.
Ну не язык же менять, в самом деле.
Megadeth77
23.07.2024 11:48А можно просто для информации - почему туда нормальные исключения не завезут? Которые по сути и есть избавление от этого бойлерплейта?
event1
23.07.2024 11:48+2Нормальные — это как в питоне или как в джаве? Если как в питоне, то у функций появляется большая, важная, но неявная часть интерфейса. Если как в джаве, то везде надо декларировать миллионы потенциальных исключений. В силу этих соображений разработчики Go приняли смелое решение избавиться от исключений вовсе. Судя по всему, получилось тоже плохо.
Megadeth77
23.07.2024 11:48+1Понятно, спасибо. Кажется если уж упарываться по явности, то даже checked/unchecked exceptions лучше этой пляски с err!=nil , по крайней мере happy path не забивается обработкой ошибок. А так непонятно, зачем эта явность нужна. В питоне, несмотря на дзен, таки забили на декларацию исключений, и правильно сделали. Потому что дзен это прекрасно, но с инструментом должно быть удобно работать в первую очередь.
asatost
23.07.2024 11:48+1Если честно, не очень понятно, почему Вы хотите, чтобы Вам завезли реализацию именно на уровне языка. Особенно первой проблемы.
Если конструкция
if err != nil { ... }
постоянна, то разве реализация на уровне редактора кода не проще?Будет у Вас что-нибудь типа:
прочитали из базы() #err
тычком на #err развернётся обработчик ошибок. Ну и пару кнопок/хоткеев: раскрыть/свернуть все обработчики ошибок, вставить блок обработки ошибок.И итоговый код типа такого:
прочитали из базы() #err обработали() #err записали результат() #err
1ee
23.07.2024 11:48Если честно, мне проще читать второй вариант с ошибками. Он однозначный, самодостаточный, легко тестируется, не надо изучать тело вызываемых функций.
А если еще и функции закрыты интерфейсом и у него может быть несколько реализаций, тогда первый вариант кажется очень не надежным. Первое желание - засунуть его в горутину, поставить обработчик паники и сделать issue чтоб автор переоформил код.
xxxDef
23.07.2024 11:48+2С ужосом вспоминаю годы програмитрования на c++ для COM. Все эти if (FAILED(hr)) return hr; после каждого вызова функции.
Помнится посмотрел в сторону go с его декларируемой "простотой", увидел то же самое, ужаснулся и больше никогда.
Множество языков было загублено желанием авторов следовать каким то академическим догмам, превращая в итоге программистов в операторов бойлерплейта. С++, java - это то что приходит на ум сразу, а так тыщщи их.
Dair_Targ
Этот ваш "try, запоминающий цепочку вызовов" давно придумали, монады называются.
Вообще вариантов, как сделать
придумали примерно два: либо через исключения, либо через монады. Они, конечно, стектрейс сами-по-себе не запоминают, но всегда можно сделать специальный вариант
Either
, у которогоLeft
будет хранить хоть стектрейс, хоть вообще весь контекст.GospodinKolhoznik
Ещё можно через алгебраические эффекты.