Pull to refresh

Comments 478

Тю, а классический Null Object Pattern в шарпе не принято использовать? Просто интересно

Можно, но как в таком случае передать причину ошибки?

if (result is NullObject) {
    switch (result.GetType()) {
        case "NullBecauseNotExists": //...
        case "NullBecauseFriday": //...
        //...
    }
}
Думаю, тут лучше nameof использовать:
nameof(NullBecauseNotExists)

Дык паттерн матчинг же завезли:

switch (result)
{
    case NullBecauseNotExists nullBecauseNotExists:
        //...
}

Проблема: никакого статического контроля за этим делом нет, в отличие от обычного enum. Плюс проблема с default.

У меня нет проблемы, я так не делаю. Просто показал «более лучший» вариант свитча.
Это чтобы имя класса было не в строке, а в коде, доступное для автоматического переименования? (я ненастоящий сварщик)
Как-то вы не последовательны…
Это все ещё конвенция. Никто не мешает пользователю кода просто вызвать метод, не проверять возвращаемое значение, и начать использовать out-параметр.
Это конвенция. Люди могут ей не следовать, или даже не знать о ней.
Есть только один способ обойти нашу защиту — взять, и руками скастить результат к Success. Но это я не знаю, кем надо быть.

Я не знаю кем надо быть чтоб Task.GetAwaiter().GetResult() из UI потока дергать. Но ведь дёргают же.

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

А чтобы понять, что Maybe не гарантирует наличие данных, нужно знать значение этого слова на английском

Маленький минусик для шарпера — большой плюсище для одинэсника.

UFO just landed and posted this here
Потому что
Для каждого Сотрудник из Сотрудники Цикл
  // какой-то код
КонецЦикла;

Можно их эмулировать через алиасы:


Пусть Сотрудников = Сотрудники;
Для каждого Сотрудника из Сотрудников Цикл
  Пусть Сотрудник = Сотрудника;
  // какой-то код
КонецЦикла;

Незнакомых букв меньше:


Функция ВернутьПользователяИлиФигню(НомерПользователя)

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

UFO just landed and posted this here
Когда я решил стать программистом, я тоже не знал слова «maybe». Но к счастью, в английском они там почти все слова из Паскаля взяли, поэтому английский потом я подтянул.
UFO just landed and posted this here

Что-то в минусах исключений что-то странное написано...


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


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

Напротив, именно в этом случае они замечательно подходят.


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

Не вполне понятно какой поддержки они требуют кроме устранения дублирования...


Лучше всего понять, чем плохи исключения, можно, когда используешь чужой, плохо задокументированный код. Есть условный метод GetById, а что он станет делать, если не найдет — ну ты понятия не имеешь. Вернет null? Выбросит какое-то исключение?

На самом деле это проблема null, а не исключений.

Не, ну почему. Вот в java есть checked исключения, и они должны быть в сигнатуре, и должны быть как-то обработаны. Ну то есть, часть минусов они снимают (должны, как бы). Но на самом деле — они создают другие.

P.S. Я надеюсь тут понятно, что речь про «из любого метода может вывалиться любое исключение».

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


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


Напротив, именно в этом случае они замечательно подходят.

Мне кажется, нет. Потому что сам тип исключений из коробки содержит несколько свойств, которые несут информацию об ошибке. Если информация не нужна и её нет, зачем тогда эти свойства?


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


На самом деле это проблема null, а не исключений.
Это проблема нулл, и это проблема исключений. Метод у меня идёт в базу искать юзера, и я понимаю, что он может и не найти. Но у него в документации нет типа исключения, я что, я заворачиваю его в catch(Exception). Метод отрабатывает, у меня отвалилась база, а мой код это не обработает, он обработает сценарий с ненайденным юзером. Плохо.
А штатная ошибка — я хочу точно знать, какие штатные ошибки может отдавать метод, потому что рассчитываю обработать их на месте.

Но ведь в вашем примере для метода GetUser(int id) отсутствие юзера с таким айди это как-раз нештатная ситуация, исключение здесь вполне логично и оправданно. В том числе и для того, кто вызывает GetUser, он ожидает получить юзера и работать с ним, а если юзера нет, то пусть с такой нештатной ситуацией разбирается кто-то выше.
Если же у вас есть какой-то метод, для которого отсутствие юзера нормально и он просто должен по-разному отрабатывать существующего юзера и несуществующего, то имеет смысл использовать либо дополнительный метод вроде IsUserExists(int id), либо один метод GetUserIfExists(int id) который будет возвращать результат либо вашей монадой, либо nullable значением, или еще как.
В общем, выглядит так, будто у вас есть какие-то архитектурные огрехи, если вам надо ловить и обрабатывать исключения для GetUser().

Мне кажется, нет. Потому что сам тип исключений из коробки содержит несколько свойств, которые несут информацию об ошибке. Если информация не нужна и её нет, зачем тогда эти свойства?

О, эта информация обычно очень нужна. Но далеко не везде.
Допустим у вас есть какой-то сервис, предоставляющий некий АПИ другим сервисам. Тогда вам не надо везде по коду ловить исключения, всё, что вам надо, это какой-то мидлвейр, или интерцептор, который уже после всей бизнеслогики будет заниматься этим и если архитектура ваших исключений продумана и разработана правильно, то он будет вполне немногословно и лаконично обрабатывать их и в т.ч. преобразовывать в корректные ответы вашего АПИ. Вам нет нужды везде по коду, где встретиться GetUser() ловить исключения, что юзера нет, как и все исключения на тему, что сеть пропала, или все инстансы SQL сервера упали и негде взять этого юзера и миллион других ситуаций. Они все обрабатываются в одном месте. А код бизнеслогики должен оставаться чистым и читаемым, без единого try/cach (ну в идеале).

Так, является ли не найденный юзер нештатной ситуацией — зависит от контекста. Что касается нейминга — в статье, в вариантах, где мы можем вернуть нулл к имени добавлено OrDefault, где отдаем по out параметру, к имени добавлено Try, где монаду — ничего не добавлено, потому что там сам тип возвращаемого значения говорит о том, что юзера мы можем и не найти. К варианту с исключением можно добавить OrThrow — хуже не будет


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

Ну в общем так и есть, так что я не вижу никаких проблем с исключениями, если их применять именно по назначению. :)

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

Это печально.
Может тогда стоило делать акцент статьи в этом направлении, что исключения только для исключительных ситуаций, а для других есть много других способов.
А то я зашел в статью решив из заголовка, что с исключениями есть какие-то проблемы требующие решения, а на деле статья о другом немного.
Это общая проблема разработки софта под названием «Don't Use Exceptions For Flow Control». От конретного языка не зависит.
Это общая проблема разработки софта под названием «Don't Use Exceptions For Flow Control»

это отлично, только разделение нештатная/штатная ситуация субъективно

Кстати, по поводу нейминга. Понятно, что у каждого есть свои методы, но расскажу о своем подходе, чтоб было понятно, чтож меня зацепило в вашем примере. Для методов, которые возвращают какую-либо сущность по ее идентификатору ситуация когда вдруг сущность не найдена это, как правило, исключительная ситуация ибо идентификатор не берется ниоткуда, это ваш-же какой-то внутренний объект, который тот, кто его использует, до этого от вас же и получил ранее. В подавляющем большинстве случаев. Поэтому для меня естественно было, что метод GetUser(int id) бросает исключение, если вдруг юзер не найден.
Мы на своём проекте договорились следовать конвенции FindUser(int id) — поиск по идентификатору, если не найден — вернуть null; GetUser(int id) — получить юзера по известному идентификатору. И во втором случае уже при неудачной попытке будет выброшено исключение.
Но ведь в вашем примере для метода GetUser(int id) отсутствие юзера с таким айди это как-раз нештатная ситуация, исключение здесь вполне логично и оправданно.

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

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

Это не несогласованность данных. Представьте себе внутреннюю базу компании, из которой только что уволили сотрудника. Сам сотрудник под своим логином продолжает выгребать оттуда данные, а его шеф в это время из другого потока сотрудника удалил (не нужно про статус «inactive», пример синтетический, пусть именно удалил).


Аутентификатор на следующем действии сотрудника пойдет проверять его права и получит обратно «User Not Found». Абсолютно штатно.

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

Я специально попросил обойти тему блокировки вместо удаления: пример синтетический, это может быть не пользователь, а ресурс, свойство, единица товара в корзине. Единицы товара в корзине можно удалять? Да, EventLog / EventSource хранилища примерно всем лучше реляционных БД, но они, к сожалению, не всегда применимы и гораздо сложнее архитектурно.


удаление пользователя прямо во время его работы будет как-раз исключительной ситуацией

Ох уж эти однопоточные программисты. 2020 год на дворе, а люди все еще мыслят синхронными категориями. Вы бы еще мьютекс предложили, чтобы удалить, пока активен, было невозможно.

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

С товаром в корзине все тоже самое. Ок, в одном окне браузера удалил товар из корзины, в другом окне пытаешься удаленному товару количество поменять, какой в этом случае ожидаемый результат будет? Показать ошибку, что товар удален, или в коде дополнительная бизнес-логика, типа чего-нибудь странного вроде товар в корзине не найден, а давайте его опять туда положим? Если первое, то опять-же исключение оправданно, зачем нам обрабатывать по цепочке вызовов эту ситуацию каждым методом, когда можно кинуть исключение и оно в конце обработается. А если вам бизнес-логики отсыпать, то да, делайте как хотите, я не против.

Какое окно? Какой API? При чем тут веб вообще?


Есть некий ресурс (хранилище). Внешний, как вам уже сказали в самом что ни на есть оригинальном комментарии. Вы из этого ресурса пытаетесь получить сущность. Она там — в точности, как Гамлет — может быть, а может и не быть.


Сообщить про то, что ее там нет — не самое главное, в общем случае. Мир не ограничивается перекладыванием джейсончика и общением с пользователем. Да и выше по стеку вызовов никого может и не быть.


Поэтому эта штатная ситуация (запрос отсутствующей сущности) должна быть обработана штатными методами. Но есть, конечно, и горе-разработчики, которые все пробрасывают наверх, и клиенту такого поделия приходится городить стейтфул запросы к якобы «рестфул» сервису, чтобы знать, к чему вообще относится этот «Error: Not found», что приобретает особенный аромат, когда ответы асинхронные (например, в частном случае простого HTTP — 202).


А теперь представьте себе, что HTTP нет, а есть брокер сообщений какой-нибудь. Который из очереди выгребает сообщение с невалидным JWT, например. Неважно, нет ли такого пользователя, или сессия протухла, или крысы кабель перегрызли. Если он бросит исключение, то его будет обрабатывать кто-то, кто понятия не имеет о природе проблемы. SRP сразу идет лесом, а за ним, скорее всего, и DRY. Зато появляется God Object, который знает всё про все возможные проблемы.


Монады Either и Maybe придумали не для того, чтобы щеголять этими терминами на тусовках пейтонистов, жабоскриптовиков и похапешников, а потому что они решают проблему инкапсуляции в случае отсутствия гарантии успеха.

Поэтому эта штатная ситуация (запрос отсутствующей сущности) должна быть обработана штатными методами.

Вы ходите по кругу. С фига ли эта ситуация штатная-то?


А теперь представьте себе, что HTTP нет, а есть брокер сообщений какой-нибудь. Который из очереди выгребает сообщение с невалидным JWT, например.

Мы точно всё ещё метод GetUser(int id) обсуждаем?


Метод GetUser должен бросить исключение. А вот тот метод, который разбирает JWT, должен его перехватить и понять что токен-то невалиден, и именно такой ответ ("ошибка: токен не валиден") должен быть послан в ответ на запрос. Причина невалидности токена никому кроме техподдержки не важна, клиент её всё равно узнает когда попытается получить новый токен.

Вы ходите по кругу. С фига ли эта ситуация штатная-то?

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


Метод GetUser должен бросить исключение.

Обожаю взвешенные, аргументированные, доказуемые утверждения. Жаль, что конкретно это к их числу не относится. Исключения даже семантически намекают, что относятся к исключительным случаям. Протухший токен — случай не исключительный. Предложение выбросить эксепшн и тут же его перехватить — это предложение использовать goto там, где можно обойтись последовательным flow. Даже вернуть null — лучше. Вообще, если исключение ловится и обрабатывается непосредственно в вызывающей функции, без размотки стека — в 102% случаев означает, что исключение выбрано в качестве контроля управления ошибочно.


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


А вот «в базе нет таблицы users», например — ситуация нештатная. Вот тут действительно нужно бросить исключение.

Протухший токен — случай не исключительный

Обожаю взвешенные, аргументированные, доказуемые утверждения.


Предложение выбросить эксепшн и тут же его перехватить

А где вы увидели "тут же"? Вы когда научитесь читать что вам пишут?

А где вы увидели «тут же»?

Метод GetUser должен бросить исключение. А вот тот метод, который разбирает JWT, должен его перехватить [...]

Я-то умею читать, что мне пишут. Пишущие, правда, не всегда сами способны понять, что именно они пишут. Подозреваю, что в коде так же, отсюда и повсеместные эксепшены.


Или между разбором JWT и вызовом GetUser есть еще пять middleware? — Ну тогда это никакими аргументами не вылечить, только лоботомией.

Ну так у вас два метода, один (GetUser) кидает исключение. другой (разбирающий JWT) — перехватывает. Где тут "тут же"? И где тут "без размотки стека"?


Или между разбором JWT и вызовом GetUser есть еще пять middleware?

Да, такой вариант допустим.


Ну тогда это никакими аргументами не вылечить, только лоботомией.

Обожаю взвешенные, аргументированные, доказуемые утверждения.

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

А вот с этим согласен на все 100%
Таймаут — ситуация тоже штатная, кстати. Если бросать эксепшн на каждый таймаут обращения к базе, ничем не обоснованные дорогие прыжки по стеку услужливо помогут положить сервис мизерной DOS-атакой.

Вы еще скажите, что отмена задачи — ситуация штатная, и метод CancellationToken. ThrowIfCancellationRequested следует запретить (в т.ч. в системной библиотеке!)


Ну не настолько исключения дорогие в C# же.

Вы правы. ThrowIfCancellationRequested лучше не использовать, если это возможно, по той же самой причине что и Thread.Abort(). В документации даже есть комментарий тут.

К слову, в своём проекте я действительно не использую исключения. Любой метод возвращает AsyncResult<T>, где статусом выполнения является Success, Canceled, Timeout, Error. А исключения используются там, где им место — для обработки нештатных ситуаций, не обработанных в коде.

По той же причине? Кажется, вы не в курсе почему вообще Thread.Abort объявили устаревшим...

Да от куда мне знать, человек я темный. Я имел ввиду вот этот комментарий:
The Thread.Abort method should be used with caution. Particularly when you call it to abort a thread other than the current thread, you do not know what code has executed or failed to execute when the ThreadAbortException is thrown. You also cannot be certain of the state of your application or any application and user state that it's responsible for preserving. For example, calling Thread.Abort may prevent the execution of static constructors or the release of unmanaged resources.
Используя _ThrowIfCancellationRequested _ вы получите непредсказуемое состояние — какой код у вас отработал, а какой нет? Но это не единственная проблема. При использовании _ThrowIfCancellationRequested _ вы затратите в 100 раз больше времени, чем с _IsCancellationRequested_.

Уточнение: это вы получите непредсказуемое состояние. А я получу предсказуемое, потому что я не забываю про using и finally.

Все так просто? А что если это не OperationCanceledException, а OutOfMemoryException или иное исключение? Вы их будете обрабатывать одинаково? Что вы будете делать с вызовами в библиотеки? Они обработают OperationCanceledException? На сколько корректно они это сделают?

Что-то я совсем перестал вас понимать. Как ThrowIfCancellationRequested может выкинуть OutOfMemoryException? Зачем библиотекам обрабатывать OperationCanceledException, если их задача — его выкинуть, а обрабатывать буду я?

Уточнение: это вы получите непредсказуемое состояние. А я получу предсказуемое, потому что я не забываю про using и finally

Что-то я совсем перестал вас понимать. Как ThrowIfCancellationRequested может выкинуть OutOfMemoryException?

Вы предполагаете поймать в using и try/finally только OperationCanceledException?

Зачем библиотекам обрабатывать OperationCanceledException, если их задача — его выкинуть

Используя _ThrowIfCancellationRequested _ вы получите непредсказуемое состояние

Я предлагаю поймать то, что ловится и обработать если получится. Конкретно OperationCanceledException ловится и успешно обрабатывается.

Ну ок, пусть там будет получение юзера не по айди, а по емейлу, а контекст — форма восстановления пароля.

Что значит — "пусть"? Это уже совершенно другой пример.


Да, я не буду делать методы получения пользователя по id и по email одинаковым образом.

«Пусть» значит, что стоило бы приводить обе эти функции в пример в исходной статье, чтобы избежать всяких непоняток. Тем не менее, я тут подумал — давайте обсудим GetUser(int id) и как её писать. И вот тут есть такие вводные:


  1. Есть такое хорошее мнение, с которым я согласен, что исключения — они для тех ситуаций, которые могли быть предотвращены при кодировании прямыми проверками или более мощной системой типов. Мне это кажется достаточно хорошим критерием для того, когда использовать исключения.
  2. Есть языки вообще без исключений (типа того же идриса).

Как на языках без исключений писать GetUser(int id)? Хотелось бы добавить исключения для этого случая?

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

А я — не согласен.


Как на языках без исключений писать GetUser(int id)?

На языках без исключений надо использовать Either, конечно же. Но на языке с исключениями я бы так делать не стал.


А вот для FindUser(string email) я выберу Maybe (на языке без исключений — Either<IoError, Maybe<User>>)

А теперь представьте себе, что HTTP нет, а есть брокер сообщений какой-нибудь.

А транспорт не важен в данном случае. Вы же не просто сообщения в очередь кидаете, а для того, чтоб кто-то их прочитал. То-есть, посылаете их в виде понятном читателю, или иными словами, предоставляете некое АПИ. И последним бастионом вашего АПИ будет try/catch, ибо вы же не хотите засирать эксепшенами очередь, а если вы работаете с базой, и/или еще чем-нибудь, то помимо not found вам может упасть что угодно, начиная от internal error до даже, прости господи, timeout, и надо их переделать в формат вашего АПИ.
Однако. Если в случае когда искомый объект не найден по айди у вас действительно есть какая-то дополнительная логика помимо сообщения об ошибке, то я бы рекомендовал не использовать GetUser(), ибо он, зараза, эксепшенами плюется, а использовать вместо него что-нибудь типа MaybeGetUser(), GetUserOrDefault(), GetUserIfExists(), или что-нибудь подобное.
Это не несогласованность данных.

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

Разумеется, если какой-то объект был получен одним потоком и собирается его как-то использовать, а в это время другой поток, пока первый собирается, этот объект уже удалил, это не несогласованность данных. Я просто с какого-то перепуга не правильно понял сообщение: «нельзя гарантировать, что данные по указанному id там есть на момент вызова, даже если мы его получили секунду назад из этой же базы» решил, что одна и та-же база создает какой-то объект и возвращает его одному потоку, а через секунду другому говорит, что такого не знаю, хотя в базе он на самом деле есть. Это да, какой-то косяк в голове был, не понял собеседника, напридумывал фигни.

Я к тому, что отсутствие в базе строки с указанным id это не нештатная ситуация, а самая что ни на есть обычная. Либо объект из базы удален только что, либо пользователь неправильно ввел, либо еще что-нибудь, что часто случается при взаимодействии 2 разных систем. Поэтому исключения для них применять нелогично. Null это по определению отсутствие значения, его для этого и придумали. А вот вызывающий код уже может бросить исключение, если ему так хочется.

UFO just landed and posted this here
Она будет нештатная для метода GetUser(int id) ибо название метода подразумевает, что он возвращает юзера, если метод должен возвращать что-то еще, какие-то состояния, то и называться должен по-другому. Например, как сделано в том-же linq: First() — плюет эксепшеном если элемент не найден, FirstOrDefault() — не плюет эксепшенами.
Для метода, которому понадобился юзер, чтоб совершить над ними какие-то действия, в большинстве случаев отсутствие юзера также будет нештатной ситуацией: нет юзера — нет действий, возвращаем ошибку.
И эксепшен в данном случае будет удобнее, ибо обработать его можно непосредственно перед формированием ответа АПИ, а не везде каждый раз перепроверять по цепочке вызовов чтож там такое случилось.

Нет, отсутствие юзера в хранилище данных это штатная ситуация. Потому что хранилище данных это сторонняя система. Так же как и GetUser(string email), нельзя из приложения гарантировать, что в базе есть пользователь с таким email. Ладно id обычно из выпадающщего списка берется, и еще можно обсудить, насколько неправильный id исключительная ситуация, а email вводится вручную.


Название метода подразумевает, что он получает откуда-то полные данные по неполным. Почему отсутствие данных в этом "откуда-то" это исключительная ситуация? Отсутствие данных надо обозначать значением отсутствия данных, это и есть null. Если в C# нельзя выразить значение User|null в сигнатуре метода, значит это проблема недостаточной системы типов в C#. А вот если мы считаем, что пользователь обязательно должен существовать, для этого и надо делать специальные методы, где это отражено в названии — GetExistingUser(), он пусть и кидается исключениями.

Название метода подразумевает, что он получает откуда-то полные данные по неполным. Почему отсутствие данных в этом «откуда-то» это исключительная ситуация?

Вообще, не обязательно. Достаточно популярный «негласный контракт» в библиотеках C# предполагает, что методы а-ля GetSomething(id) предполагают наличие соответствующего объекта, и его отсутствие — ситуация нештатная. Методы FindSomething(id) предполагают, что объекта может не быть, и могут возвращать null.
Это, как по мне, вполне логично, и бесплатно добавляет в приложение ещё один уровень самоконтроля.

Ну так "негласный контракт в C#" это совсем не то же самое, что "это нештатная ситуация, исключение здесь вполне логично и оправданно".

Если контракт функции подразумевает, что она должна возвращать пользователя по его Id, то какой-нибудь UserNotFoundException там вполне логичен и уместен.
Есть принципиальная разница между задачами «найти пользователя по его айди» и «получить атрибуты пользователя по его айди», и для второго случая невалидный айди — вполне себе нештатная ситуация.

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

Контракт это гарантия.

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

Это как раз и есть редкая нештатная ситуация. Мы же не говорим про поиск пользователя, которого можно по входным параметрам найти, а можно не найти. Функция, возвращающая данные пользователя по его Id, по своей логике работы не предполагает вызов с несуществующими Id в штатном режиме.
Контракт — это не гарантия. Контракт — это соглашение об условиях использования, не более и не менее того.

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


значит, неверный Id — это недопустимое значение параметров

Ну так дело в том, что мы не можем определить, верный он или нет. Потому что это зависит от того, что хранится в стороннем сервисе.


Это как раз и есть редкая нештатная ситуация.

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


Если это редкая нештатная ситуация, тогда на месте обрабатывать ее не надо. Надо упасть с записью в лог и отдачей Server error, а например никакой не 404 Not found.


"методы GetSomething(id) предполагают наличие соответствующего объекта, методы FindSomething(id) предполагают, что объекта может не быть, и могут возвращать null" — с таким соглашением я согласен, это удобно. Но это именно соглашение. А изначально по контексту говорилось про некий метод получения объекта из БД, имя которого условно и неважно, а разговор был про его логичное поведение. Логичное поведение метода получения объекта в случае отсутствия объекта — это вернуть признак отстутсвия объекта, а не менять поток выполнения.

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

Да можем, почему нет? Что хранится в базе данных, ответственность не её, но как она на это будет реагировать, целиком и полностью её ответственность.
А изначально по контексту говорилось про некий метод получения объекта из БД, имя которого условно и неважно, а разговор был про его логичное поведение.

Похоже, мы немного по-разному понимаем обсуждаемый вопрос. Как по мне, тема была более общей, есть ли смысл возвращать исключение, если объект не найден? Ну и в качестве примера взяли условный GetUser(Id). Я веду к тому, что это зависит от контракта и контракт, в котором GetЧего-то-там возвращает исключение, вполне уместный.
тема была более общей, есть ли смысл возвращать исключение, если объект не найден?

Ну да, я про то же. Про общие вопросы, а не конкретное соглашение.


Я веду к тому, что это зависит от контракта и контракт, в котором GetЧего-то-там возвращает исключение, вполне уместный.

Но это ваше желание сделать такой контракт, а не какое-то общее правило или более логичное поведение, чем возвращать признак отстутсвия объекта. А в утверждении, с которого началась эта ветка, это показывается как более правильный способ, я на это и возражал.

Но это ваше желание сделать такой контракт, а не какое-то общее правило или более логичное поведение, чем возвращать признак отстутсвия объекта

Да я просто не считаю, что возвращать признак отсутствия объекта — это более логично. По одной простой причине: это всегда обязывает вызывающую сторону предпринимать какие-то завершающие действия. Исключения вам дают выбор: если у вас выход из такой ситуации штатно предусмотрен, вы его делаете. Если не предусмотрен, вы предсказуемо крашитесь. А признак отсутствия выбора вам не даёт. Будьте добры, обрабатывайте всегда, иначе оно отвалится с NullReferenceException.

А тут мы как раз приходим к выразительности системы типов языка. В моем понимании правильно, когда переменная, объявленная как User u, не может быть null.

Ключевое тут всё-таки не NullReferenceException, а сам факт, что вызывающий метод в любом случае обязывается делать некий cleanup, и так — во всех других местах, хотя оно может быть совершенно не нужно.

Зависит от ЯП. Если это язык со статической типизацией, то информацию о семантике можно понять не только по имени функции, то и по типу возвращаемого значения.

Это не про шарп, но все же. Можно использовать Either<Exception, User>, где Either содержит только одно значение. Значение получается так: either.when(left: обрабатываем, right: результат)

Ну это и есть Result, про который говорит автор.
Про то и говорится, что автор зачем то переизобрел Either, приплетя сюда Maybe.
При том, что Maybe — самодостаточная монада и не нуждается ни в каком наследовании, а Either такая же самодостаточная монада, никак не связанная с Maybe.
Я уже молчу, что в контексте C# Maybe должна быть структурой, а не классом.

PS. Случайно минуснул коммент, а отменить нельзя
В нашей кодовой базе Option<> это структура, и мы с неё съезжаем на nullable references например, как раз из-за идиоматичности =)
Если идеоматичность поддерживается во всей кодовой базе — вполне норм.
Но если это библиотека для стороннего пользователя (или общая библиотека для нескольких сервисов) — то nullable reference types не подходит, потому что сторонние пользователи:
— могут забить на идеоматичность.
— библиотека может (и должна, если в ней нет чего то прям такого, что требует netstandard2.1) поддерживать старую версию фреймворка без поддержки nullable reference types.
Вот тут то и нужна Maybe/Optional именно как структура.

Я больше про контекст статьи, изобретать Either поверх Maybe — странное решение, когда все уже давно придумано, и это отдельные, не зависящие друг от друга монады
Про библиотеки для сторонних пользователей я согласен, это основная боль когда что-то делается такое, что нужно отдать «на сторону» (тот же опенсорс) и линковать с нашими внутренними библиотеками нельзя.

Для nullable reference types именно поддержка фреймворка не особо нужна. В netstandard2.0 нет разметки BCL, но это решается multitargeted билдом. У нас таргет для библиотек netstandard2.0, второй таргет netcoreapp3.1, а финальное приложение net472. Итого warnings/errors по поводу nullability мы видим от netcoreapp3.1, но всё что мы собираем отлично запускается под .NET 4.8 =)

Про то что Either и Maybe это разные вещи, я согласен.

Я ничего не изобретал, просто рассказал про концепт. На рынке есть библиотеки и для Maybe/Option, и для Either/Result. И на хабре про них нередко пишут материалы. Моя задача была — концептуально сравнить монадический подход с другими, которые используются в шарпе.

Окей, я вас понял.
Но нужно было явно указать, что:
— есть такая монада Either и объяснить ее назначение.
— не стоило Either делать на основе Maybe.
В целом, как сравнение подходов — статья неплохая, но, как всегда, есть нюанс. Опытные разработчики и так знают про монады, nullable reference types, try pattern и т.д.
А начинающие разработчики, на которых и ориентирована этат статья, узнают про Maybe (что очень хорошо), но не узнают про Either (плохо, но не очень — узнают позже). А еще увидят вашу реализацию с Result поверх Maybe (и начнут использовать в своем коде)- а вот так уже делать не стоит. Монады используют в «чистом» виде. Расширять их extension'ами — пожалуйста, но не наследоваться от них

К сожалению, это не эквивалентные вещи, когда null может быть вполне себе валидным значением.

никак не связанная с Maybe

Maybe<T> – это частный случай Either<E, T>, при котором E – unit-тип. Монадическое поведение у них абсолютно одинаковое.


не нуждается ни в каком наследовании

Наследование – лишь деталь реализации этой штуки на C#, не имеющем (пока) алгебраических типов. Нормальный подход, используется с вариациями в Scala (case-классы) и Kotlin (sealed-классы).

Maybe<T> – это частный случай Either<E, T>, при котором E – unit-тип. Монадическое поведение у них абсолютно одинаковое.

Структурно — да. А вот семантика там несколько отличается.

В случае Maybe, значение None означает отсутствие значения. В случае "стандартной интерпретации" Either, значение Left () означает ошибку без дополнительной информации (ну а вне интерпретаций у Either вообще нет семантики).

None точно так же может означать ошибку без дополнительной информации. Это вопрос конвенций, а не какая-то принципиальная семантическая разница.

В том то и суть, если мы вызываем некий метод
Maybe<User> FindUser(string id)
то метод либо возвратит нам пользователя, либо не возвратит (и только), причем результат выполнения будет предельно однозначным.
Считать это ошибкой или нет — дело того кода, который вызывает этот метод.
Инфраструктурные ошибки, типа отвалившейся БД — тут не в счет, дело только в логике метода
Считать это ошибкой или нет — дело того кода, который вызывает этот метод.
Так я про то же и говорю:
Это вопрос конвенций, а не какая-то принципиальная семантическая разница.
Наверное, мы друг друга недопоняли.
В статье обсуждаются способы прокидывания детализированной ошибки из метода в вызывающий код. Maybe для этого не предназначен, а Either — очень даже.
И учитывать вызывающий код в проблематике статьи — явно не стоит. Потому что можно проигнорировать как Maybe.None, так и Either.Left.
Устные конвенции и договоренности — такая себе штука, рано или поздно кто то ее нарушит, а компилятор такое отлавливать сейчас не может.
компилятор такое отлавливать сейчас не может

В Idris и Agda очень даже может, а в шарпе, джаве и хаскеле не сможет [без костылей] никогда.

Тем не менее, есть forgetful functor из Either в Maybe, который ИМХО и связывает семантику.

Разве что в том смысле, что если мы не смогли получить значение из-за ошибки — то у нас его нет. Но обратное неверно: если у нас нет значения — это не значит что произошла какая-то ошибка!

(Either ()) так вообще естественно изоморфен Maybe в Hask. Но кого это волнует. Математика ведь не нужна… :-).
Впрочем, понесло меня куда-то не туда. Естественное преобразование, по ходу, не про это. Извините.

Давайте ещё раз: TimurNes заявляет, что Maybe и Either никак не связаны, я же пытаюсь показать эту (имхо, очевидную) связь.

> Когда метод потенциально выплевывает пять шесть типов исключений, код превращается в нечитаемое говно — и код метода, и код использования.

Наследуем эти 5-6 типов исключений от одного базового типа. Кому надо — обрабатывает нужные типы по разному. Кому не надо — обрабатывает один базовый тип.
Основная проблема с исключениями в том, что сигнатура метода класса (или ещё хуже — метода интерфейса) ничего не сообщает о том, какие вообще исключения могут быть. Документация этого не решает, плюс если это всё-таки интерфейс, то каждая новая его реализация потенциально эродирует контракт добавлением новых, ранее неизвестных «науке» исключений.
Исключения действительно хорошо подходят только для неожиданных, исключительных результатов исполнения. Эти исключения обычно нет никакого смысла обрабатывать, за исключением catch/log/rethrow, но во-первых это всё-таки cross-cutting concern, а если даже не получается, то всё равно влияния на поток управления никакого.
UFO just landed and posted this here

Потому что даже на jvm они не прижились и в альтернативах Java вроде Kotlin и Scala их нет. Причина проста — слишком часто исключение НА САМОМ ДЕЛЕ ловить не нужно. Потому что оно гарантированно не случится (например, парсинг константы), либо если оно случится, то логика программы не подразумевает ничего, кроме как вывести красивое сообщение об ошибке в uncaught exception handler. Условно, если у вас в вебприложении отвалился коннект к бд, то вам обычно остаётся только развести руками и выплюнуть 500 ошибку. С этим отлично справляется try… catch (Throwable) где-то в дебрях фреймворка. Ситуаций, когда исключение требует особой обработки не очень много и они заранее известны. Ещё checked исключения несовместимы с функциональным стилем программирования, который сейчас очень популярен — сигнатуры лябмд для обратных вызовов не содержат исключений (иначе их придётся добавлять во все map, reduce, filter и т. д. и это заразит весь код). Как следствие всего этого, большинство исключений либо оборачиваются в RuntimeException, либо просто подавляются. Первое путает код (потому что теперь нельзя так просто понять, что же случилось в обработчике верхнего уровня), второе часто гораздо опаснее непойманного исключения, если применено не к делу. И параллельно с этим код распухает и теряет читабельность (что также повышает вероятность ошибок).


Таким образом несмотря на красивую теорию, на практике checked exception скорее приведут к увеличению количества ошибок, чем к их уменьшению.

UFO just landed and posted this here

В случае с функциями обратного вызова для функциональных примитивов вроде map, filter, reduce и т. д. добавить сигнатуру в метод нельзя.


Ну и как я уже сказал, проблема в том, что необходимость обработки исключения очень контекстнозависима. Взять даже ошибку парсинга числа. Если это пользовательский ввод, то, вероятно, мы хотим это обработать особо. Если это конфиг приложения, то мы скорее всего хотим фатально упасть, потому что больше делать нечего. Если это парсинг константы (например, для BigInteger), то мы уверены, что не упадём. В случае с тем же findUserById нам совершенно нет нужды ловить ошибки в SQL, например. Если они будут, это критический баг в программе и ничего сделать программно уже нельзя.


По сути дела всё зависит от того, есть ли в ТЗ указания, что делать при какой ошибке. А тз у каждого проекта своё

UFO just landed and posted this here

Так на что uncaught exception handler (либо try… Catch на корневой класс исключений где-то высоко в коде)? Как раз в общем виде залоггировать исключение и нарисовать красивую ошибку пользователю, если мы всё равно не знаем, что с этим делать.

UFO just landed and posted this here
То есть если вы например работатете с async/await или с COM'om/unmanaged code.

Для асинков есть TaskScheduler.UnobservedTaskException, в случае с COM/unmanaged там что угодно может быть, это правда. но там ни один из подходов не будет достаточно хорош
UFO just landed and posted this here
Но на мой взгляд многое было бы проще если в С# добавят пару фич для работы с исключениями. Даже если их добавят как опциональные.

Всё в ваших руках github.com/dotnet/csharplang
UFO just landed and posted this here
В случае с функциями обратного вызова для функциональных примитивов вроде map, filter, reduce и т. д. добавить сигнатуру в метод нельзя.

Так это проблема конкретно жавы, а не checked exceptions как таковых.

Если бы checked exceptions реально попадали бы в сигнатуру и обрабатывались бы дженериками, я бы тоже за них ратовал:


IEnumerable<U> Select<T, U, E>(this IEnumerable<T> source, FallibleFunc<T, U, E> selector) throws E {...}

Но в итоге это практически эквивалентно предлагаемому в статье решению, мы даже можем оставить обычную сигнатуру LINQ:


IEnumerable<U> Select<T, U>(this IEnumerable<T> source, Func<T, U> selector) {...}

Просто принимаем тип U за Result<RealU, E>.

Неа, не эквивалентно. Потому что первый Select возвращает "эквивалент" Result<IEnumerable<U>, E>, а второй IEnumerable<Result<RealU, E>>.


Правда, в реальности у вас throws E будет не у Select, а у IEnumerable<U> (ленивость же!), так что сигнатуры станут более похожими — но поведение всё равно останется разным: первый вариант останавливается при первом же исключении, в то время как второй всегда проходит до конца.

Потому что оно гарантированно не случится (например, парсинг константы)

Подход с Maybe/Either для парсинга тоже заставит обработать негативный результат (если метод parse возвращает Maybe), так что разницы не будет.


checked исключения несовместимы с функциональным стилем программирования

Можно попробовать прикрутить дженерики для исключений вместо оборачивания в RuntimeException и пробрасывать их в дженерик-виде во все места, которые их вызывают.
Типа:


public <E extends Exception> void doSomething(String s) throws E {
  ...
}

В дополнение хочется вариабельных списков (как в темплейтах в C++), чтобы можно было перечислять их, если нужно несколько исключений, или указывать список нулевой длины, когда они не нужны, но такого в джаве нет. Тогда можно было бы починить имеющиеся map/reduce/filter/etc.

Достаточно было бы добавить автовывод типов исключений. Сделали же автовывод типов переменных, не сломались.

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

Думаю такие, которые возможны в любом методе, можно и опустить.

К слову, достаточно ведь очевидная штука. Не могу понять, почему так не сделано

Извините, но это всё не так.


Checked exceptions прекрасно себя чувствуют в ФП, и монада Either — это такая каноническая реализация checked exceptions. Да, map/filter/reducefold имеют несовместимую сигнатуру, но совместимую сигнатуру имеют монадические версии этих функций.


Основная проблема с checked exceptions в джаве (на мой взгляд, конечно) — отсутствие полиморфизма по ним.

Идея простая. Есть отдельная сборка, в ней лежит абстрактный класс Maybe, у него два наследника, Success и Failure. Отдельная сборка и интёрнал конструктор нужны, чтобы гарантировать — наследников всегда будет только два.

А теперь возвращает вместо Maybe "горячо любимый" null, и...

UFO just landed and posted this here
А можно вспомнить опыт Golang и возвращать 2 значения, как именно — зависит от языка, но можно…
1 значение — success flow, 2 значение — Error

Бонусом: упростятся программы, исключения из недр фреймворков весом в тонну не будут летать десятки исключений и программа не будет обрастать списками отлавливаний
public (User user, Error error) GetUserById(int id)
{
    //...
    return (user, error);
}

Но кажется это лишь в некоторых случаях действительно органично подходит.

Нет уж, спасибо, if err != nil { return nil, err } — это то, что я бы не хотел видеть в коде вообще, ни в своём, ни в чужом.

да, посчитал строки в нескольких больших проектах с github.com/trending/go
получилось, что эта конструкция повторяется раз в 100-300 строк. ещё примерно так же часто ошибка декорируется.
через год обещают выкатить генерики в го, увидим, станет ли народ заворачивать ошибки в монады.
через год обещают выкатить генерики в го, увидим, станет ли народ заворачивать ошибки в монады.

Пытаться будет, но смысла нет без сумм-типов, а их введут неизвестно когда.

Технически это описанный в статье try pattern, только значение возвращается из функции как обычно вместо ref-параметра. Но вообще возвращать из функции значение И ошибку — костыль за невозможностью вернуть значение ИЛИ ошибку.
Про if err != nil (и возможность про err вообще забыть или забить) тут уже сказали.

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

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

В случае с монадами так не получится. Если взялся их использовать в одном месте, то дальше приходится распространять их по всему коду, даже в тех местах, где обработка ошибок не предполагается. Мне такой подход именно по этой причине не зашел.

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

С исключениями проблема в том, что зачастую их используют для сигнала об ожидаемом ошибочном поведении. Я скажу вероятно кощунственную вещь, но выбрасывать FileNotFoundException при отсутствии файла, который мы пытаемся открыть это ужасный дизайн. Особенно если потом там есть ещё DirectoryNotFoundException, PathTooLongException и т.д.
И всё это вместо Result<Stream, FileOpenError> Open(string fileName).
Тут конечно же есть вопрос удобства/идиоматичности работы с Result<TOk,TErr>, но тут придётся идти на компромисс, начиная от Unwrap, который-таки выбросит исключение (который можно замаскировать под explicit или даже implicit conversion operator к TOk), через map-методы типа And/Or и заканчивая реализацией через pattern matching (но тут value-типом не обойтись, придётся или боксить или на хипе выделять, это грустно если производительность не на последнем месте).

Зависит от контекста. Вполне может быть, что выполнение программы без файла лишено смысла. Например, это какой-нибудь важный конфиг или входные данные. И наоборот, если отсутствие файла является штатным, то ничего не мешает проверить его наличие перед открытием. Это даже более логично. Ведь проверка существования не равна открытию и может быть использована даже когда файл не нужно на самом деле читать или писать.

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

Файл не только может быть удалён между проверкой и открытием, как написали выше, но еще и может быть недоступен по правам, заблокирован другим процессом, и так далее.
Конечный итог один — во многих случаях мы можем понять, что что-то не так, только когда попытаемся выполнить операцию.
И тогда встаёт вопрос: а нужно ли городить отдельную методологию для обработки «ожидаемых» ошибок, когда есть неожиданные?
Ну собственно если в коде проверяется, что файл есть и доступен, а потом в момент открытия это неожиданно стало не так, то вот и ИСКЛЮЧИТЕЛЬНАЯ ситуация. Зачем ее обрабатывать как-то особенно, всё равно ничего не сделать, кроме как попытаться опять, и потом уже кидать ошибку наверх. Можно сразу кидать ошибку, пусть наверху разбираются с повтором.

Проблема тут в том, что знание о том, какие именно проверки необходимы и достаточны, инкапсулированы в функции, открывающей файл и ожидающий определённого состояния этого файла, но этой информации нет в сигнатуре этой функции. Поэтому чтобы написать проверки до вызова этой функции, нужно вначале как-то узнать, какие именно проверки нужны, чтобы не упустить ни одной. А стремлении сделать все проверки заранее приходим к эдаким pre-checked exceptions: вместо полного покрытия post-conditions — полное покрытие pre-conditions. Вот только теперь компилятор никак за этим не следит, и асинхронность реального мира всё равно требует проверять на исключительные ситуации, потому что заглянуть в будущее принципиально невозможно. Ну и happy path теперь обвешан проверками с обоих сторон — и перед, и после вызова.

Но ведь это же действительно исключительные ситуации, если вы пытаетесь открыть на чтение несуществующий файл. Зачем городить огород с Result<TOk,TErr>, когда достаточно перед открытием файла проверить, что он действительно существует? Будет сделано примерно то-же самое, что пытаться открыть, а потом уже проверять, открылось, или нет, но код будет более читаемый, в таком случае.

Проверка на наличие файла может быть полезна сама по себе, но разделение операции на две независимых функции "проверить" и "сделать" вроде логично, но приводит к тому, что ответственность за правильное использование ложиться на программиста (можно проигнорировать проверку или допустить ошибку). А подход Result позволяет в некотором роде на уровне типов выразить, что мы имеем корректное состояние. Проверка, по-сути, представляет собой отображение из "некоторое непонятно состояние" в "точно валидное состояние", выраженная в типах.


З.Ы. Вышесказанное имеет смысл, если в языке нет исключений и null. В противном случае, гарантировать, что разработчик того или иного модуля поступил грамотно, нельзя.

достаточно перед открытием файла проверить, что он действительно существует

Если проверили файл перед открытием, а потом между проверкой и самим открытием случилось удаление файла, то будут проблемы)
В этом случае нужно именно атомарное открытие с одновременной проверкой.

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

FileNotFoundException,… DirectoryNotFoundException, PathTooLongException и т.д.
И всё это вместо Result<Stream, FileOpenError> Open(string fileName).

Так все те эксепшены — это и есть ваш FileOpenError (или IOException, как это на самом деле назвали). Семантически одно и то же, просто синтаксически выглядит по-другому.

C#:


var file = File.Open("config.json");

Rust (в котором исключений нет):


let mut file = File::open("config.json")?;

Лично я за вычетом отдельного символа для проброса ошибки разницы не вижу.

А она там есть :) В Расте паники работают по тому же принципу, что и исключения, но они то точно используются для совсем уж плохих ситуаций.
А тут это просто монадический подход с сахаром, делающий его похожим на исключения, но этот подход производительнее и обладает плюсами от checked exceptions.

Ну это же будет работать только на одном уровне. То есть он через 5 вызовов это не прокинет, если по стеку выше нет аналогичного заворота в Error<> и такого же типа.

Довольно легко писать код таким образом, чтобы не требовать конкретный тип.

Исключения хороши тем, что а) их невозможно случайно проигнорировать

Наоборот же, их легко проигнорировать. Или что значит «случайно проигнорировать»?

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

Ну в таком ключе — да. Но таким же свойством и монадический подход обладает (придется делать unwrap чтобы достать результат без проверки)

Не совсем. Во-первых, если результата нет — вызов unwrap должен кинуть исключение (либо панику), то есть задача сводится обратно к исключениям. Во-вторых, если результат программисту не нужен — он может его ненамеренно проигнорировать вместе с признаком ошибки, что тоже нехорошо.


Полностью этим свойством обладает полноценный и ленивый монадический подход. Полноценный — это без всяких unwrap, а с операцией bind. Ленивый — чтобы без проверки результата операция даже не запускалась.

Хм. А где собственно решение проблемы? Все эти способы известны и применяются.
Что-то мне это напоминает, эта «наивысшая надёжность»… Подумал и вспомнил.
enum Result<T, E> {
    Ok(T),
    Err(E),
}

Это же Rust'овский метод работы с возвращаемыми значениями!
да, и именно про такие перечисления упоминает автор.
Это же Rust'овский метод работы с возвращаемыми значениями!

Вот только подобные методы работы с ошибками были ещё в Standard ML в аж 1984 году.

Можно имитировать АДТ без наследования, используя, например, методы типа And(Func). Но в целом nullable reference types более идиоматичны чем Maybe<>. Замены Either<>(Result) идиоматичной нет и скорее всего не будет, пока не будет АДТ. Tuple с двумя nullable references это беспомощно, потому что у него не два состояния, а 4, то есть правило про irrepresentable illegal state не работает.
С другой стороны, подход Марка Зимана с универсальными абстракциями показывает, что если немного отвлечься от идиоматичности, можно очень неплохо приобрести в композируемости, легче соблюдать open/closed principle.
Мой вариант.

public class BaseResultTwo<T> where T : class
{
    public BaseResultTwo()
    {
        IsSuccessful = true;
    }

    public bool IsSuccessful { get; set; }

    public string Message { get; set; }

    public T Result { get; set; }
}
public T Result { get; set; }

Ну вот зачем вы так? Теперь результат можно достать, полностью проигнорировав флаг IsSuccesful.

Как надо на C# не сделать, потому что у него для этого средств нет в системе типов.

…но можно приблизиться вот так:


private T result;
public T Result 
{
    get => IsSuccessful ? result : throw new InvalidOperationException();
    set // если вообще нужен
    {
        IsSuccessful = true;
        result = value;
        message = null;
    }
}
InvalidOperationException… который тоже чем-то ловить? :)))
Забавная идея…

Его не надо ловить. Это фатальная ошибка в коде.

Его не надо ловить. Это фатальная ошибка в коде.

Фатальная ошибка в коде зависящая от IsSuccessful ? result : throw new InvalidOperationException();?
Мы пишем обертку, которая должна принудить программиста проверить IsSuccessful перед тем как прочитать результат?
И у нас нигде в коде не будет ошибок вроде:
1. Упало при первом запуске, забыли проверить IsSuccessful
2. Упало — забыли указать IsSuccessful\ забыли передать значение\по умолчанию объект вернули.
При этом ничего не ловим, не логируем и просто даем ОС закрыть процесс?
Или мы с эти как-то планируем бороться?
Мы пишем обертку, которая должна принудить программиста проверить IsSuccessful перед тем как прочитать результат?

Да. Но в C# нет языковых средств, которые бы это могли обеспечить на этапе компиляции.


При этом ничего не ловим, не логируем и просто даем ОС закрыть процесс?

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


Можно поймать исключение на самом верхнем уровне. Можно вообще не ловить — тогда оно окажется в Event Log.

Во-первых, как уже написали выше, это уже фатальная ошибка.
Во-вторых, можно добавить методы Map/Select, Bind/SelectMany, Catch/OrElse...


Но в целом именно по этой причине выше и написано "для этого средств нет в системе типов"

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


Предпочитаю Maybe/Either-подобные подходы. Они получаются громоздкими в языках, не имеющих для этого специальных средств. Например, если иметь набор методов в стиле map, bind (а также ряд других, для сахара, такие unwrap_or_default и многие другие), то это резко снижает громоздкость кода, он становится зачастую даже более лаконичным, чем с try-catch.

Готовить их на самом деле просто. Есть нехитрое правило: исключения, про которые вы не в курсе, доверьте дефолтному обработчику. Если вы знаете, что метод N в каких-то случаях бросает исключение ESomething, и его обработка влияет на логику вызывающего метода, то вы его обрабатываете. Если вы не знаете про его существование, то не обрабатываете.
UFO just landed and posted this here

А разве если у вас есть "некритичный процесс" он не должен использоваться так чтобы не останавливать всю систему?
Он ведь может и outofmemory какой-нибудь выбросить....

UFO just landed and posted this here
Ну, тут под игнорированием явно подразумевалось отсутствие специальной обработки. Заворачиваете некритичный процесс в try catch Exception, складываете ошибки в лог и забываете.
Либо если это прям процесс, ловите необработанные исключения процесса и делаете всё то же.

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

В прикладном коде — можете. В инфраструктурном — да, иногда таки надо catch(Exception) написать.

Но если вы не знаете, что там может быть за исключение, и как восстанавливать работу в случае его возникновения, то откуда вы знаете, что процесс некритичный, а исключение глупое? Если этой информации нет в контракте метода, то полагаться на «авось» — тоже стрёмная стратегия.
UFO just landed and posted this here
Минуточку, но если вы знаете, что вот этот процесс некритичный и исключения от него нужно игнорировать, то вы знаете контракт.
UFO just landed and posted this here
Могу я никак не обрабатывать исключения которые в теории может кинуть эта библиотека?

«Никак не обрабатывать» — это значит, увалить приложение. Если вам это надо, можете.
Или мне как минимум надо завернуть её в общий try catch?

А если вам это надо, тоже можете. Я не понял, честно говоря, что вы хотите тут спросить. Я всего лишь написал, что не ловите исключения, если вы не знаете, как с ними поступать. Передавайте их в дефолтный или вышестоящий обработчик и падайте, или восстанавливайтесь в вышестоящем, если он там умеет, например, перезапускать весь аварийный процесс. А если знаете, как в вашем примере, так ловите себе на здоровье.
UFO just landed and posted this here

И всё таки, что делать с outofmemory которое может быть у кода который сам никаких исключений не выбрасывает (если рассматривать в контексте .net/c#)?

UFO just landed and posted this here

Т.е. по факту проблемы нет, мы обрабатываем все "знакомые" исключение а всё остальное (т.к. сервис "некритичных" падает в лог через catch (Excepton)

UFO just landed and posted this here

Есть ожидаемые ошибочные ситуации. Например, некорректный ввод от юзера. Всё остальное — нештатные ситуации. И это уже не ваша проблема. Упала БД — тухлые помидоры летят в девопса. Случился outofmemory — опять же виноват он. Ну и, как известно, быстро поднятое упавшим не считается.


Если вы не догадываетесь об исключении, скорее всего всё равно не существует нормального способа его обработки. Или у вас в каждом приложении есть обработка ситуаций вроде "юзер удалил половину файлов приложения"?

Если вы не догадываетесь об исключении, скорее всего всё равно не существует нормального способа его обработки

Ну так я именно про это и пишу. Если вы не знаете, как корректно обработать возникшую проблему, сообщайте, логируйте, краштесь. Это в общем случае правильнее, чем пытаться продолжать работу, не понимая, что там произошло.
Если процесс не критичный то вы заворачиваете его в Try/Catch на самом высоком уровне кол-стека и логируете ошибки
И при этом я не знаю, какие исключения надо обрабатывать в том или ином случае, это никак не отражено в сигнатуре, это приходится отдельно указывать/читать в документации.

Я считаю, это ошибка создателей C#. Они наслушались жалоб про то, какие эксепшены неудобные и как их надо постоянно обрабатывать в Java, и сделали unchecked вариант, где обработчики не форсируются компилятором. В итоге получили, что всё пропало из сигнатур.
Стандартный вариант в Java (checked exception) аналогичен использованию maybe/either — при использовании в коде всегда нужно описывать как минимум два пути: один для положительного результата, другой для ошибки.

По-мне так наоборот смысл checked exceptions какой-то мутный совсем. Исключения на то и исключения, что мы их не ожидаем, а если метод возвращает какие-то ожидаемые, пусть и ошибочные состояния, то так и надо вводить для них соответствующие сущности, а не обзывать их исключениями.
так и надо вводить для них соответствующие сущности

Эти сущности и есть исключения)
Checked exceptions — это ожидаемые ошибочные состояния. Если мы читаем из сети, то мы ожидаем или ошибку, или ответ. Если мы читаем из файла, то мы ожидаем или ошибку, или данные.
Unchecked exceptions — неожидаемые. Типа, закончилась оперативная память и не получилось аллоцировать объект. Или случился assertion, который ну никак не должен был случиться, но из-за бага кто-то его допустил. Или деление на 0 (которое по-хорошему надо делать checked exception, или оформлять в виде отдельной сущности — Either<Integer, DivisionByZeroError> result = 10 / 0;, — но это сильно замусорит код).

Unchecked exceptions — неожидаемые. Типа, закончилась оперативная память

На самом деле в джаве это Error, который вообще в другой ветке наследования, чем RuntimeException.

В джаве сложно с ветками наследования)
Есть базовый Throwable, у него подклассы Error и Exception. При этом Throwable — checked, а Error — unchecked.
Потом идёт Exception, у него подкласс RuntimeException. При этом Exception — checked, а RuntimeException — unchecked.
Сделали бы две базовые ветки (как вариант, просто переименовать RuntimeException в RuntimeError и запихнуть в Error) и было бы норм.

Проблема checked exception в том, что не совсем понятно их назначение.


Если они нужны для того, чтобы декларировать какие вообще исключения могут выбрасываться методом — то их будут сотни в любом методе, пользоваться этим будет невозможно (даже с автоматическим выводом).


Если их использовать для того, чтобы декларировать, какие исключения может выбрасывать конкретный метод — тоже сомнительная полезность. Почему одни исключения мы декларируем, а другие игнорируем? Разделяем опять же исключения на "бизнес-логику" и "ошибки платформы" — а почему они разные, они же не всегда должны по-разному обрабатываться?


Как быть с интерфейсами? Checked Exceptions — это свойство интерфейса или реализации?


Много вопросов, и мало пользы. Они реально нужны только в том случае, если исключениями описывается бизнес-логика, но для этого не стоит использовать исключения.

UFO just landed and posted this here

А как потом происходит их обработка? Например, через несколько уровней?

UFO just landed and posted this here

Если мне не надо дифференцировать, у меня есть Exception

UFO just landed and posted this here
А если вам надо дифференцировать какое-то относительно небольшое конечное количество случаев?

То значит вы используете исключения для бизнес логики (ну либо это такой АПИ у библиотечного кода, как в случае с System.IO.*), и лучше бы их заменить на что-то другое (но в шарпе нет хороших альтернатив)


Просто речь о том что проблема с сотнями декларируемых исключений если и бывает, то очень-очень редко и обычно легко решается.

По-хорошему, количество возможных исключений для метода — это произведение количества всех возможных исключений для всех АПИ, которые вызываются методом + количество исключений, которые выбрасываются непосредственно. То есть оно достаточно большое.


Но поинт не в том. Если для нормального использования АПИ вам нужно знать типы исключений, которые в нем выбрасываются, значит (скорее всего), АПИ выбрасывает эти исключения в качестве некоего аналога возвращаемого значения (а не в случае исключительной ситуации). И это неправильно.

UFO just landed and posted this here
Если они нужны для того, чтобы декларировать какие вообще исключения могут выбрасываться методом — то их будут сотни в любом методе, пользоваться этим будет невозможно (даже с автоматическим выводом).

То же самое можно будет сказать про Either/Maybe из поста.
Допустим, у нас есть OneOf, который работает как Either с несколькими типами. И тогда у вас будут что сотни эксепшенов, что сотни видов результатов.


OneOf<GoodResult, OpenError, ReadError, FindError> result = getResult();
if (result.first()) {
  // good
} else if (result.second()) {
  // open error
} else if (result.third()) {
  // read error
} else if (result.fourth() {
  // find error
}

эквивалентно:


try {
  GoodResult result = getResult();
  // good
} catch (OpenError) {
  // open error
} catch (ReadError) {
  // read error
} catch (FindError) {
  // read error
}

На это вы можете возразить, что мы не хотим использовать OneOf<GoodResult, OpenError, ReadError>, а хотим использовать Either<GoodResult, BadResult> из только двух состояний.
В этом случае и с эксепшенами можно писать:


try {
  GoodResult result = getResult();
  // good
} catch (BadResult) {
  // some error
}
На это вы можете возразить, что мы не хотим использовать OneOf<GoodResult, OpenError, ReadError>, а хотим использовать Either<GoodResult, BadResult> из только двух состояний.
В этом случае и с эксепшенами можно писать:

Ну так BadResult точно так же может быть типом-суммой. И на уровне типов гарантируется, что все варианты будут обработаны.

Как быть с интерфейсами? Checked Exceptions — это свойство интерфейса или реализации?

Свойство интерфейса.
Как вы пишете


interface Foo {
  Either<Result, Error> getResult();
}

так можно и писать


interface Foo {
  Result getResult() throws Error;
}
А зачем для ненайденного пользователя вообще исключение?
Исключения, всё же, для более исключительных ситуаций, типа связь с сервером отвалилась, место кончилось.
А использовать их для передачи данных (факта отсутствия данного пользователя) — так себе идея.

Обычно у подобных функций есть две версии — одна возвращает какой-нибудь optional, а другая бросает исключение. Первую вызывают, когда в ТЗ бизнес логики есть слова "а если пользователь не найден, то делаем Х", во втором случае, когда такого нет, потому что эта ситуация сигнализирует о каком-то критическом сбое, когда останется только нарисовать красивое сообщение о фатальной ошибке, но с этим отлично справляется обработчик верхнего уровня.

А что тогда должен вернуть метод? Какой нить константный DefaultUser тоже так себе идея минимум в 30..40% случаев по моему опыту. За ним потянется цепочка всяких DefaultUserBlaBlaPropertiesRelationships и так далее. Нет серебрянной пули тут. Но на круг, ексепшен выходит лучше всего. Видишь сигнатуру NotFoundException, IllegialArgumentException и сразу понимаешь в каких случаях тебя пошлют в пеший эротический тур. Во всяком случае null уже не ждешь.

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

я понимаю, что пример выдуманный, но раз уж на нём обсуждаем… )
По сути тут одна проблема описана — лень обрабатывать какой-то «неожиданный» результат.
Какая разница, как эта «детская неожиданность» проявится, если «оно» уже попало в «вентилятор»? Приложение или идет по Success Flow или не идет по Success Flow и принимаются какие-то «аварийные меры».
Принципиальной разницы в обработке Exception или return resultCode — вообще никакой. в любом случае придется писать «простынку» кода, который будет разгребать «а что случилось ?!»
Есть два эмпирических правила:
1. пиши код\библиотеку так, как будто ей будет пользоваться «слюнявый идиот». Поэтому ты обязан сделать так, чтобы ошибку нельзя было игнорировать.
2. используй чужой код так, как будто его писал «слюнявый идиот». Поэтому код должен быть написан так, чтобы знать об ошибке.
Иногда, обнаруживается что этот «слюнявый идиот» — это ты сам и есть, с разницей в пару недель… месяцев.
Поэтому Exception «всплывает» на уровень среды исполнения приложения, как последний шанс разобраться, перед тем как ОС убъет приложение принудительно.
Формализованно, если мне не изменяет память, это описано в каком-то из стандартов ISO 2700x (не помню в каком именно).
Принципиальной разницы в обработке Exception или return resultCode

Исключение (также как return code и out-параметр) можно проигнорировать. Maybe/Either игнорировать не получится из-за несовпадения типов. Поэтому совсем уж проигнорировать ошибку не выйдет, пользователь кода увидит ошибку компиляции. Даже если везде вставлять условный unwrap(), это все равно более заметно, чем игнорирование ошибок. Можно даже линтер настроить какой-нибудь, чтобы ругался.

Исключение (также как return code и out-параметр) можно проигнорировать.

Исключение можно, но до первого обработчика — своего «адекватного», а для этого разработчику нужно спецально приложить усилия. Изначально среда исполнения заставляет делать «правильно», иначе просто убивает такой процесс.
return code и out-параметр

Тут как «дизайн» сделан будет. Если криво — сам себе злобный буратино. Например WinAPI так сделан — все «оборачивается» и на уровне рефлекса пишется обертка и обработка вызова таких функций.
Maybe/Either

Если я верно прочитал описания к Maybe/Either для Haskell — это такой же «синтаксический сахар» позволяющий пихать что угодно куда угодно и возвращать «по умолчанию» если «не получилось». По сути проглатывание ошибок. Но опять же, нужно ручками указать «что я сам себе злобный буратино» и хочу проигнорить ошибку.
А что будете делать для «не верный пароль», «нет памяти», «Timeout» и т.д.? Стратегии обработки ошибок разные для разных исключений.
По сути проглатывание ошибок.

Почему? Например, условный Maybe<T> не может быть применен там, где ожидается T.


А что будете делать для «не верный пароль», «нет памяти», «Timeout» и т.д.?

Either/Result хранят значением ошибки, если "не получилось". Которое может быть типом-суммой, по которому осуществляется match. К примеру.

Исключение (также как return code и out-параметр) можно проигнорировать. Maybe/Either игнорировать не получится из-за несовпадения типов.
Что вам помешает игнорировать несовпадение типа? TypeCastException?
приведите тогда код, я не понимаю о каком несовпадении типов речь. Если взять пример из статьи, то можно Maybe привести к Success и никакой ошибки компиляции не будет.

Простейший пример. Но я не затрагиваю проблему реализации самих Maybe/Either на C#, я давно не писал на C#.


// Сигнатуры
Either<Foo, Error>  getFoo(); 
Either<Bar, Error> processFoo(Foo foo);

var foo = getFoo();
var bar = processFoo(foo); // Ошибка: Either<Foo, Error>, expected Foo.
// Нужна обработка:
var bar = foo.bind(f => processFoo(f));
ну вот в реализации как раз таки и соль.

Либо я что-то не понимаю, либо безболезненно превратить C<T> в T нельзя в любом случае.

В реализации из статьи можно сделать так:
var user = ((Success<User>)service.GetUser()).Value;

Автор попытался имитировать тип-сумму через наследование. Я как-то делал Option, Result на Python, и сделал одним классом. Идея примерно такая (просите, я мог забыть C#):


class Option<T>
{
    private T _value;
    private bool _is_some;
    private Result() {}
    public static Result Some(value T)
    {
         var res = Result();
         res.v_value = value;
         res._is_some = true;
         return res;
     }
     static Option Nothing()
     {
          var res = Option();
          res._is_just = false;
          return res;
     }
     bool IsSome() => this._is_some;
     bool IsNothing() => this._is_nothing;
     T unwrap()
     {
          if (this._is_some) return this._value;
          raise NothingOptionUnwrapException();
     }
     // map, bind, unwrap_or_default и так далее

}

Состояние инкапсулировано в объекте и никакими кастами его не изменить. Попытка unwrap'а кидает эксепшн. Ну да, щито поделать. В Rust попытка unwrap'а пустого Option вызовет панику. Правда, у автора, вроде, был еще паттерн-матчинг, а тут не получится, вроде.

но тут у вас тоже есть исключение :)

В Rust есть panic, сделал аналог. Можете не делать unwrap, а оставить только другие методы. Так даже более правильно.

var bar = processFoo(foo); // Ошибка: Either<Foo, Error>, expected Foo.

Это ошибка времени исполнения или ошибка времени компиляции? Это два разных «уровня»
Если это ошибка времени компиляции, то это ошибка уровня дизайна решения.
Если это ошибка времени исполнения, то код
// Ошибка: Either<Foo, Error>, expected Foo.
// Нужна обработка:

это то же «велосипед» фильтра ошибок. Те же яйца только в профиль.
Определитесь со стратегией обработки ошибок на этапе дизайна решения.
Либо вы хотите их обрабатывать либо нет. В чем проблема?
Вы не знаете какие ошибки генерит библиотека стороння? Это не так. Вы их знаете, поскольку это будут такие же классы публичные xxxException с каким-то метаописанием, если это специальные классы если не хватило стандартных. Вы же не будете изобретать свой PuperOutOfRangeException делающий тоже самое, что и стандартный?
Сторонний код может сгенерировать не только «свои ошибки», но и среды исполненения (переполнение стека, деление на ноль, выход за переделы индекса и т.д.) Любая ошибка может быть в любое время и в любом месте, а не только те что «мне удобно» и «я тут ожидаю».
В любом месте кода у вас два состояния — или я знаю что могу сделать для исправления ошибки, или я не знаю, и передаю «проблему вышестоящему».
Но в любом случае должно быть: минимизация ущерба для клиента и его данных, вернуться в состояние «как было ДО...» и максимум информации, что бы как можно быстрее понять «что случилось» для максимально дешевого «исправления».
P.S.
Есть еще третье — а мне пофигу и у меня все хорошо. Но за такое, обычно, заставляют заниматься отладкой релизной версии без отладочной информации консольным отладчиком в машинных кодах, что бы не повадно было такую хрень больше делать.
Это ошибка времени исполнения или ошибка времени компиляции? Это два разных «уровня»

Компилятор защищает разработчика, если он забыл обработать значение, пришедшее откуда-то, на ошибки, тем самым "разыменовав" Maybe/Either в нормальный объект, с которым он хочет работать.


Сторонний код может сгенерировать не только «свои ошибки», но и среды исполненения (переполнение стека, деление на ноль, выход за переделы индекса и т.д.) Любая ошибка может быть в любое время и в любом месте, а не только те что «мне удобно» и «я тут ожидаю».

Если говорить, к примеру, про Rust, в котором мне нравится, как сделана обработка ошибок, то там все делается через Maybe/Either, и нет никаких исключений, ни от библиотеки, ни от среды выполнения. (В самом крайнем, фатальном случае, есть panic). Поэтому, глядя на сигнатуру метода, я понимаю, что можно ждать.

Вообще не вижу проблемы с исключением. Лучшие практики от МС говорят, что надо обрабатывать только те исключения, которые ты ожидаешь. Если ты получаешь объект по ID, и он не найден, то на самом деле ситуации с исключением или null равноправны и зависят от логики и здравого смысла. Например, я бы ожидал исключение для условного филиала компании и null для атрибута финансового актива.


Исключения хороши, потому что гарантированно прерывают выполнение при нарушении логики. Но они дорогие, поэтому есть есть еще один паттерн, который реализован, например, в asp.net core, и о котором вы не упомянули. Этот же паттерн, по сути, реализован в Regex тоже: в качестве результата всегда возвращается объект (не исключение и не null), в котором есть свойства такие как bool Success, object Value и string Error например.


Производительность не страдает, async/await поддерживается, строгая типизация присутствует, особенно при комбинации с дженериками, возможность использовать структуры для оптимизации памяти тоже есть.

Я несколько лет назад работал в роли QA (тестировщика, грубо говоря) по одному увесистому проекту на с#. Так вот там философия была проста как пять копеек — исключения выбрасывались везде где только можно, но нигде не обрабатывались. Вообще нигде. Программа валилась от любого чиха, я пачками репортил листинги цепочек исключений, они планомерно исправлялись, а я потом находил новые. Ну то есть такая «TDD» методология — нафига изначально раздувать код обработкой исключений, заткнём только то, что найдут тестировщики. Я ушёл потом оттуда, ибо после нескольких недель такой работы даже дома ещё долго вздрагивал при открытии любого окна или файла…

А мне кажется подход неплохим. Ибо какой смысл программисту писать код обработки фантастических ситуаций "приложение запустили на Марсе, предварительно удалив каждый второй файл".

UFO just landed and posted this here

Этим занимается глобальный обработчик исключения. А также всякие try with resources и finally. Нужно просто по умолчанию считать, что любой метод может бросить любое исключение и если в ТЗ не прописано особенное действие, то надо обспечивать лишь минимальное освобождение ресурсов).

UFO just landed and posted this here

Судя по тому, что разработчики таки получали репорты с цепочками исключений — этот обработчик был.

Репорты просто в системный лог Windows прилетали, оттуда я их и брал. Ну, грубо говоря если я в консольном приложении на ноль поделю и обрабатывать это не буду, то падение программы вот так выглядит:

А, ну это уже и правда клиника.

Как оказалось, это не лишено смысла.

Мне грешным делом казалось, что «писать в логи/показывать пользователю (или не показывать, смотря какой пользователь)» — это настолько очевидное дефолтное поведение, что о нём вообще не нужно упоминать :) Ну и естественно, это обычно делается где-то в главном обработчике.

У нас был случай, когда в главном обработчике писалось в лог. И если исключение шибко большое, то система логирования тоже возвращала исключение, которое опять глобальным обработчиком пихалось в нее же, еще более увеличив объем. И так, пока всё не валилось по аутофмемори

Система логирования, кстати, это одно из немногих мест, где бывает уместен знаменитый говнопаттерн
try {
    LogSomething();
}
catch()
{
   //ignore any errors
}

Не уместен, логгеру лучше в отдельном треде крутиться, а в основном не кидать никаких исключений.

Во-первых, в отдельном или не отдельном — это смотря какое у вас приложение. Во-вторых, логгеру во многих случаях вообще нельзя кидать никаких исключений, т.к. необработанное исключение логгера не должно валить программу (а оно в общем случае увалит даже из другого треда, по крайней мере, если это упоминаемый здесь дотнет), а обработанное… обработанное ведь скорее всего будет завернуто в тот же логгер, как в случае у dmitry_dvm.
Я тоже сторонник минимализма, но всё хорошо в меру. В данном случае, скажем, достаточно было открыть файл с флешки, затем вытащить флешку и вызвать диалог снова, и всё валилось в исключение, унося в нирвану уже открытые файлы. Попытки сохранения на заполненный диск, в файл, у которого «только для чтения» и т.п. — вот это вот всё крешилось. В принципе можно всё и вся аккуратно сделать на исключениях и оно будет работать, но в данном случае разработчики судя по всему даже не утруждали себя проверкой тривиальных ситуаций, поскольку сборка автоматически шла на сервере и я был единственный, кто эти сборки запускал. И ведь при этом код был покрыт тестами, и они удивительным образом проходили, поскольку выброшенное исключение было ожидаемым поведением. Я иногда смотрел код (который проходил ревью — там вообще весь процесс разработки как по учебнику шёл) и мог порой заранее сказать где оно рухнет вообще без запуска.
Лучше всего понять, чем плохи исключения, можно, когда используешь чужой, плохо задокументированный код. Есть условный метод GetById, а что он станет делать, если не найдет — ну ты понятия не имеешь. Вернет null? Выбросит какое-то исключение?

Как раз для разрешения таких вопросов в .NET Framework есть очень простая и хорошая традиция: если функция не смогла сделать то, что стоит в её имени, она должна бросать исключение. Именно поэтому в стандартной библиотеке есть Int32.Parse которая бросает исключение если «не шмогла» и Int32.TryParse котрая честно пытается и возвращает флаг ошибки при неудаче. Разработчик лучше знает логику приложения, ему видней является ли неверный ввод обыденностью валидации или действительно исключительной ситуацией — ему и выбирать какую из функций использовать.

Пользователю такое может и удобно, а вот копипастить эти методы-побратимы утомительно.

Зачем их копипастить? Один вызывает другой.

Ну, вот этот вызов и придётся копипастить:


public Foo GetFoo(int id) => TryGetFoo(id, out var foo) ? foo : throw new FooNotFoundException();

public Bar GetBar(int id) => TryGetBar(id, out var bar) ? bar : throw new BarNotFoundException();

public Baz GetBaz(int id) => TryGetBaz(id, out var baz) ? baz : throw new BazNotFoundException();
UFO just landed and posted this here
через кодогенерацию это надо решать.
UFO just landed and posted this here

Использую этот подход и на 4.5 с глобальным обработчиком исключений и на .net core (на любой версии). В основном подходит для WebApi
P.S. Приведенный пример естественно для .net core. Для 4.5+ через Global asax


1)Объявляем список возможных ошибок, пример:


public enum ErrorEnum 
{
ERR_OK = 0,
ERR_DB_CONNECTION,
ERR_SOME_EXTERNAL_API_CONNECTION,
...
// другие исключения
ERR_INTERNAL
}

Далее описываю кастомный Exception


public class ApiExc : Exception
    {
        public ErrorEnum ExcCode { get; set; }

        public ApiExc(ErrorEnum excCode, string message, Exception innerException = null) : base(message, innerException) => ExcCode = excCode;
    }

далее глобально юзаю ExceptionFilter


public void OnException(ExceptionContext context)
            {
                var e = context.Exception;

                ApiError error;

                if (e is ApiExc exc)
                    error = new ApiError { Code = exc.ExcCode, Desc = exc.Message };
                else
                    error = new ApiError { Code = ErrorEnum.ERR_INTERNAL, Desc =e.Message };

                context.Exception = null;

                ControllersHelper.ResponseError(error).ExecuteResultAsync(context);
            }

плюсы:


1)единый обработчик исключений на весь проект. Любое необрабатываемое исключение породит JSON с кодом ошибки ERR_INTERNAL
2)легко обрабатывать нудные исключения в пользовательском коде. Допустим в методе API надо проверить есть ли коннект к бд или нет (а метод возвращает exception с кодом ERR_DB_CONNECTION


Код превратится в


try

{
CheckConnect();
return true
}

catch (ApiExc e) when e.ExcCode == ErrorEnum.ERR_DB_CONNECTION
{
return false;
}
Интересно, а исключения, которые выбрасываются нижележащим кодом, вы тоже будете ловить и оборачивать? Или вы предлагает все ваши примеры ещё и в try...catch обернуть сверху для простоты и лаконичности?

Кстати, ещё вопрос, что хуже: засовывать голову в песок или решать (возможно даже некомпетентно) сторонние проблемы, для которых ваш класс не предназначен, и от него это не ожидается.

Тут вот какая штука. Обрабатывать ошибки вызываемых методов — может и не обязанность моего класса. Но вот сообщать своим пользователям о том, как и когда он может свалится — это точно его обязанность. Так что да, хоть как-то но обрабатывать их все же придется.

Мне кажется, наиболее элегантно эту проблему попробовали решить в Swift.

// Error это просто протокол, может быть чем угодно
enum TaskError: Error {
  case invalid(reason: String)
  case fatal
}

protocol Task {
  // в протоколе-интерфейсе мы можем явно указать, что функция кидает ошибку
  func run() throws -> Int
}

class ValidTask: Task {
  // можно не реализовывать метод как throws, даже если протокол требует
  func run() -> Int {
    return 42
  }
}

class InvalidTask: Task {
  // в обратную сторону не работает, протокол тоже должен объявлять throws
  func run() throws -> Int {
    throw TaskError.invalid(reason: "dunno")
  }
}

Ну и самое вкусное — обработка ошибок.

let task: Task = ValidTask()
// let task: Task = InvalidTask()

// 1
do {
  let result = try task.run()

  print("try succeeded \(result)")
} catch TaskError.invalid(let reason) {
  print("try failed \(reason)")
} catch {
  print("try unknown error \(error)")
}

// 2
if let result = try? task.run() {
  print("if succeeded \(result)")
} else {
  print("if failed")
}

// Можно даже в функциональном стиле
let opt: Optional<Int> = try? task.run()

opt.map({ v in print("map succeeded \(v)") })
// rethrows тоже крутая штука
try opt.map({ _ in throw TaskError.fatal })

Главное, что вся эта красота не бьёт по производительности, потому что это не классические исключения, а просто синтаксический сахар.

А ещё в 5 версии в стандартную библиотеку языка включили Result (точно Rust повлиял). Кортежи так же в языке присутствуют, можно в Golang стиле писать.
Я слышал, что можно разрабатывать по SOLID'у и не нарушать правило зависимости. Более устойчивый модуль не должен зависеть от менее устойчивого, что касается и исключений. Реализуя интерфейс бизнес-логики, мы и выбрасываем исключения бизнес-логики и никакие другие, получая гарантию, что они будут обработаны. Это вопрос проектирования интерфейсами.

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


В то же время, условный


fn calc_improtant_thing() -> Result<ImportantThing, MyDomainError>

гарантирует, что у нас есть ошибки определенного вида, которые прописаны в сигнатуре.

Что же вы не идеоматично обрабатываете Maybe?
Должна быть функция:
Maybe<V> Bind(this Maybe<T>, Func<T,V>)
Которая на самом деле очень напоминает оператор ?.. Это очень сокращает код, избавившись от управляющих конструкций. Их можно чейнить, а Nothing обрабатывается только один раз в конце цепочки. И не надо никаких switch, чтобы не возникало ошибок, что реализованы не все варианты наследников.
Ещё можно добавить проперти
bool IsSome { get; }
как в Nullable, чтобы можно было проверять с помощью конструкции if вместо switch.

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

По моему скромному опыту Rust куда не завезли do-нотацию, именно в функциях обработки Option/Result сила, потому что паттерн матчинг ошибок быстро превращается в вермишель. Более того, такой подход принуждает разработчика декомпозировать функции. Там могло быть большое полотно с разными try-catch, if, match итп, приходится писать маленькие функции по выполнению того или иного действия в цепочке операций, образованных map и bind.

есть сомнения, что в Rust другая проблема: преобразование Option в Result, и наоборот.
Как вариант решения надо было вместо Option сделать только Result, у которого есть None, типа такого:
enum Result<T, E>{
  Ok(T),
  Err(E),
  None
}

В таком случае Option не нужен, достаточно Result.

А если Err не нужен, то что делать? А если не нужен None?


Нет в Rust проблемы с преобразованием между Option и Result, потому что есть методы ok_or и ok.

А если Err не нужен, то что делать?


Result<T, ()>

Тогда уж Result<T, !>. Но всё ещё не понятно что с None делать когда он не нужен.

Должна быть функция:
Maybe<V> Bind<T, V>(this Maybe<T>, Func<T, V>)

Маленькое уточнение — функция Bind приведённая выше на самом деле называется Map.

Сигнатура для функции Bind выглядит вот так:
Maybe<V> Bind<T, V>(this Maybe<T>, Func<T, Maybe<V>>)
А если ещё точнее, то функция называется FMap. Map это для частного случая списка.
Да и эта функция тоже должна быть. Просто в C# неудобно каждый раз заворачивать Just-результат в контейнер. И чаще всего у нас уже есть функции, которые принимаю чистое значение и возвращают чистое значение. Функций которые возвращают значение в контейнере Maybe намного меньше.

fmap она называется из-за конфликтов имён в Хаскеле. Нет никаких причин добавлять букву f в обрыве от конкретного языка программирования.

А как же кондишены как в лиспе? Это когда функции передаются стратегии обработки исключительных ситуаций. В такой стратегии можно вернуть валидное значение, тем самым "спася" вызываемую функцию, либо бросить своё собственное исключение, которое самому же и поймать.


GetUser( id: string, NotFound: ()=> null )

GetUser( id: string, NotFound: ()=> me )

try {
    GetUser( id: string, NotFound: ()=> { throw new MyError } )
} catch( e: MyError ) {}

Хороший подход, но он на самом деле мало где применим — особенно с учетом достаточно бедной системы типов в C#. Например в тайпскрипте можно сделать функцию, которая отдает мне User | T, где T — тип, который отдаёт моя стратегия обработки исключительных ситуаций. И вот это подойдет уже везде, потому что где-то я смогу отдавать например дефолтного юзера, где-то тот же undefined, а где-то — объект с информацией о деталях ошибки.


А в C# есть опция только с дефолтным значением, потому что типов объединений там пока нет

Суть кондишенов не в том, чтобы переопределить возвращаемое значение (это вырожденный случай), а в том, чтобы ответить вызываемой функции на вопрос "что делать?". А возвращает она то, что должна, конечно, или кидает исключение. Пример не очень удачный просто.


GetUser( id: string, ConnectionRefused: ()=> secondaryConnection )

Мне кажется, что случай, когда ты можешь подставить "запасные" данные, или, как в твоем примере, коннекшн, достаточно редкие.

Ну да, чаще это кастомный эксепшен, который сам и кидаешь и ловишь, так что и неопределнности с прилетающими эксепшенами нет.

Кстати действительно достаточно здравый способ. Жаль в C# придется отдельный класс исключения фигачить, но, думаю, можно сделать один достаточно универсальный для таких случаев

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

Не получится ничего не добавляя в язык, как минимум в языке изначально должны быть обобщенные типы, discriminated unions, сопоставление с образцом, лямбды и, желательно, do-нотация.

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