Comments 184
Result, это такой же рак, как и возврат ошибок к golang. К тому же, при использовании "паттерна" Result, мы все равно должны использовать try catch на случай непредвиденных ошибок и обмазывать весь код гошным if err = nill.
И ещё, можно не обрабатывать ошибки в методах контроллера, а ловить их прямо в Middleware. Тогда, кроме вызова метода и возврата результата, там не будет вообще ничего.
В статье я написал, что try-catch используется, но только там, где это действительно необходимо.
Конечно, middleware на случай непредвиденных ошибок есть. Но всё, что можно предвидеть, уходит в Result. Проблема middleware в том, что он одинаково ловит и "wrong id", которое нужно показать пользователю, и null reference со stack trace, которое не нужно показывать пользователю. В моём коде туда попадает только то, что не должно попадать на глаза пользователю для логирования, отправки уведомления об ошибке и показа пользователю заглушки.
~~
Result, это такой же рак, как и возврат ошибок к golang
зависит от языка, в раст это весьма элегантно
Это потому что там озаботились операторами помогающими с этими типами работать, в отличии от golang, а в шарпах при наличии нормальных исключений такой паттерн становится вредным советом.
В C# можно переиспользовать старый linq синтаксис для этого благодаря утиной типизации
Имхо, это сносно работает только для несложных игрушечных примеров. При первых же сложностях вроде async
/await
читаемость сильно падает.
Если честно - не вижу в примере с таской (речь про public Task<Result> ProvisionUser) проблем с читаемостью, это буквально те-же for-comprehensions из Scala или do-notation из Хаскеля
Computation Expressions из F# - та же идея
мы все равно должны использовать try catch на случай непредвиденных ошибок
Платформа уже имеет хорошие дефолты на случай непредвиденных ошибок: 500 код и залогировать в ILogger.
и обмазывать весь код гошным if err = nill
Не обязательно, есть иные подходы. Посмотрите на ютубе "Scott Wlaschin Railway oriented programming". Такое вполне реализуемо и в C#. Некоторые библиотеки предоставляющие result pattern уже имеют необходимые расширения из коробки.
Проблема исключений в .net в том, что они не декларируются как в java. Вызывая тот или иной метод ты не можешь быть уверен какие исключения могут упасть. Да, иногда бывает документация, но это не гарантия. В случае result pattern ты знаешь какие ошибки могут вернуться. Более того ты обязан обработать каждый кейс при матчинге.
И ещё, можно не обрабатывать ошибки в методах контроллера, а ловить их прямо в Middleware.
И в итоге получаете магическую коробку, которая непонятно как работает и какой возможен результат, при этом она ломается, если вы её запускаете не в окружении "Подставить под каждую магическую коробку костыль"
Мб я не понял статью но вот и согласится хочется что это онкология все какая-то...
У клександреску читал про похожие подходы и что делаю я....
Методы ничего не возвращают кроме эксепшн, никаких бросков нет, принимают ссылки возвращают нулл и тп...
Да, все превращается в проверки
Но нет нигде никаких подстеков с тру...
Канеша тоже изврат но блин вообще то бросить можно что угодно, даже резалт. Но как ответить на вопрос зачем так делать...
Result, это такой же рак, как и возврат ошибок к golang.
Плюс много. Сколько раз пытался на этот паттерн перейти и всегда одно и то же: в простых случаях выглядит ещё нормально, а вот когда у тебя в методе вызов пары других методов, возвращающих Result, да ещё ошибки в этих Result'ах типизированные (чего у автора поста нет), вот тогда начинается геморрой...
throw == goto
.
И что? break в цикле это тоже goto, но если знаешь, что дальнейшие итерации не имеют смысла, то зачем вообще их делать? С исключениями точно также - если знаешь, что дальнейшая обработка запроса не имеет смысла, то зачем ее делать, зачем всем по цепочке вызовов проверять битый результат, когда можно сразу прыгнуть в самый конец?
Далее, ваш пример:
// Было
[HttpGet]
public IActionResult Get(int id)
{
try
{
var data = service.Get(id); // тот самый Get() из примера выше
return Ok(data); // возвращает SomeModel
}
catch (Exception e)
{
logger.Log(e, ...);
return BadRequest(e.Message); // возвращает {"Message": "error_message"}
}
}
// Стало
[HttpGet]
public IActionResult Get(int id)
{
var result = service.Get(id);
if (result.IsSuccessful)
return Ok(result); // или return Ok(result.Data);
if (result.Exception.Type == ResultExceptionType.NotFound)
return NotFound();
else
return BadRequest(result); // или return BadRequest(result.Exception)
}
Зачем????? Зачем вы делаете обработку ошибок в КАЖДОМ API эндпойнте???? Сделайте единый обработчик ошибок, сделайте разные типы исключений под разные варианты бизнеслогики, типа ValidationExeption, NotFoundExeption и тд, и пусть этот единый обработчик ими занимается. А эндпойнты будут выглядеть так:
[HttpGet]
public IActionResult Get(int id)
{
return Ok(service.Get(id)); // тот самый Get() из примера выше
}
И лучше чтоб он был асинхронным.
Это просто примеры, чтобы не перегружать не важными словами и кучей ненужных для этого классов. Каждый волен делать так, как пожелает.
В простых API моно и бросать исключения. Но вложенность до вызова десятка методов один за другим и каждый может выбросить какое-то исключение, часть из которых логируется, часть игнорируется, часть выбрасывается дальше с новым текстом или даже новым типом, код превращается в ад. Читать его очень трудно, понять логику - ещё труднее. А потом пользователи говорят, что нажатие на одну кнопку возвращает осмысленный текст, а на другую похожую - заглушку. Потом оказывается, что одно и то же исключение в одном месте перехватывается и просто логируется где-то на полпути к контроллеру, а в другом - перебрасывается дальше.
Получается, что у каждого такого метода может быть несколько состояний: вернул результат, выбросил ValidationException, выбросил NotFoundException, выбросил FooBarException, и т.д. И каждый надо учесть. У Result зависит от подхода. Можно всегда Ok(result) возвращать, можно разбить на Ok и BadResult, можно сделать больше и вообще на клиенте обрабатывать.
Получается, что у каждого такого метода может быть несколько состояний: вернул результат, выбросил ValidationException, выбросил NotFoundException, выбросил FooBarException, и т.д. И каждый надо учесть.
Зачем? В подавляющем большинстве случаев вы вообще нигде try/catch не делаете, только в одном едином обработчике ошибок. Везде по коду вы только бросаете исключения.
Но вложенность до вызова десятка методов один за другим и каждый может выбросить какое-то исключение, часть из которых логируется, часть игнорируется, часть выбрасывается дальше с новым текстом или даже новым типом, код превращается в ад.
И вы предлагаете заменить один ад другим, зачем? Надо исправлять, и не портить. Если бросаем исключение - то логгируем, перед тем как бросить. Все исключения которые бросаем - правильного типа, а не просто один общий Exception. Перепроверить места где исключения ловятся и затем опять бросаются - в подавляющем большинстве случаев этого не надо, есть несколько редких кейсов, их оставить. В обработчике ошибок самого верхнего уровня, который ловит все исключения, логгировать обязательно со стектрейсом.
Все, проблемы решены, код чистый, всегда можно понять где что случилось. Я это говорю как человек десяток лет проработавший с несколькими большими легаси системами, где вложенность два-три десятка вызовов это обыденность, а классы встречаются размером за десять тысяч строк. Без эксепшенов там было-бы можно повеситься. С правильными эксепшенами код чище и багхантинг достаточно легкий.
у каждого такого метода может быть несколько состояний
Состояний ровно 3: норма, исключение и ошибка программы/ВМ. Далее процессинг для нормы и для исключений.
На плохом примере у вас вряд ли получится хоть кого-то убедить. Большинство согласных будут теми кто влюблен в другой язык, в котором это работает.
У вас в примерах
Бросается в глаза, что вы в было кидаете Exception, и лечите базовый, непонятный класс классом ResultException с enumом типов ошибок. А практика на самом деле такая, что можно было бы кидать какой-нибудь NotFoundException и проблема определения, какая же ошибка выпала пропадает сама собой
Бросается в глаза, что вы в было делаете try-catch в каждом эндпоинте и лечите его тем, что заменяете одну большую конструкцию другой конструкцией не меньше. А практика на самом деле такая, что ловить исключения можно в Middleware и сразу превращать в ProblemDetails ответ в зависимости от типа исключения. Одно место для типовых ошибок вместо сотен. Result в Middleware так не попадет
Бросается в глаза, что вы в Result используете Exception тип. Непонятно зачем - его бенефиты не используются - ни Data словарь, ни InnerException(пишется, но нигде не читается), ни стектрейс (он не записывается и не используется). Учитывая сообщение об ошибке "wrong id", у разработчика из логов не будет понятия, а в каком из 4 мест чего-то не нашлось. Я привык, что тестировщик или поддержка просто скидывают ошибку или id ошибки, и я в логах просто нахожу где она вылезла и сразу лезу в конкретное место, чтобы смотреть в чем дело. Иногда просто по стектрейсу все понятно и можно сказать в чем дело
Касательно вашего комментария
"Потом оказывается, что исключение было перехвачено" - проблема не исключения, а конвенций в проекте, так как ошибку в Result точно так же можно перехватить и превратить во что-то ещё. А все потому что одинаковые исключительные ситуации в разных местах могут означать разные вещи. Где-то not found - это ошибка, а где-то сигнал, что надо создать сущность.
"У такого метода может быть несколько состояний". У вас ResultException построен на enum - по мере роста проекта, счёт типов ошибок пойдет на десятки, а понимания того, что именно может прийти в конечном итоге - убавится. Вы вернётесь к той же проблеме с исключениями, только у вас будет уже 2 таких механизма.
Можно решить как OneOf решает, и каждый возможный тип ошибки добавлять в сам возвращаемый тип - так есть точное понимание того, что может прийти, но в коде тогда появятся возвращаемые типы типа
OneOf<SomeSpecificResultTypeEntity, NotFoundError, ValidationError<SomeSpecificError>, RateLimitedError>
и так далее - читать/писать такое тоже довольно тяжело, работал с таким. Ошибок меньше, но все ошибки туда не засунешь, а обрабатывать приходится везде.
а теперь представьте, что нужно генерировать OpenAPI, чтобы клиент понимал, какие коды возможны, а у вас все NotFound и пр. в едином обработчике.
Result тут как ни как хорош. Плюс рекомендация не использовать исключения для не исключительных ситуаций (NotFound, Conflict в конкурентных системах уже становятся далеко не исключительными), скорей бы discriminated unions завезли (или как они там сейчас называются)
а теперь представьте, что нужно генерировать OpenAPI, чтобы клиент понимал, какие коды возможны, а у вас все NotFound и пр. в едином обработчике.
А в чём сложность? Если у вас minimal api, возвращайте из функции Results<>
[1], например Results<Ok<Foo>, NotFound, Conflict>
. Если MVC, навесьте ProducesResponseType
[2].
сложность не в задани перечня возвращаемых значений в каждом endpoint-е, а в контроле соотвествия, что всё, что в едином обработчике возвращается, перечислено в каждом endpoint-е (при этом скорей всего там будет с десяток возможных вариантов, но каждый endpoint может вернуть только подмножество этих кодов)
Вот я со всем с вами соглаен, кроме
Сделайте единый обработчик ошибок
Буквально в прошлом проекте был такой, и показал себя ужасно.
Во первых такой класс, даже если он чисто управление передаёт - очень быстро растёт. Во вторых, он так-же быстро обрастает доп условиями - когда требуется контекст. Например пришел nullpointer - надо ресурсы освободит, значит надо как-то ошибку вернуть в точку вызова, или когда нужно добавить дополнительные данные для аналитики, но варианты этих данных разные. Или когда нужно логировать, но не все. Или когда разное количество retry надо сделать.
Вы вероятно скажете, что надо заводить отдельные ошибки под каждые данные - но это придет после, когда у вас уже заложена логика с развилками в зависимости от контекста, и даже если нет - приведёт к еще большому разрастанию этого большого единого обработчика. И не дай бог у вас будет цепочка наследования ошибок типо MyError : Retryable : NetworkError ... - потом когда поведение одного из обработчиков надо будет специализировать - все вообще развалится.
А ещё это усложняет вход в проект - намного менее очевидно - откуда пришла или куда пропала ошибка. Да - в теории в стак-трейсе все должно быть, но на практике были проблемы.
Короче на долгоживущих проектах я бы не рекомендовал такой подход.
Лучше либо локальные перехватчики для небольшого контекста (типо экрана) - там ошибку поймал - состояние экрана в нужное привёл, ретраи в нужном порядке и тайминге сделал и молодец. А если нужно, через композицию подключил какой-то из общих обработчиков, и параметризовал.
Либо result использовать там, где ошибка внутри нашей бл. Он не так красив но намного более очевиден.
Например пришел nullpointer - надо ресурсы освободит, значит надо как-то ошибку вернуть в точку вызова
Под точкой вызова вы что понимаете в этом примере? Какие ресурсы освобождаем? Если пришел nullpointer - вы логируете nullpointer, а в http ответ пишите код 500.
Ну предположим ситуация такая - мы открыли ресурс (например соединение с базой данных), а потом сделали запрос на сервер, пришел null в критически важном параметре (может он там по БЛ может быть null, может просто ошибка, не важно) - в случае чистого try - catch - ты заворачиваешь закрытие содениня с базой в finaly и знаешь - что не важно - ошибка там или успешное выполенние - соединение с базой не будет подвешено. А тут - случился nullpointer - но exception перехватился в едином обработчике исключений, а он вне контекста, который открыл соединение с базой (в нём нет ссылки на класс, который открыл содединение с базой). Это значит либо обработчик ошибок должен знать про класс, который инициировал запрос - а тогда он обо всём на свете будет знать - каша и связность, либо все должны ему свои обратные вызовы передавать для дополнительных действий при получении ошибки - а тогда это ничем не лучше, чем try catch по месту вызова запроса. И чтобы это работало - калбек должен прицепляться именно к запросу т.к. на разные запросы, даже в рамках одного контекста, могжет быть разная обработка одних и тех-же exception.
в C# (в других я языках должно быт похожее) блок using гарантирует освобождение ресурсов даже при возникновении исключения внутри блока.
В общем случае общий обработчик ошибок не отменяет try catch finally. Не нужно туда логику финализации перемещать. Есть места где нужно try finally а есть где не нужн. В вашем примере вам нужно обернуть обращение к бд в try / finally , в finally освободить ресурсы, далее вы все равно не сможете идти по логике программы т.к. у вас нет данных, поэтому вам нужно заново выбросить то же самое исключение, чтобы оно в итоге обработалось в едином обработчике ошибок
Логика может быть сложнее, чем освободить ресурсы - например в зависимости от ошибки - нужно освободить не все ресурсы, а только определённую часть. И тогда в finlay придется получать исходное исключение - и вот уже обработка ошибки размазана на две части а очевидность потеряна.
Или тоже самое - нужно перестроить ui c учетом ошибки. Для этого viewModel должна вернуть в View состояние ошибки, но оно уже было перехвачено на уровне единого обработчика - а значит либо он должен во viewModel передать данный (читай иметь ссылку), либо в finlay проверяем наличие exception на уровне viewModel - но у нас опять - половина ошибки в едином контроллере, а половина в viewModel слое. А если нам еще параметры передать из viewModel в логгер для ошибки надо например - вообще пиши пропало.
У нас на dotnet try-finally обработчиков много локальных, в том числе синтаксический сахар с using, а try-catch чаще всего один глобальный где и происходит логирование и формирование кода ошибки. Но есть кое где retry и другие специфические места, где нужна локальная обработка, отличная от try finally.
Ну я так понял из коммента, что у вас скорее локальные перехватчики - вида try finaly - где finaly в том числе проверяет и обрабатывает exception. Это не про глобальную перехватку как раз, а в целом хороший подход.
Единый обработчик ошибок который я имел ввиду, это когда в условном singletone лежит switch(error) case на сотню вариантов, где на каждый вариант вызывается некоторый обработчик конкретной ошибки.
try-finally не ловит и не обрабатывает исключения (в нём нет блока catch).
Не знаю как в c#, но в java в finaly можно получить исходное искоючение и что-то сделать (не перехватить но обработать).
Если у вас не так - то я не понимаю как try finaly решает проблему "нужно вывести ошибку в том ui компоненте, откуда пользователь кнопку нажал, а catch у нас где угодно но не там"
в java в finaly можно получить исходное искоючение и что-то сделать (не перехватить но обработать).
А можно пример этого чуда?
В C# не так. Чтобы получить исключение, нужно его перехватить. Перехватывать исключения, не перехватывая их (без catch
), нельзя.
я не понимаю как try finaly решает проблему "нужно вывести ошибку в том ui компоненте, откуда пользователь кнопку нажал, а catch у нас где угодно но не там"
Это try-catch:
private void button1_Click(object sender, EventArgs e)
{
try {
throw new InvalidOperationException("The answer to the ultimate question was not 42");
}
catch (Exception ex) {
// Вывести ошибку в том ui компоненте, откуда пользователь кнопку нажал
button1.Text = $"The exception was: {ex.Message}";
// Пробрасываем выше по стеку в "где угодно"
throw;
}
}
Как вам уже успели ответить, никто вам не запрещает обрабатывать исключения не глобально. Это база работы с исключениями: можешь обработать - обработай, не можешь - не обрабатывай.
goto не любят, потому что от него можно прыгнуть куда угодно по логике. И ситуация сильно ухудшается, если эти goto расставлены в нескольких местах и становятся перекрестными. Сравнивать throw с goto некорректно, так как throw явно ведёт либо вниз по коду, либо на какой-то уровень выше, то есть throw в этом смысле более линеен и предсказуем. Собственно, поэтому он и сделан: если есть большая вложенность и надо резко выпрыгнуть, минуя какую-то логику, которую, допустим, писали не вы. Да, его не нужно использовать часто, это обработчик специфических ситуаций и я тоже предпочитаю чаще использовать return, но это не всегда оправдано.
Вообще, я напомню, throw вводился для отделения логики работы от логики обработки ошибок. Т.е. в теории с ним более чистый код.
Дейкстра критиковал "goto" за то, что этот оператор позволяет создавать произвольные переходы в коде, включая переходы между функциями (т.е. игнорируя стандартный вход по имени и выход по return) или внутри программы, что нарушает структурированность и предсказуемость программы. И не более того. А дальше уже мифы и "не читал, но осуждаю"...
Ирония в том, что в C/C++ аналогом "goto" Дейкстры является не сам "goto", а "longjmp", аналогично которому реализованы исключения...
Да, все верно, когда я писал комментарий выше - я учитывал, что goto сейчас во многих языках можно ставить достаточно ограниченно. Но это по-прежнему путает логику, особенно в комбинации с if. И я ссылался не на Дейкстру, а на собственный опыт писания, например, на ассемблере. Достаточно быстро понимаешь, зачем логика делится на блоки и почему почти всегда goto можно и нужно менять на вызов функции, в которую выносится участок кода.
goto и вызов функции принципиально разные вещи.
Если представить ход выполнения как некую "тропинку", то вызов функции - уход с этой тропинки в сторону с обязательным возвратом в ту же точку откуда ушли.
goto же - это как прыжок в другую часть тропинки. Причем, не обязательно вперед. Может быть и назад. Тут вся линейность выполнения меняется.
Хорошая аналогия с тропинками, и как раз функцией можно заменить прыжок назад. Я не помню, читал это или пришел сам, но суть такая:
У нас блок кода, мы можем прыгнуть назад на него через goto. А можем вынести этот блок в функцию и вызвать её несколько раз. Пример без смысла, но с пояснением идеи
(если что, это псевдоязык у нас, допустим, пародия на javascript)
label1:
a = b + c
c = a + b
if( c < 3 ) {
goto label1
}
Тут получается блок, который можно представить через функцию или через while или всем одновременно.
function do_sum( a, b, c ) {
a = b + c
c = a + b
return [a, b, c]
}
do {
a, b, c = do_sum( a, b, c )
} while ( c < 3 )
С пересекающимися блоками бывают сложности, но обычно лучше посидеть и подумать, как их сделать непересекающимися, а потом распилить на циклы и функции.
Нет, декларативная обработка ошибок через монады не серебряная пуля. На практике код с Result/Either/Optional в сложной логике тоже превращается в кашу.
Result success/fail, и его дальнейшее развитие в виде functional extensions хороши в том случае, когда мы делаем цепочку логики, из которой можем вывалиться с ошибкой на любом шаге, так называемое railway-oriented программирование. Про него в C# очень хорошо Владимир Хориков рассказывал. А исключения на то и исключения, что ими не следует делать второй контур логики, вводя кучу пользовательских и обрабатывая их то тут, то там, что приводит к разрушению связности кода и появлению неявных зависимостей. В основном такая претензия к исключениям, когда их не по назначению используют.
Исключения используют крайне избыточно. Лично у меня есть правило, которое в грубом виде звучит так: обработчик исключения всегда должен заканчиваться на sys:exit(). Если событие не повод останавливать процесс или хотя бы поток, то это и не повод использовать исключение.
А некоторые популярные библиотеки наоборот считают исключения решением всего. Функция имеет 3 режима работы? Добавим ей аргумент int mode и исключение, если mode > 2. С точки зрения архитектуры, это орки писали.
Невалидные состояния системы должны не иметь валидного представления в коде. Это классическая школа проектирования, и в ней не нужны исключения. Если открываешь файл, а он не открывается, то поступать можно так: ввести два типа: ОткрытыйФайл и НеоткрывшийсяФайл. И функция открытия файла возвращает одно из двух. Потом результат надо различить по типу. И все функции работы с файлом принимают только ОткрытыйФайл. Всё. Ошибится - невозможно, компилятор не даст. Поток выполнения не нарушается. Компилятору легко оптимизировать.
Функция имеет 3 режима работы? Добавим ей аргумент int mode и исключение, если mode > 2.
А чем это отличается от assert?
Или, судя по остальной части комментария, надо чтобы возможности неограниченной int переменной не было вовсе, а только Enum, для которого каждое состояние строго определено?
А чем это отличается от assert?
Ничем. assert-ы это хорошо. Особенно когда их много, а в релизной сборке их можно отключить и это не инвалидирует всё тестирование, проведённое в дебаге.
Преднамеренно необработанные исключения это тоже бывает норм, там sys:exit() сам вызывается.
надо чтобы возможности неограниченной int переменной не было вовсе
Именно так. И главное - с результатом вызова то же самое. Из функции не должно вылетать то, что не написано в её заголовке. Например, исключения.
Добавим ей аргумент int mode
Используйте енум, и будет вам счастье, (если это не случай си-подобного языка типа джс)
С точки зрения архитектуры, это орки писали.
Вы- наверное OpenGL никогда не пытались вызывать
два типа: ОткрытыйФайл и НеоткрывшийсяФайл
Нил, как неоткрытый файл тоже признается в современном мире злом.
Аргумент больше двух когда есть три режима работы это синтаксическая ошибка, которую не смог поймать компилятор. Если уж не смогли написать так, чтобы поймал, то остается только падать мне кажется.
Если могли бы, но не стали морочиться - то конечно зря.
А в реальности обычно всё не так очевидно. введут три-четыре параметра, которые взаимно ограничивают друг-друга, запрещая определённые комбинации. А у тебя часть этих параметров идёт напрямую из пользовательского выбора. Короче, пока не вызовешь функцию и получишь исключение - фиг ты сможешь узнать, что хотелки пользователя невозможны и ему нужно сказать выбрать другое.
Не люблю компоненты, у авторов которых Си головного мозга.
Так делать то что? вот в тебя влетела невозможная комбинация. Ну и пошли исключение вызывающему. Хочет жить без исключений - пусть обкладывает вызов условием на совместимость входных параметров и заранее говорит пользователю, что он не то навыбирал. Не хочет - пусть ловит исключения. Все равно дальнейшая жизнь невозможна.
Я о том и говорю, что исключения должны быть только там, где программа даже не должна работать дальше. Если в API вместо положительного числа пришло отрицательное, то это просто ошибка валидации и мы возвращаем BadResult(ModalState). Если же кидать исключение на каждую проверку вместо того, чтобы вернуть сообщение об ошибке и далее тот же BadResult с этим текстом, то ничего хорошего из этого не выйдет.
И функция открытия файла возвращает одно из двух
Это невозможно в языках с нормальной типизацией
А if без {} в вашем языке не дурной тон?
Общего консенсуса нет, да и многие фичи добавляли в C#, по ощущениям, чтобы угодить сразу всем и через синтаксический (и не только) сахар переманить на язык как можно больше разработчиков.
Нет, просто мне лично нравится, когда в один экран влазит больше кода, а пустые строки только там, где я их оставил.
В вашем языке goto может прыгнуть куда угодно?
В моем возможны только в том же контексте. То есть внутрь функции или цикла не прыгнешь.
Без поддержки со стороны языка и фреймворка подход с Result
превращается в гору бойлерплейта и не дает никаких гарантий, т.к. методы BCL все равно кидают исключения. А еще может быть ситуация, когда у вас много реализаций Result
, например из двух разных NuGet-библиотек и еще ваша собственная, и они не совместимы между собой...
Так пиши(те) на Си. И делайте везде проверки вручную, если нравится. И компилируй(те) с флагами -noexceptions -nortti. Только без гоуту.
А ещё лучше на ассемблере (только чур без goto):)).
Конечно, тогда ещё, возможно, не знали о возможности возврата ошибки вместо её выбрасывания
Я вам, возможно, открою глаза, но исключения придумали ровно для того, чтобы не заморачиваться постояннными проверками в Си-стиле. И это было ПОСЛЕ си-подхода.
Но почему-то я не встречал никакого негатива насчёт throw. А ведь это точно такая же фигня, если даже не хуже.
Ну почему же? Я тут на днях почитывал дискуссии давно минувших лет, тогда были еще совершенно другие идеи по поводу того, куда и как должно развиваться структурное структурированное программирование.
А раз появились исключения, значит какую-то проблему ими пытались в свое время решить? Как "старый метод" решения проблемы в языках, где можно вернуть только одно значение (без оборачивания в struct/object): те самые C API. Где, если вместо handle или чего-то там вернулось null/-1, то посмотри на GetLastError() и т.п. И оказалось, что статическая errno на всю программу - не очень-то thread safe и так далее.
И оказалось, что статическая errno на всю программу - не очень-то thread safe и так далее.
И быстренько решили эту проблему, сделав errno thread-local...
Не назвал бы быстреньким. В документацию по cpp посмотрел, говорится: многопоточные библиотеки следующие стандартам C11, C++11 должны сделать их thread-local. А это (вики) конец 2011 года. Неосведомлен, правда, что там было вне стандарта.
А в POSIX это когда появилось?
"Programs should obtain the definition of errno by the inclusion of <errno.h>. The practice of defining errno in a program as extern int errno is obsolescent. "
System Interfaces and Headers, Issue 5: Volume 1. 1997
стр. 189 pdf
issue 6. см. изменения
https://pubs.opengroup.org/onlinepubs/009696799/functions/errno.html
Если я правильно понял (опережу: да), то задепрекейтили только в пятом издании.
issue 4, стр. 130, 1994
conformant systems may support the declaration:
extern int errno;
Еще высказывания Торвальдса по теме самого errno: http://yarchive.net/comp/linux/errno.html
Это про то, что errno - не обязательно настоящая переменная, а может быть и макрос.
Но в целом, сами по себе POSIX threads стандартизованы в 1995-м...
Торвальдс очень категоричен.
Из того, что errno стало макросом, следует, что его можно переопределить и сделать thread-local. Как иначе?
extern __thread int errno;
Ну или по-Go-шечному, сделать errno
таким волшебным словом, которое обозначает не просто переменнию, а тред-локальную. В конце концов, memcpy
примерно так и устроен в ряде компиляторов.
Видимо, решили не наступать дважды на те же грабли однозначного значения символа в стандарте?
Торвальдс категоричен, не отнять. Подумалось, может это такой вариант rage-bait'а двадцатилетней давности? Понятно, что это мягко говоря... кхм, "особенности" характера, но может она его и возвела в культовый статус?
С наводкой именно на pthreads теперь и докопался окончательно:
https://unix.org/version2/whatsnew/threadspaper.pdf
10.13 Redefinition of errno
.
In POSIX.1, errno is defined as an external global variable. But this definition is unacceptable in a multi-threaded environment, because its use can result in non-deterministic results. The problem is that two or more threads can encounter errors, all causing the same errno to be set. Under these circumstances, a thread might end up checking errno after it has already been updated by another thread.
.
To circumvent the resulting non-determinism, POSIX.1c redefines errno as a service that can access the per-thread error number as follows (ISO/IEC 9945-1: 1996 (POSIX-1), §2.4):
.
‘‘Some functions may provide the error number in a variable accessed through the symbol errno. The symbol errno is defined by including the header , as specified by the ISO C standard ... For each thread of a process, the value of errno shall not be affected by function calls or assignments to errno by other threads.’’
.
In addition, all POSIX.1c functions avoid using errno and, instead, return the error number directly as the function return value, with a return value of zero indicating that no error was detected. This strategy is, in fact, being followed on a POSIX-wide basis for all new functions.
Отдельно хочу обратить внимание на последний параграф. Я бы сказал, обычный просчет архитектуры API. Об ошибках этого рода как-то не принято вспоминать и говорить? Хотя очень помогло бы в виде пост-мортемов для разбора "полетов".
Отдельно хочу обратить внимание на последний параграф.
Возврат errno
по значению вносит сумятицу, когда невозможно выделить какое-то специальное невалидное значение
Особенно обидно, когда для всех функций API он подходит, а для парочки каких-то надо изобретать отдельный механизм
Ну и конечно, то, что в UNIX все функции при ошибке возвращают -1, а pthread_XXX - 0, не добавляет удобства.
В этом плане, конечно, Go-ный подход, возвращающий ошибку отдельным значением (или подход языков с алгебраическими типами, возвращающими результат или ошибку через maybe-тип) выглядит более удачным. Но если API на Си, тут, наверное, совсем уж хорошо не сделаешь...
Они и продолжают решать определённые проблемы, например, когда в DI не дописали интерфейс и у нас случился NullReferenceException в методе или ArgumentException в контроллере, если туда это дописали. Это ошибка программиста, которая решается один раз и там больше не должно быть исключения. А пользователь, соответственно, не должен видеть содержимого этого исключения.
Но если нам просто надо провалидировать данные и сообщить пользователю о неверных данных, это подобно использованию топора там, где нужен пинцет.
Я считаю, что исключение - это такое сообщение, текст которого конечный пользователь не должен видеть. Он только для внутреннего использования и в идеале исключение не должно повторяться, если его один раз поправили.
Поскольку статья заявлена как туториал, а не мнение, ставлю минус - слишком самоуверенный подход к поучению спецов у вас.
А где же вариант: "Ничего из вышеперечисленного"? )
Всё, что угодно, надо использовать только там, где оно действительно необходимо, а не везде где попало. Вы так можете написать целый цикл статей или даже книгу
- Используйте циклы только там, где они действительно необходимы
- Используйте протокол HTTP только там, где он действительно необходим
- Используйте микросервисы только там, где они действительно необходимы
и так далее
Проблема обычно не в том, чтобы такое общее утверждение выдать, а в том, чтобы понимать, когда оно действительно необходимо, а когда нет
PS. Ловить неспецифичный exception в контроллерах - тупейшее занятие. Для этого придумали миддлвары. В этом проблема кода "было", а не в том, что в программе внутри exception выбрасывались
Пока нет Result-а из коробки предпочитаю создавать явно типы как:
internal abstract record CreateResult
{
public sealed record Success(SomeModel Model) : CreateResult;
public sealed record NotFound : CreateResult;
public sealed record WrongOperation : CreateResult;
}
который используется в controller-е как:
CreateResult result = await _service.Create(...);
return result switch
{
CreateResult.Success data => TypedResults.Ok(data.Model),
CreateResult.NotFound => TypedResults.NotFound(),
CreateResult.WrongOperation => TypedResults.BadRequest(),
_ => throw new ArgumentOutOfRangeException(result.GetType().Name, "Unexpected result type")
};
Если объявление CreateResult
и использование в разных сборках, то дефолтный branch в switch нужен пока, к сожалению, но это отдельный вопрос
goto? Все его ненавидят,
Серьезно? А что кто-то с ним реально сталкивается сейчас, кроме программистов на ассемблере? Начало статьи выглядит так как будто написана 30 лет назад, когда goto в реальном коде ещё встречалось.
А Exception'ы эффективны именно при большой вложенности кода, особенно когда часть слоёв вообще не ваши.
Серьезно? А что кто-то с ним реально сталкивается сейчас, кроме программистов на ассемблере?
Разумеется. В рантайме того же нашего дотнета уйма goto.
Полагаю, поинт был не про библиотеки и их внутренности, а про код, который ты или твоя команда пишет. Лично я даже не знаю, какой синтаксис у goto и пользовался им еще на Си в универе.
Полагаю, поинт был не про библиотеки и их внутренности, а про код, который ты или твоя команда пишет.
А в чём принципиальная разница?
Они эффективны, пока ты не начинаешь читать такой код. Разные исключения обрабатываются по-разному и никогда не угадаешь, как очередное исключение будет обработано, пока не прочитаешь код всего стека вызовов методов от контроллера до твоего десятого по счёту метода.
goto тоже по-своему эффективен, пока не начинаешь читать такой код. (Вспомнилось, как последний раз я их использовал как блок, подобный finally. Это было лет 15 назад в Delphi.)
В ядре Linux они до сих пор как finally и используются: освободить ресурсы и на выход.
Разные исключения обрабатываются по-разному и никогда не угадаешь, как очередное исключение будет обработано
А может их просто не для того используют в конкретном проекте? Когда появляются множества обработчиков множества разных видов исключений - это тревожный звоночек, возможно что-то не так с архитектурой. Когда приходится залазить во внутренние слои чтобы понять как работают внешние - это уже громкий звонок. Что-то не так с принципами SOLID.
Вспомнилось, как последний раз я их использовал как блок, подобный finally. Это было лет 15 назад в Delphi.
В Delphi всегда был нормальный finally
Когда пишешь async await - внутри живёт код стейт машины с goto :)
давайте уже забудем о питоне
Мне кажется человек с такими утверждениями не может считаться хорошим программистом. Мало того, что утверждение про goto ложно и вытекает из некоторых нежелательных моментов при его использовании, но автор этого высказывания подчеркивал, что именно избыточное и необдуманное использование goto может привести к проблемам. Автору посоветую глянуть код BSD или Linux, для просвещения. Тем более после компиляции C кода Goto преобразуется в JMP, что более эффективно чем всë остальное, но вы про эффективность не слышали?
Теперь пройдемся про throw, оно придумано и рекомендовано к использованию в любых ситуациях, что прямо прописано в документации Java, и там же разобрано как с ним работать, думаю вы не осилили это, раз появилась очередная холиварная статья. Так же в документации и курсах от Microsoft так же четкая рекомендация использовать throw. Если они для вас не авторитеты, то даже не знаю как характеризовать эту статью не прибегая к грубым эпитетам.
Почему не любили throw раньше? На слабых компьютерах и ограниченном объеме в реализации этого на C++ получался огромный оверхед, который съедал относительно много памяти и процессорного времени. Отсюда пошли статейки, что лучше не использовать throw, а только в критических местах. В java и прочих интерпретаторах или JIT языках такой проблемы нет, а сейчас и с памятью и мощностью процессоров проблем нет, так что не надо выдумывать проблемы где их нет, а просто научитесь писать правильный код.
В java и прочих интерпретаторах или JIT
Статья по теме Hotspot JVM: https://shipilev.net/blog/2014/exceptional-performance/
Спасибо, очень интересная статья, но она о сферическом коне в вакууме и приводить еë как аргумент как минимум не уместно, т. к. в итоге мы видим, что на самом деле эффективность в пределах разумного, а писать критические приложения где сборщик мусора может в любую секунду обнулить все оптимизации, такое себе) Опять же хочу обратить внимание, что данный оппус был создан пользователем Хабр, что не является истиной в последней инстанции, а в свете погони авторов за рейтингами вообще критически отношусь ко всем публикациям на Хабр, к чему и призываю всех людей. А то потом эти необоснованные статьи попадают в индексацию и тем самым влияют на неокрепшие мозги других пользователей и они начинают следовать призывам и утверждениям в данных статьях. Ещë раз, лучше придерживаться рекомендациям от производителей и разработчиков языков, а там черным по белому написано "Используйте исключения, они эффективны для большинства ваших задач"
Да, только Алексей потом с Хабра и ушел (ЕМНИП из-за несогласия с политикой сайта) и вел сам себе блог на сайте. Но помимо этого он перформансом в OpenJDK долгое время и занимался: всё то, что под капотом.
А ссылку мне стоило подписать, что ничего там страшного и противоречащего нету. За это извиняюсь :)
а там черным по белому написано "Используйте исключения, они эффективны для большинства ваших задач"
У нас черным по белому написано так
Do not use throwing or catching exceptions as a means of normal program flow, если уже критически относится. При этом эти наши кейсы из бизнес логики ведущие к возвратам тех же NotFound - normal program flow
Замечательно и где противоречия с моими высказываниям? И на сколько NotFound это нормально? Давайте не будете врать ни мне ни себе. Это ошибка поведения программы, возврат значения которое не является успешным. 403 это ОШИБКА. Это уже не Normal Flow. И тут вопрос как обрабатывать лучше и удобнее. Ничто не мешает, в том числе и ваша ссылка на MS, использовать исключения и консолидированную обработку на уровнях ниже, можно даже прописать каждому исключению свой класс, что будет смотреться и обрабатываться удобнее. Но конечно можно промудрить по классике с Result. Сколько людей, столько и мнений?
Тут ситуация такая вырисовывается:
Представим: Брюки это Исключения, Килт это Result
Автор: "Если пойти в туалет и не снять Брюки, то может произойти казус, а вот если использовать Килт, то это минимизирует ущерб"
Комменты:" Да точно, вы правы Брюки надо расстегивать и да Килт это вроде круто! "
Автор: " Если расстегнуть ширинку на Брюках, но при этом стоять против ветра, то может произойти казус, а вот при использовании Килта такие проблемы минимизированы. "
Комменты: "О да, делать это против ветра это конечно не разумно, Килт выглядит очень удобно! "
Автор: " ну вот используйте Килт чаще чем Брюки! "
Комменты:"Стопэ, Килт конечно это круто, но мы привыкли использовать Брюки, и да мы не забываем их расстегивать и стараемся контролировать направление ветра, поэтому извини дружище, но мы уже в Брюках походим. "
Замечательно и где противоречия с моими высказываниям?
Как минимум считаю полезным читающим видеть как сформулировано официально у ms, в противовес вашему утверждению что рекомендуют «Используйте исключения, они эффективны для большинства ваших задач», а дальше каждый для себя решит, что ему ближе.
Это уже не Normal Flow
Да, тут возможно не на кого сослаться для определения, хотя в тех же проектах-примерах ms, как eshop, NotFound через исключения не кидают. Ну и в целом если представить, что у нас добавление товара в корзину возвращает NotFound когда товар закончился (что случается давольно часто), чем это не normal program flow
Автору посоветую глянуть код BSD или Linux, для просвещения.
А там разве goto не используется в одном конкретном паттерне для очистки ресурсов, чисто из-за ограничений C?
Почему не любили throw раньше?
У моего коллеги был легаси проект, где логику построили на исключениях. Говорит, очень не понравилось распутывать все цепочки вызовов, чтобы понять как и что работает.
Но если почитать заголовок и хейт на goto то создаётся впечатление, что goto это зло.
И опять же это не из-за C, а из-за процессора, Goto это jmp. Для адептов антиГото, можно было сделать вместо Goto очищающую функцию, в которую передавали бы параметры очистки и т. д. Но нет, используют не потому что ограничение C, а потому что вызов функции медленнее в разы, а тут раз и вышел если всë хорошо.
Пример интересный, но goto и throw это инструмент, а не модная штука. Просто надо руководствоваться здравым смыслом. Проекто с ужасными result которые без слез не раскрутишь тоже не мало.
Но нет, используют не потому что ограничение C, а потому что вызов функции медленнее в разы, а тут раз и вышел если всë хорошо.
Именно это и имел ввиду. Нет другого эффективного способа очистить ресурсы.
Просто надо руководствоваться здравым смыслом. Проекто с ужасными result которые без слез не раскрутишь тоже не мало.
Да всё плохо, везде сложно.
Исключения должны использоваться для исключительных ситуаций, а не для возврата значения, тогда с ними проблем не будет. Это означает, что их не нужно нигде ловить, кроме глобального обработчика. Когда конкретный тип исключения ловится неизвестно где выше по стеку, это и есть goto. Их можно ловить, чтобы откатить действие (например транзакцию в базе данных) - поймали, откатили, бросили дальше. Или когда это второстепенное действие, и ошибка не должна останавливать основное, например, отправка уведомлений после создания заказа.
Расскажите нам, что плохого в Goto, что бы дальнейшие ваши претензии были обоснованы. Просто такое ощущение, что у большинства людей поливающих грязью Goto нет понимания для чего и где он используется.
Во вторых если вы почитаете про исключения, наконец от разработчиков, то обнаружите, что поведение исключений не сильно отличается от простых return, просто заданных неявно. И если вы используете return, то вы попадаете на то, что вы вынуждены разворачивать всегда всю цепочку вызовов, даже если на верху что-то пошло не так и выполнять данный набор команд(функций и т. д.) не имеет смысла и нужно перейти к другим. Именно это делают исключения, убирая ненужные return для вложенности, а сразу переходя к коду обработчку(ловушке).
И да, просто возвращать положительный результат это не очень хорошая практика, но это нигде не запрещено явно и руководствуется только здравым смыслом, как и с оператором Goto, всë остальное это ваше ИМХО.
Расскажите нам, что плохого в Goto
Я же написал: "ловится неизвестно где". Это означает, что из вызываемого кода сложно найти место, куда возвращается управление, а в вызывающем сложно понять, что вообще может прилететь и что надо ловить. Особенно если там "иерархия исключений", и бросается одно исключение, а ловится его произвольный родительский класс.
поведение исключений не сильно отличается от простых return, просто заданных неявно
Да, я так и написал, "неявно" в данном случае это синоним "неизвестно где".
Во вторых если вы почитаете про исключения, наконец от разработчиков
Я сам разработчик, и у меня есть свое мнение об этом.
И если вы используете return, то вы попадаете на то, что вы вынуждены разворачивать всегда всю цепочку вызовов
Если вы не поняли, я не предлагаю использовать подход, который описан в статье. Я написал, как надо работать с исключениями, чтобы они не создвали проблем, про которые пишет автор.
Именно это делают исключения, убирая ненужные return для вложенности, а сразу переходя к коду обработчку
Я в курсе.
всë остальное это ваше ИМХО.
Любые правила для кода это ИМХО разработчиков. Вы можете писать код в одну строку с однобуквенными переменными, только ИМХО других разработчиков будет в том, что это хреновый код, который сложно поддерживать.
Мои придирки были обращены к оператору goto, который является всего лишь инструментом, но который необоснованно хейтят и приплетают ситуацию с ним в прошлом к другим проблемам, вызывая негатив и отторжение к данному инструменту. Изначально кампания была по ограничению и правильному использованию GOTO, которая переросла в довольно кривобокую картину, когда молодые разработчики уверены, что GOTO это плохо и боятся применять его или даже не знают о его возможностях. Это не то к чему надо стремиться и статья вызывает критику в том числе и кликбейтным заголовком и необоснованным принижением инструмента разработки.
Я с вами полностью согласен, что всë программирование это ИМХО, но при этом вы высказывкетесь в довольно категоричной форме:"Я написал как надо...", это читается, что всë остальное не надо?) Понимаю что это может быть оборот речи у вас, но всë же... Вы и выше утверждаете, что только глобальный обработчик это правильно. Но с вами несогласны именно разработчики языков, о которых я говорил, или вы участвуете в разработках языков Java и т. д. ? Думаю я непонятно выразился... Увлекаться глубиной fast-return согласен не всегда хорошо, но и прям пренебрегать как вы данным инструментом, как вы советуете тоже сомнительно.
но который необоснованно хейтят
Хейтят его вполне обоснованно. Личный опыт разработчика это достаточное обоснование. А большое количество таких разработчиков уменьшает вероятность, что кто-то что-то не так понял.
когда молодые разработчики уверены, что GOTO это плохо и боятся применять его
Правильно уверены, и применять его не надо, если есть возможность сделать по-другому.
или даже не знают о его возможностях
Возможность там одна, переход в произвольное место программы.
но при этом вы высказывкетесь в довольно категоричной форме:"Я написал как надо..."
В первом комментарии я это не писал. Во втором понятно по контексту, что это мое мнение. Я там так и написал "у меня есть свое мнение".
Я говорю "применять его не надо", потому что точно знаю, что мне будет сложно работать с таким кодом, и могу представить, что другим разработчикам тоже будет сложно. Мне неважно, что лично вы считаете этот код простым и понятным. В проектах, которые будете поддерживать только вы, конечно можете делать как хотите.
Но с вами несогласны именно разработчики языков, о которых я говорил
Насколько я могу судить, разработчики языков, о которых вы говорили, со мной согласны.
но и прям пренебрегать как вы данным инструментом, как вы советуете
Я не советую пренебрегать данным инструментом. Бросать исключения можно и нужно. Ловить их, тем более по специфичному типу, и продолжать выполнение в большинстве случаев не надо. Исключительная ситуация должна останавливать программу, потому она и исключительная.
"Правильно уверены, и применять его не надо, если есть возможность сделать по-другому."
Совсем неверный постулат, если инструмент работает лучше чем другие варианты, то лучше применить этот инструмент. И хороший мастер должен знать где и когда лучше применять инструмент. Не надо молотком забивать шурупы, а отверткой закручивать гвозди. Goto тоже можно и нужно использовать, а что бы знать когда и где нужно не хейтить, а обучать, разъяснить. Вы предлагаете мыслить шаблонами, как возможно вас приучили это делать, не спорю это тоже результативно.
"Ловить их, тем более по специфичному типу, и продолжать выполнение в большинстве случаев не надо. Исключительная ситуация должна останавливать программу, потому она и исключительная."
В этом вся проблема шаблонов, вы почему-то твердо уверовали, в то, что только ваше применение это правильно, но в документации Java отлично показано, что можно использовать в том числе и fast-return. И хочу вас просветить, Исключения придумали не для того что бы останавливать программу, а именно для того, что бы по возможности не останавливать еë.
Совсем неверный постулат, если инструмент работает лучше чем другие варианты
Ну вот дело в том, что в большинстве случаев он не работает лучше, чем другие варианты. Дальше надо приводить конкретные примеры, а не общие слова.
Вы предлагаете мыслить шаблонами, как возможно вас приучили это делать
Еще раз объясняю, мое мнение о goto основано на личном опыте, а не на чьих-то шаблонах.
вы почему-то твердо уверовали
Еще раз объясняю, я не "уверовал", а "проверил". Почему, я уже написал - потому что я точно знаю, что мне будет сложно работать с таким кодом.
Исключения придумали не для того что бы останавливать программу
Предлагаю вам посмотреть в словаре, что означает "исключение" в русском языке, "exception" в английском, и подумать, почему этот механизм так назвали. Если программа получила исключение, что база данных недоступна по сети, то она должна остановиться, а не продолжать алгоритм с пустыми данными.
Я тоже пишу на личном опыте. Спасибо.
"Если программа получила исключение, что база данных недоступна по сети, то она должна остановиться, а не продолжать алгоритм с пустыми данными."
Пример хорош, но с таким же успехом я могу привести пример ожидания появления файла в файловой системе, вы каждый раз будете дергать глобальный обработчик или всë же будете действовать по ситуации и ловить исключения там где это логично, в функции проверки наличия файла файловой системы, не доводя до глобального обработчика? Как раз тут ситуация как не остановить программу, а заставить еë работать и ждать результата.
Вообще-то функция проверки наличия файла файловой системы должна возвращать boolean, а не бросать исключение. Поэтому "логично" сделать if, а не try/catch. Это как раз то, о чем говорит автор, только он слишком преувеличивает.
Аналогично, функция валидации входных данных должна возвращать boolean и список ошибок, а не бросать ValidationException. Обработка некорректного ввода должна быть предусмотрена, поэтому это не исключительная ситуация. А вот контроллер уже может бросить ValidationFailedHttpException, если это требуется фреймворком, а может и не бросать, а использовать метод контроллера return this.sendValidationErrorResponse(validationResult)
.
Ксавье Леруа (Xavier Leroy), который учёный, а не учитель танцев, недавно выпустил набор лекций по связи goto/исключений и прочих управляющих структур - https://xavierleroy.org/CdF/2023-2024/
Очень рекомендую.
Я долго пытался есть этот кактус на Kotlin + Spring потому как теоретически мне идея нравится, но в итоге отказался.
Потому что:
прокидывать ошибки по стеку руками - через чур гемморойно, имхо
Result плохо интегрируется с библиотеками. В частности Spring не откатит транзакцию, а корутины не закенсалят скоуп при возврате резалта. Транзакции точно, а коррутины скорее всего можно обработать напильником, но, опять же, через чур гемморойно, имхо
В Котлине нет нормальной возможности в верхнеуровневом методе вернуть пару разных ошибок из вызываемых методов и в итоге ошибка очень быстро превращается в Exception (корневой тип ошибок), чья информативность стремится к 0. Хотя это может решиться благодаря Rich errors (доклад с их представлением).
В итоге я разделил ошибки на восстановимые (которых в моих проектах очень мало) и не восстановимые. Восстановимые возвращаю резалтом и тут же обрабатываю, а не восстановимые бросаю исключениями которые улетают до контроллера (спецефичные для метода) или миддлваря (универсальные в духе resource-not-found). Всё это у меня подробно описано тут
Так у вас типичная "Проблема XY". После правильного рефракторинга обычно нет проблемы понять, в каких местах (на каких уровнях) вам нужно отрабатывать ошибки, а в каких это не нужно. Если рефакторинг правильно разделил код на слои и свёл его к типовым абстракциям, то вам не нужно бегать по всему коду и расставлять ловлю исключений или обработку ошибок. Ловить исключения нужно только на тех уровнях абстракции, где нужно их как-то обработать: записать ошибку в лог, прервать работу программы или наоборот повторить попытку, и т.д.
А вот объект с ошибкой вы вынуждены будете отрабатывать по всей цепочке вызовов, везде проверяя, не случилась ли ошибка, или можно что-то делать дальше, это ли не кошмар?
Удачи вам проверить и обернуть десяток ошибок в каждом методе контроллера вместо выбрасывания ошибок
У меня перед глазами два проекта. В одном все возвраты функций это Result, во втором присутствуют исключения. Весь вопрос вокруг валидации бизнес логики, поэтому для нее заведен собственный тип исключений. Проблема result в необходимости вручную прокидывать его наверх, у нас практически нет ситуаций, где можно обработать и пойти дальше. При прокидывании, бывает что подменяют ошибку своей, обобщенной(исключения обычно не трогают). Тогда теряется контекст и цепочку придется раскручивать дальше. На проект с result повлиять не можем, на второй - еще думаем что же удобней
Я не шарпист, пишу на жабе, но в жабе ты бросаешь эксепшен в любом слое, он по стеку возвращается на самый верхний слой (чаще всего это какие нибудь контроллеры), а там обрабатываешь его и возвращаешь http error с нужным кодом и описанием из эксепшена и я не представляю какой геморрой пробросить это все с помощью того же return, который фактически предназначен для возврата результата, а не ошибки. Создаётся такое впечатление, что автор в разработке недавно.
Это у вас в Java исключения хотя бы часть сигнатуры методов. Без этого, переиспльзование бизнес логики с исключениями в новом endpoint-е потребовало бы изучить все слои, что и где там может стрельнуть, и это все замапить в http коды + OpenAPI - то ещё удовольствие
Ну так то в некоторых языках это by design. В том же Golang нет исключений и всё должно возвращать статус помимо результата.
И это поняли только сейчас?
Кстати, почитайте "Go statement considered harmful", там, в частности, есть разбор проблем оригинального goto.
Ну ну знаю...
Вот у нас в JavaScript, а точнее в браузерном API есть fetch – API которого выделяется на фоне других браузерных API как раз использованием result вместо throw. И как по мне, это неудобно:
const result = await fetch('https://habr.com/');
Во первых, нам все равно здесь нужен try/catch, потому что может произойти ошибка при отправке запроса:
try {
const result = await fetch('https://habr.com/');
} catch(error) {
// ...
}
А потом еще проверят result:
try {
const result = await fetch('https://habr.com/');
if (result.ok) {
// ... все хорошо
} else {
// все плохо
}
} catch(error) {
// ...
}
Хотя на мой взгляд, было бы лучше так:
try {
const result = await fetch('https://habr.com/');
} catch(error: RequestError | ResponseError) {
// ...
}
Да и в целом try/catch лаконичнее:
class Foo {
data: JSON;
async doWork() {
try {
// можно обойтись без локальных переменных в замыкании
this.data = await HttpClient.get();
} catch(error) {
// ...
}
}
}
class Foo {
data: JSON;
async doWork() {
// лишняя переменная
const result = await HttpClient.get();
// ложно-положительный результат
if (result.ok) {
this.data = result;
} else {
// ...
}
}
}
Исключения про то, что их невозможно проигнорировать как всякие там ручные result и коды ошибок. И они про исключительные ситуации, если допустим нужно открыть файл и без него работать ничего не будет, это исключение, если это просто логика программы, если нет то создать, то это простой bool.
И тащить во весь проект result или bool api, как в SDL3. Это не преимущество, а отсутствие исключений.
В том проекте, где у вас сотни throw
, обязательно будет что-то вроде try { ...} catch {}
, в лучшем случае try { ...} catch { logger.Log(); }
.
Исключения про то, что их невозможно проигнорировать как всякие там ручные result
openFile : String → Either FileError FileHandle
Как тут проигнорировать ручной result?
Справа! Как подсказывает Hoogle — https://hoogle.haskell.org/?hoogle=Either a b -> b
80% ошибок исключительные, просто runtime error и все. Все остальное это логика программы. А вы пытаетесь все обрабатывать неким единым ручным способом, в си оправдано, но в более высокоуровневых языках нет.
Скоро всем мы будем goto вода
Выделение метода при большой вложенности. Например уже двойной цикл желательно, а тройной уж точно
Картинка где готу сидит на диване и все другие операторы готовы его активно пользовать )))
Во всех случаях, где мне надо вернуть ошибку, я использую паттерн Result.
Я надеюсь вы не HTTP API пишете. А то встречались уже HTTP 200, смотришь внутрь, а там {"error": "not found"}
Читал что используют «встроенные функции» вместо goto для возврата значений. Где return error(); а там уже оттуда уже возвращается ошибка и данные…
«Локальная функция»
[httpPost]
public IAct Submit(ship form){
IAct error (){
Response.Cookies.Append(“shipping’s error”,”1”);
return RediAct(“Index”,”ship”,form);
}
Все же throw это не совсем goto. Throw - это структурированный goto с понятными правилами куда он выкинет. Если идти дальше в этом направлении, то нужно забанить и return, поскольку return может выходить из середины функции или блока. Вместо этого все обмазывать флагами чтобы выход был только один в самом конце. Просто код с флагами будет хуже читаем, поэтому и делают return и throw. Это компромисс.
В goto прямо явно написано, куда он выкинет, куда уж понятнее.
Вы будете смеяться, но когда-то я читал публикацию, где автор очень серьёзно топил за единственный return. Мол, если их много - то с кодом однозначно что-то не так. Я тогда даже проникся этой идеей - но было это много лет назад, я был зелен и глуп...
Тем не менее, в отказе от преждевременных return есть свой резон: для них можно запросто забыть написать код, который прибирает за собой (например, делает Dispose, или освобождает блокировку). Но в C# всё это изначально лечилось тем, что такой код пишется в блок finally - и тогда он точно будет исполнен, даже если return будет сделан изнутри блока try. А ещё в более-менее современном C# есть для этого специальные операторы using и lock - с ними даже try...finally самому писать не надо.
Это делается ради единой точки выхода. Когда требуется подчистка выделенных локально ресурсов.
Статья вызывает возражения.
Во-первых, автор излишне драматизирует throw в заголовке, будто это тот же goto.
На самом деле, тут есть существенная разница. Чтобы ее понять, нужно вспомнить о структурном программировании. Концепция эта старая (повека где-то уже), а потому я ее здесь напомню.
Давным-давно, когда многие нынешние мидлы и даже некоторые сеньоры ещё не родились, возникла ип остепенно стала общепринятой идея,
что программа, чтобы легко читаться, должна иметь определенную структуру передачи управления в ней - состоять из нескольких типов элементов (возможно, вложенных друг в друга). Эти элементы включают в себя, как минимум:
простые операторы (включая оператор вызова подпрограммы), последовательности выполняемых один за другим одного или некольких операторов (иначе, блоков),
условные операторы - блоки, выпаолняемых при определенных условиях, и операторов цикла - блоки, выполняемых неоднократно при соблюдении определенных условий. И, возможно - других операторов передачи управления, вызывающих преждевременное завершение блоков, которые эту структуру не нарушают, и могут быть заменены условными операторами: break, continue... Оператор вызова исключения throw тоже входит в число таких сохраняющих структуру операторов. А вот goto, который может передать управление откуда угодно куда угодно - в общем случае не входит. А потому throw!=goto
Второе возражение - недостатки вызова исключений при высокой глубине вложенности вызовов
В итоге получилось так, что вызывается некий метод, далее всё это сильно ветвится, вызывая прочие методы. И разные методы выбрасывают разные типы исключений, а ещё некоторые из них отлавливаются и просто логируются, а некоторые пробрасываются дальше. Некоторые нигде не отлавливаются, а некоторые перебрасываются с новым текстом ошибки.
не спепецифичны именно для исключений - они, как мимнимум, те же, что и в шаблоне с результатом, включающим ошибку: объекты ошибок точно так же могут быть разного типа, некоторые ошибки могут точно так же обрабатываться внутри частично ("логироваться") или полностью (ещё и не передаваться дальше), заменяться другими - короче автора это всё равно бы не избавило от необходимости самостоятельно анализировать документацию или код промежуточных подпрограмм при ее отсутствии.
Идея автора
Моя идея проста: выбрасывайте исключения только там, где они действительно нужны.
тем не менееЮ правильная, но - по другой причине: существующие реализации обработки исключений обычно дорого обходятся. Это не везде и не всегда так: структрурная обработка исключений (SEH) в ядре Windows достаточно легка, потому поддерживается аппараторой (кстати, ради совместимости с SEH при переходе к x64 пришлось сохранить некоторые регистры, которые иначе для x64 нужны, как козе баян). Но в языках высокого уровня такая поддержка обычно не используется (ну, в Delphi 32-битной использовался SEH, но для нас это не актуально), поэтому совет экономить на throw остается.
Короче, как-то так.
PS IMHO куда шире, чем try...catch стоит использовать конструкцию try...finally, чтобы прибрать за собой, например - по-любому выполнить очистку (Dispose) объектов, которые такую очистку предусматривают. Или - по-любому освободить захваченную блокировку. Разработчики C#, похоже, тоже так же думают, и они для таких случаев ввели специфические конструкции (using и lock) прямо в язык.
Вопрос очистки имеет очень разную значимость для разных языков. В языке с ручным управлением памятью вроде C++ очистка имеет первостепенную важность, а в языке с автоматическим управлением вроде Python - это скорее редкая ситуация. При чисто функциональном стиле вообще нет никакой очистки.
Автор тут писал про конкретно C#, а в C#, несмотря на автоматическое управление памятью, есть резон очистку все-таки выполнять: GC будет меньше работы при сборке мусора, чем если бы ему самому пришлось вызывать Finalize. Ну и, если очистка возвращает в общее пользование какие-то разделяемые ресурсы (например, разрешения на параллельное выполнение от ConcurencyRateLimiter, я сейчас как раз в этой теме, потому что статью пишу), то эти ресурсы вернутся в общее пользование быстрее.
Речь может идти не о тех ресурсах, которые могут быть очищены автоматически, но о какой-то обязательной логике типа commit/rollback или отсылке куда-то какого-то результата в зависимости от того, что успели или не успели сделать в процессе выполнения.
Всё верно, поэтому я и пишу, что ситуация редкая, а не невозможная.
Хотя rollback (или commit) скорее всего будет выполняться автоматически при уничтожении соединения по таймауту запуска GC. Но тут многое зависит от прикладной логики.
А как эта сама автоматика узнает когда нужен rollback, а когда commit?
А если вы обрабатываете некоторое сообщение из очереди и обязаны в очередь же отправить какой-то ответ в любом случае?
Вам каждый return придется оборачивать блоком кода, который что-то делает по логике завершения.
В нашем языке для этого можно объявить блок on-exit - туда безусловно попадешь в любом случае. Даже если случилось не перехваченное системное исключение (аварийный выход).
вы иногда пиши́те
try-catch
— это звучит как призыв к действию, но вы, наверное, хотели написать другое:
вы иногда пи́шете
try-catch
— просто констатация факта.
У таких конструкций и паттернов есть фатальный недостаток, их нужно пробрасывать выше и выше. Каждая вложенная функция должна позаботиться об ошибке. Зачем все это если можно сделать обработчик ошибки по месту, где он нужен?
Тейк про скорость очень сомнительный. Если не писать вообще весь код на исключениях, то разницы нет.
У таких конструкций и паттернов есть фатальный недостаток,
Сильно зависит от языка. Например, в Rust есть discriminated unions, #[must_use] и try operator. Вместе они делают работу с results удобной и одновременно убирают заботливо разложенные грабли (например, случайно проигнорировать ошибку нельзя). Win-win.
В C# ничего этого нет, вот и выходит, что работать с исключениями гораздо проще. Но в целом, это не вина паттерна.
Разницы нет до тех пор пока не начнут строго спрашивать за производительность. А вот когда начнут, то удобнее сразу написать как эффективнее работает (а не как писать удобнее) чтобы не переделывать все после провала на нагрузочном тестировании - это точно неудобно с точки зрения разработки.
Ну и избегать большой вложенности вызовов (и большой глубины стека) - это тоже хороший стиль.
начнут строго спрашивать за производительность
try-catch-finally/using практически бесплатны (если не помещать их внутри длинного цикла на горячем пути, конечно)
throw дорогой, но - можно пример сценария, при котором throw начинает влиять на производительность нормального/expected случая, а значит и среднюю производительность?
избегать большой вложенности вызовов (и большой глубины стека) - это тоже хороший стиль
Более чем спорно
Как вы себе представляете работу try/catch без throw где-то внутри?
throw дорогой, но - можно пример сценария, при котором throw начинает влиять на производительность нормального/expected случая, а значит и среднюю производительность?
Да легко. Обработка миллиона записей (а это немного на самом деле, бывает и кратно больше) где могут случаться какие-то нештатные ситуации, которые нужно отложить для последующего ручного разбора и идти дальше.
Особенно в случае, когда все это всего лишь один из тысяч работающих на сервере процессов.
Тут не только общая производительность сервера может деградировать, тут еще нагрузка на сервер будет расти.
Как вы себе представляете работу try/catch без throw где-то внутри?
Не "без throw где-то внутри", а "без try-catch внутри длинного цикла".
Запросто, try-catch снаружи цикла, если нужно прервать цикл при исключении. Если нужно "пропускать" "исключительные" итерации и продолжать дальше - внешний while/do-while/или-даже-for, внутри подготовка условий и контекста для внутреннего цикла, try-catch, внутри него - внутренний цикл по подготовленным условиям и контексту.
Вариантов масса.
Обработка миллиона записей (а это немного на самом деле, бывает и кратно больше) где могут случаться какие-то нештатные ситуации, которые нужно отложить для последующего ручного разбора и идти дальше.
И таких "исключительных" записей ожидается больше, чем погрешность от общего количества? - тогда это выглядит не как исключительный случай, а как вариант нормы - здесь просится валидация данных итерации и выбор одного из путей обработки или условный if (!TryProcess(...)) { ... } - зачем строить такую логику на исключениях?
Внешние неконтролируемые зависимости внутри цикла? - переписать согласно предыдущей рекомендации, прогнать нагрузочные - если всё ещё неприемлемо - заменить зависимости на самописные/более подходящие.
В любом случае, этот пример - хорошая иллюстрация использования неподходящей техники, а не ущербности исключений.
А что вы строите на исключениях? 99% "ошибок" - это логика, требующая обработки. Причем, в разных сценариях использования логика обработки одной и той же ошибки может быть существенно разной. В каком-то случае это прерывающая ошибка, в каком-то просто логируемая без прерывания общего потока обработки, в каком-то ее вообще можно игнорировать как несущественную.
Т.е. если у вас некий модуль бросает исключение, а вызывается он внутри вложенных циклов, может возникнуть сценарий когда вам придётся бороться с тем, чтобы он по данному конкретному исключению из цикла не вылетал
А что вы строите на исключениях? 99% "ошибок" - это логика, требующая обработки.
В моём понимании (и практике) ровно наоборот - условные "99% ошибок" внутри процессинга - это прерывающие исключения.
Всё, что не прерывает - это нормальный случай, он выбирается по условиям/анализу/валидации параметров-данных. Или тот самый Try*-pattern.
Да, бывают, опять же, внешние неконтролируемые зависимости с высокой частотой throw по любому поводу - приходится заменять или минимизировать ущерб.
Одноразовые (per API call/button press) проверки - вообще, как правило, не влияют на картину throughput, могут быть построены как угодно (до известной степени, конечно).
...если что - обработка миллиардов итераций per-API-call (в том числе с необходимостью обработки исключений на горячем пути) - специфика моих систем в последние долгие уже годы, так что "цену" разных мелочей я прочувствовал и понимаю. И как показывает моя практика - оптимизация нормального/expected случая "весит" куда больше, чем возможные исключения.
В моём понимании (и практике) ровно наоборот - условные "99% ошибок" внутри процессинга - это прерывающие исключения.
Какого рода ошибки?
Если речь о чем-то типа деления на ноль, то они вызывают системное исключение которое перехватывается дефолтным обработчиком с формированием дампа и автоматически (системой же) фиксируются в joblog задания. Это уже "дефект промсреды" - он передается на исправление.
Здесь же речь о другом изначально.
Всё, что не прерывает - это нормальный случай, он выбирается по условиям/анализу/валидации параметров-данных
Вот. А дальше? Данные невалидны. Что дальше? Вернуть какой-то result или бросить исключение через throw? Вот о чем разговор.
Вот. А дальше? Данные невалидны. Что дальше? Вернуть какой-то result или бросить исключение через throw? Вот о чем разговор.
В общем случае так: если у меня есть обработчик для такого [невалидного] состояния - вызвать его, иначе - исключение, всё прерывается, исключение улетает выше - может быть там знают, что с ним делать.
Вот в том и дело что исключение - это слишком дорого. По крайней мере для нас.
Но у нас стек специфический очень. Суть в том, что все построено на "сообщениях". Сообщение - это идентификатор (7 символов), текст (с возможностью подстановки параметров) и уровень серьезности (информация, предупреждение и т.п.). Сообщения можно использовать как имеющиеся, так и добавлять свои.
Данные по сообщениям хранятся в т.н. файлах сообщений (что-то типа таблицы БД).
Есть понятие "структурированной ошибки" - это код сообщения + блок параметров (которые потом будут подставляться в текст). Есть системное API которое возвращает полный текст сообщения (с подставленными параметрами) и его уровень серьезности получив на вход структурированную ошибку.
Ели что-то случилось - просто заполняем структуру ошибки а дальше... Дальше самое интересное. Можно вернуть структуру (аналог того самого Result), а можно бросить ее системным исключением (у программы есть "очередь сообщений программы" - бросаем туда сообщение и вот вам системное исключение). Системное исключение может быть перехвачено и обработано в самой программе или уйдет вверх по стеку.
При этом система автоматически заносит в JOBLOG задания информацию по исключению - что случилось, в каком модуле, в какой строке... В дефолтном обработчике можно добавить dump - там тоже будет вся информация - что, где, полный дамп всех переменных.
Так вот исключения пользуем только в самых крайних случаях. Потому что это очень дорого. Штатно работаем со структурированной ошибкой. Которая просто проверяется и при необходимости уходит вверх по стеку
Условно говоря:
dcl-proc procA;
dcl-pi *n;
dsError likeds(t_dsError);
end-pi;
clear dsError;
procB(dsError);
if dsError <> *blanks;
return;
endif;
...
return;
end-proc;
Примерно так. Это намного дешевле чем делать
snd-msg *escape %msg(dsError.code: msgFile: dsError.data);
в procB (snd-msg *escape в данном случае есть аналог throw)
и
monitor;
procB();
on-error;
return;
end-mon;
в procA или где-то выше по стеку (monitor - аналог try, on-error или on-excp с нужным кодом ошибки - аналог catch).
И да, своих исключений в языке нет - они не нужны т.к. есть системный механизм исключений, работающий на уровне системы вне зависимости от того, на чем написана программа - в рамках задания может быт так, что программа на одном языке вызывает программу на другом, там бросается исключение которое перехватывается в вызывающей программе выше по стеку. Т.е. этот механизм работает в рамках всего задания "насквозь".
А snd-msg вообще очень гибкая штука
Так, ну раз пошла такая пьянка, то давайте уже и я отвечу взаимностью и разверну немного свою т.з.
Перво-наперво - теги у статьи .NET/C#, с этих позиций я и выступаю, понимаю, что могут быть рантаймы/архитектуры, где всё по-другому.
Повторю, да, throw в .NET достаточно/относительно тяжелый, нужно это учитывать, но при правильном применение - более чем годный.
Правильное применение - это не пытаться использовать исключения как оператор ветвления.
Дальше, как я вижу предложенный пример с "миллионами записей, некоторые из них нужно отложить".
На примитивном уровне - это два варианта данных, причём для обоих у меня есть алгоритм обработки. Поэтому код выглядел бы как-то так:
if (!TryProcess(item)) Postpone(item);
или
if (IsValid(item)) Process(item);
else Postpone(item);
А вот если Process или Postpone не справились - то они выкидывают исключение, которое улетает выше - и там уже, возможно, мы решаем что с этим делать.
Потому, что это явно случай, к которому мы не готовы, не важно по какой причине, но что делать мы на этом уровне не знаем.
(кстати, TryProcess хоть и не должен бы, но тоже может выкинуть исключение, если там совсем что-то непредвиденное)
А на уровне выше, может и знаем - например, пропускаем эту запись и идём дальше (как я описывал выше), а может и вообще всё рушим и перезапускаем машину ;)
Но важно то, что если таких случаев становится слишком много, то мы их анализируем и добавляем обработчик(и), тем самым опять снижаем частоту исключений.
И код становится примерно таким:
if (TryGetStrategy(item, out var strategy))
{
strategy.Process(item);
}
else
{
throw new UnexpectedDataException(item);
}
Кстати, выбранная стратегия тоже может выкинуть исключение, если она по какой-либо причине не справилась, логика выше по стеку остаётся такой же, как и была.
Иначе говоря: поддерживать все известные случаи, в неизвестном - выкидывать исключение, но таких случаев должно оставаться как можно меньше.
Как-то так.
Во-первых, валидация данных может оказаться избыточно тяжелой.
Во-вторых, ошибка может возникнуть в процессе обработки данных. Например, пытаетесь что-то записать в БД, получаете ошибку по дублированию ключа. Ошибка? Да. Но ее обработка сильно зависит от контекста.
Пример. Есть обработка... Ну некоторых данных, так скажем. Там в течении дня где-то под сотню миллионов проходит.
А процессе обработки возникают некие промежуточные результаты которые надо закешировать (для данного ключевого значения) с тем, чтобы если придет блок данных с этим значением, просто их прочитать из кеша.
Пришли данные, лезем в кеш, на этот ключ ничего нет, вычисляем, обрабатываем, пытаемся записать в кеш и получаем дублирование ключа. Потому что обработка идет в 10 потоков (точнее, в 10-ти параллельных заданиях) и кто-то из другого задания получил другой блок с таким же ключом и успел записать в кеш до нас. И в данном случае - "да и хрен с ним, записал кто-то другой, нам писать не надо". Т.е. эта ошибка просто игнорируется.
Есть и другие ситуации когда по одной логике некая ошибка является блокирующей, по другой - просто информационной, по третей ее вообще можно игнорировать.
Иначе говоря: поддерживать все известные случаи, в неизвестном - выкидывать исключение, но таких случаев должно оставаться как можно меньше.
Таких ошибок вообще не должно быть. У нас это называется "дефект промсреды" который подлежит исправлению.
Обычно это что-то типа Decimal data error (когда не проверили что пришло на вход) или Variable too small to hold result - переполнение (да, у нас переполнение вызывает системное исключение если вы попытаетесь в двухбайтовое целое записать 4 байта - получите исключение). Такие вещи надо проверять и формировать структурированную ошибку что результат фактически неопределен. А дальше уже опять по логике задачи действовать.
А вот если Process или Postpone не справились - то они выкидывают исключение, которое улетает выше - и там уже, возможно, мы решаем что с этим делать. Потому, что это явно случай, к которому мы не готовы, не важно по какой причине, но что делать мы на этом уровне не знаем.
В этом случае у нас система сама выкинет исключение и оно пойдет вверх по стеку до тех пор, пока не наткнется на обработчик. В случае если случилось что-то такое, чего не смогли предусмотреть.
Во всех остальных случаях используется структурированная ошибка. Потому что ваша
if (IsValid(item))
не дает никакой информации о том, а что именно там не прошло валидацию.
Допустим, вы обрабатываете миллион клиентских данных (к примеру, у нас есть процедура ежегодной актуализации клиентов, через нее проходит порядка 25млн клиентов). И что у вас не прошло валидацию? Неправильная дата выдачи паспорта? Некорректный ИНН? Или еще что-то?
Вот для этого и используется структурированная ошибка - на каждую ситуацию там будет свой код, свой текст и свои данные - что именно не так. И все это валится в лог по которому потом видно кто именно и почему не прошел актуализацию - IsValid вернет структурированную ошибку, которая отправится прямиком в лог.
Вот файл сообщений
Более полная информация по конкретному сообщению
Структурированная ошибка содержит ИД сообщения + блок данных (параметров) которые будут подставляться вместо &1, &2, &3 и т.п.
В принципе, в процессе обработки обычно бывает достаточно ИД сообщения (тип ошибки). А в лог пойдет полная текстовая расшифровка со всеми подставленными параметрами.
Ну вот как пример. Есть некая программа (которая будет вызываться из другой программы в рамках задания - специфика нашего стека в том, что "процедура" может быть не обязательно внутренней процедурой в этом же бинарнике - она может быть и внешней - это в динамической библиотеке, или вообще в виде отдельной программы-бинарника, но при соответствующем описании вызываться как обычная процедура).
И внутри нее нужно выполнить несколько последовательных этапов. Если какой-то завершился с ошибкой - выходим с возвратом ошибки в вызывающую программу.
Интерфейс у нее такой
// --------------------------------------------------
// Prototype for main procedure
// --------------------------------------------------
DCL-PROC INNSMEVRQ;
DCL-PI *N;
Dte zoned(7: 0);
Error char(37) options(*nopass);
END-PI ;
Dte тут есть некоторый параметр, Error - та самая структурированная ошибка (у нас структура тождественна строке - 37 символов это 7 символов на ИД + 3 параметра по 10 символов).
Причем, тут ошибка объявлена как необязательный параметр - кого-то может в принципе не интересовать результат - все равно он залогируется, вызвали, потом логи посомтрим.
А далее
// Этапы вполнения
dcl-enum enStages qualified;
stageAccounts 1;
stageNames 2;
stageUnconfGRF 3;
stageChngUID 4;
stageChngCBH 5;
stageCLJ 6;
stageCrdHldr 7;
stageSend 8;
end-enum;
dcl-s stage int(5);
dcl-ds dsError likeds(t_dsError37);
// Заполняем список и вызываем процедуру отсылки запроса в СМЭВ
clear dsError;
for-each stage in enStages;
select stage;
when-is enStages.stageAccounts ; // Отбор по счетам
procAccounts(procDte: dsError);
when-is enStages.stageNames ; // Отбор по изменению Имени
procNames(procDte: dsError);
when-is enStages.stageUnconfGRF; // Отбор по неподтвержденному ИНН
procUnconfCRF(finnstFrom: finnstTo);
when-is enStages.stageChngUID ; // Отбор по изменению идентификации
procUID(finnstFrom: finnstTo);
when-is enStages.stageChngCBH ; // Отбор по изменению статуса самозанятого
procCBH(procDte);
when-is enStages.stageCLJ ; // Отбор из витрины ЖМ
procCLJ(procDte);
when-is enStages.stageCrdHldr ; // Отбор держателей карт из МПК
procCardHolders(procDte);
when-is enStages.stageSend ; // Отсылка запросов
sendRequests(dsError);
endsl;
// Если на каком-то из этапов вернулась ошибка - завершаем работу
if dsError <> *blanks;
leave;
endif;
endfor;
if %passed(Error);
Error = dsError;
endif;
return;
Т.е. в цикле вызываем все этапы (в том порядке, как они определены в enStages, если на каком-то этапе произошла ошибка - выходим из цикла. И, если "сверху" попросили вернуть ошибку (вызвали с параметром Error) - пробрасываем ее наверх.
при этом не все этапы могут вернуть ошибку - есть такие, где просто нет источника ошибки.
Если где-то внутри ошибка таки возникла, то она будет там же залогирована и возвращена наверх.
Например, в procAccounts и procNames источником ошибки может быть SQL запрос. Тогда оттуда будет вызвана процедура SQLError
DCL-PROC SQLError ;
DCL-PI *N;
procName char(64) const;
dsError likeds(t_dsError37);
END-PI ;
Внутри которой
exec sql GET DIAGNOSTICS CONDITION 1
:SQLID = DB2_MESSAGE_ID,
:SQLSTATE = RETURNED_SQLSTATE,
:errText = MESSAGE_TEXT;
dsError.errCode = 'FIN0019';
dsError.errParm1 = sqlid;
dsError.errParm2 = sqlstate;
dsError.errParm2 = procName;
AGFRLogText(C_INNSMEV_NODE: '': 'E': 'SQL Error: ' + %subst(errText: 1: 253): *omit: *omit: procName);
AGFRLogText - запись ошибки в лог. Туда пойдет тот текст, что вернул SQL движок в поле MESSAGE_TEXT.
А наверх уйдет структурированная ошибка с кодом FIN0019 (это наш код, под конкретный модуль) которая описана как
messageID = "FIN0019" // message ID. Set it if not match with OBJECTNAME
messageText = "Ошибка &1 со статусом &2 выполнения SQL запроса в процедуре &3"
messageSeverity = "20"
messageFile = "KSMMSGF"
messageFileLib = "%KLIB%"
invertMessage = false
replaceMessage = true
lengths = [10,10,10]
И в качестве параметров в нее будут подставлены SQL код (DB2_MESSAGE_ID), SQL статус (RETURNED_SQLSTATE) и имя процедуры где она возникла.
Т.о. с минимальными затратами ресурсов имеем полную диагностику если что-то пошло не так.
Такими темпами мы сейчас перепишем вашу систему на .НЕТ и отпрофилируем заодно.
Валидация тяжелая, IsValid не возвращает деталей - ок, усложним по-быстрому, будем кешировать и переиспользовать известные детали/проблемы процессинга:
struct ItemProcessingContext
{
// Содержит поля и флаги, в которых мы можем сохранять
// результаты различных этапов процессинга записи
}
...
ItemProcessingContext context = default;
if (!TryProcess(item, ref context)) Postpone(item, ref context);
или
ItemProcessingContext context = default;
if (IsValid(item, ref context)) Process(item, ref context);
else Postpone(item, ref context);
или
ItemProcessingContext context = default;
if (TryGetStrategy(item, ref context, out var strategy))
{
strategy.Process(item, ref context);
}
else
{
// внутри, возможно, будет throw. А может и нет.
ProcessUnexpectedCase(item, ref context);
}
// если дошли сюда - можем проанализировать накопленный контекст
// и сделать ещё что-нибудь, если вдруг нужно
PostProcessContext(item, ref context);
Ну и не забываем, что каждый уровень должен решать возникающие проблемы в рамках своей ответственности, всё неожиданное выбрасывать наверх.
БД не сумела сохранить данные? - ретрай и если снова не удалось - исключение.
Кэш уже содержит запись для ключа? - молча игнорирует.
А другая имплементация, возможно, кидает DuplicateKeyException.
И так далее.
Сути не меняет:
Если у нас есть известный случай и мы его поддерживаем - нормальная его обработка проходит без исключений.
Неизвестный случай или ошибка процессинга, о котором мы знаем, как сообщить на уровень выше (наш контракт имеет необходимые для этого свойства/типы) - нормальный процессинг, не знаем, как сообщить наверх - исключение.
Доведём мой подход до абсолюта: "идеал" по умолчанию - "все" методы возвращают void, со своими проблемами справляются сами, выполненный метод означает успех, иначе - исключение, означает нерешенную проблему. Простой линейный контрол флоу.
А уже в дальнейшем, для обеспечения необходимой гибкости/производительности начинаем вводить необходимые типы/коды возвратов, шаред контексты, стадии обработки, ветвления и прочее, и прочее - в минимально необходимых рамках "нормально" поддерживаемых случаев.
Такими темпами мы сейчас перепишем вашу систему на .НЕТ и отпрофилируем заодно.

Ваш нет не поддерживает SQL непосредственно в коде. Ваш нет не поддерживает типы данных, которые есть в БД (всякие фиксированные точки с арифметикой, в т.ч. и арифметикой с окрглением, дату-время со всеми форматами и арифметикой и т.п.) Вам на все надо внешние зависимости и объекты создавать. А у нас это все "из коробки" прямо в языке. Плюс возможность прямого доступа к БД через индексы (чтобы получить одну или десяток записей по известному значению ключа, скуль - мегаизбыточно).
А работа с БД - 99.9% нашей деятельности. И БД у нас - часть ОС. Т.е. программно-аппаратная реализация.
Так что, нет. Спасибо, но не надо :-)
Валидация тяжелая, IsValid не возвращает деталей - ок
В том и дело что не ок... Вот есть добавление записи в таблицу. Запись... ну 10-15 полей... И все их нужно провалидировать (часто не просто что туда что-то осмысленное, но еще и на непротиворечивость каким-то другим данным). Для этого есть специальные модули-валидаторы. Которые производят соотв. действия возвращают осмысленные ошибки по которым можно понять что не так. Это отдельный модуль - если потребуется изменить логику, не надо пересобирать все. Только один модуль (у нас вообще все так, на отдельных модулях, каждый из которых выполняет свою конкретную задачу и ничего больше).
С остальным в целом согласен. Но с оговоркой - в нештатных ситуациях система у нас сама генерирует исключениия. А в тех, которые мы можем предусмотреть - там только структурированные ошибки т.к. они легче по нагрузке (для нас существенно)
Таких ошибок вообще не должно быть.
Теория утверждает, что результаты практики должны соответствовать теории, но на практике это не так.
Для меня основной сценарий исключений - это обработка и автоматическое парирование ошибок в программе или аппаратуре.
Теория утверждает, что результаты практики должны соответствовать теории, но на практике это не так.
Ну я не говорю что их нет. Я говорю что это уже не ошибка, а дефект. Который должен быть зафиксирован, диагностирован и передан на исправление.
То, что рушит бизнес-процесс, должно исправляться в кратчайшие сроки. У нас так.
Потому что конечная цель - бизнес-логика, а не отлов ошибок.
Конечно. Но программа, тем не менее, должна ведь как-то работать и до момента когда её исправят. И желательно, чтобы ошибки в программе не рушили бизнес-процесс.
Я не знаю что вы имеет ввиду под "программой". У нас нет ничего крупномонолитного. Есть много мелких модулей-функций. Если дефект критичный, всегда можно откатить назад к предыдущему состоянию.
Ну и тестирование у нас это несколько этапов (кратно дольше разработки) - компоненты, бизнес, интеграция, нагрузка... Все это делается с одной целью - избежать дефектов, которые могут повлиять на работу банка в целом.
Дефект промсреды имеет максимальный приоритет. Т.е. бросить все и исправлять.
В итоге чего-то крупного я даже не припомню. Бывает мелочь которая влияет на какие-то отдельные процессы, но там обычно речь о каких-то редких отдельных ситуациях. Все серьезное выявляется на этапах тестирования.
А 99% ошибок - ошибки связанные с бизнес-логикой. И там исключения не нужны. Все идет через структуриованные ошибки. Даже есть механизм (сервис) стека ошибок который работает в рамках задания.
А на что-то непредвиденное система сама сгенерирует исключение. И всегда есть дефолтный обработчик которые его перехватит, создаст дамп (плюс система залогирует исключение в joblog задания - сразу понятно где конкретно что случилось).
MCH1202 Аварийное 40 14.07.25 10:52:51.511786 AGT01R ALIBOP1 *STMT AGT01R ALIBOP1 *STMT
От модуля . . . . . . . . . : AGT01R
От процедуры. . . . . . . . : AGT01R
Оператор . . . . . . . . . : 64300
Для модуля. . . . . . . . . : AGT01R
Для процедуры . . . . . . . : AGT01R
Оператор . . . . . . . . . : 64300
Сообщение . . . : Decimal data error.
Cause . . . . . : The sign or the digit codes of the packed or the zoned
decimal operand is in error. Valid signs are hex A-F, valid digit range is
hex 0-9.
Более того, есть возможность создавать цепочку дампов (дамп создается просто одной командой) для каждого уровня стека. В дампе уже будет полная информация - состояние всех переменных в области видимости и много чего еще.
Все серьезное выявляется на этапах тестирования.
Хорошо, когда есть такая возможность. Это обычно возможно в информационных системах и не всегда возможно в системах обработки измерительной информации и управления оборудованием в реальном времени.
Представьте, например, что вы проводите ядерное испытание на полигоне (или стреляете по противнику). Хорошо, конечно, что у вас останется дамп упавшего модуля, но потерянной информации это не восполнит. Адекватно протестировать заранее невозможно, а повторять запуск программы дороговато во всех смыслах.
У меня была программа, для отладки которой необходимо было включать бортовое оборудование космического аппарата в определённой точке орбиты. Тоже недешёвый манёвр.
Хорошо, когда есть такая возможность. Это обычно возможно в информационных системах и не всегда возможно в системах обработки измерительной информации и управления оборудованием в реальном времени.
Не поверите, но с такими системами тоже работал достаточно много. И для тестов был собран специальный стенд. Не говоря уже о том, что для софта верхнего уровня были специальные тестовые модули (эмуляторы), которые гнали туда заданный поток данных. Причем, тестировались даже ситуации, когда вместо реальных данных гнался откровенный бред - как система на него будет реагировать.
Это дорого, но дешевле потенциальных последствий от ситуации когда все это случится уже на реальном объекте.
У меня была программа, для отладки которой необходимо было включать бортовое оборудование космического аппарата в определённой точке орбиты. Тоже недешёвый манёвр.
Т.е. лучше когда непосредственно на орбите вылезет ошибка которую вы заранее не предусмотрели и с которой не знаете что делать? Серьезно?
Как раз такие вещи надо тестировать предельно тщательно - там не вывесишь плашку "что-то пошло не так, попробуйте позже".
И для тестов был собран специальный стенд. Не говоря уже о том, что для софта верхнего уровня были специальные тестовые модули (эмуляторы), которые гнали туда заданный поток данных.
Да это всё понятно, но не гарантирует натурного результата.
Т.е. лучше когда непосредственно на орбите вылезет ошибка которую вы заранее не предусмотрели и с которой не знаете что делать? Серьезно?
Надёжно написанная программа предсказуемо работает даже при непредусмотренных ошибках.
Вы сейчас описали пример как работает голанг. Там полностью отсутствуют эксепшины и используется именно такой подход.
Я согласен, что он имеет место быть и даже возможно иногда лучше чем экспешены. Но к сожалению вы забываете или не упоминаете в этой статье о том, чтобы использовать данный метод надо везде будет обложиться иф стейтментами.
Если у нас большое кол-во вложенности и оч много методов возвращающих такой тупл - то надо везде обкладываться условиями и проверять не вернулась ли ошибка
if (!validator.Validate(dto, ModelState)) return BadRequest(ModelState);
И такой код должен быть в огромных кол-вах продублирован в приложении, что не шибко облегчает чтение кода(Именно так и происходит в большинстве голанг приложений.)
А это противоречит вашей фразе
Смотрите, как красиво, легко читается и предсказуемо выполняется!
Не особо легко читается если всё приложение пестрит строчками if(blabla) return Response
Я не фанат ни того ни другого подхода, но наверно стоит упомянуть и минусы такого подхода в статье. Не все так однозначно, к сожалению.
Но ведь именно для устранения бойлерплейта, связанного с обслуживанием Result, исключения и используют. Никакой семантической разницы между явным возвратом результата и "невидимым" пролетом исключения вверх по стеку нет, и нет проблемы, которую исключения создают, а Result решает.
Что исключения, что результаты позволяют отделить бизнес-логику от логики обработки ошибок. Другое дело, что неопытные программисты часто не могут понять, где логика, а где ошибка - вот тогда и начинаются многоуровневые невидимые спагетти (причем использование Result их видимее не сделает). Я как-то столкнулся с полноценным конечным автоматом на разбросанных по всему коду исключениях - но проблема-то была не в механизме, а в его неуместном применении и в размазанности бизнес-логики.
Но ведь именно для устранения бойлерплейта, связанного с обслуживанием Result, исключения и используют.
Не для устранения, а для "запихивания его поглубже под..."
Другое дело, что неопытные программисты часто не могут понять, где логика, а где ошибка
А ошибка часто является частью логики. Если это не тупая ошибка деления на ноль (например). И то, ноль может быть логически обоснованным и требующим отдельной ветки в логике.
Напротив, Result "запихивает поглубже": в исключении, как правило, есть информация о стеке, а в результате, как правило, нет.
ошибка часто является частью логики
В этом случае она обрабатывается непосредственно в момент возникновения и сложностей не создает. Существенной разницы между try/catch и if/else при этом не возникает.
P.S. Такая ситуация возникает не "часто", а "редко" - потому исключения и прижились. Достаточно заглянуть в любой код на Go и посчитать, сколько раз ошибку вернули без обработки, а сколько - обработали.
Напротив, Result "запихивает поглубже": в исключении, как правило, есть информация о стеке, а в результате, как правило, нет.
Это уже фактически проработанная ошибка. Там не нужна информация о стеке.
Существенной разницы между try/catch и if/else при этом не возникает.
Ровно до тех пор, пока не прогонишь реальную задачу с большой плотностью вызовов через профайлер
...Да, и вот кстати: в C# использую goto не так, чтобы часто, но регулярно - сильно упрощает жизнь в некоторых случаях, часть из которых уже упомянули.
Считаю его незаменимым инструментом, хоть и излишне "задушенным" в C#. Понятно зачем и почему - но всё равно хотелось бы ещё чуть больше свободы.
throw не плох, плох catch. Т.е., плохо, когда на основе исключений строят логику программы.
Большинство исключений в программе должны перехватываться единым обработчиком верхнего уровня, который сделает запись в лог и отдаст 500 ошибку.
Паттерн Result в C# работает ерундово. Если у вас Error не параметризированный тип, а простой Exception или string, то что вам с него толку в конечном endpoint'е? Вам всё равно придётся перебирать типы исключений или коды ошибок и разводить boilerplate без всякой поддержки компилятора. А если Error типизированный, то вы столкнётесь с проблемой, когда в одном методе попробуете объединить две различные ошибки. Без discriminating unions все эти вещи нормально не поедут.
P.S. Интересный подход применён в библиотеке HotChocolate, которая реализует GraphQL для C#. Там с помощью атрибутов можно разметить endpoint'ы типами исключений и эти исключения превратятся в специфические для данного метода типы ошибок, которые в сгенерированной схеме превратятся как раз в discreminating union наподобие того, что на скрине. Мой внутренний пурист поначалу сопротивлялся (модели ошибок уровня API мы должны выбрасывать из слоёв нижележащего уровня), но на практике такой подход оказался удобным.

Throw — это новый goto