среда, 29 июня 2016 г.

О сути исключений

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

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

И именно о семантике исключений сегодня и пойдет речь.

 

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

Drawing2

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

  • Критические системные ошибки
  • Некритические системные ошибки
  • Некритические проблемы в бизнес-логике пользователя
  • Баги в пользовательской логике

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

Критические системные ошибки

Проще всего дела обстоят с невосстанавливаемыми системными ошибками, такими как StackOverflowException, OutOfMemoryException (*), ThreadAbortExcecption и ExecutionEngineException.

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

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

Некритические системные ошибки

К некритическим (или восстанавливаемым) системным ошибкам относятся исключения, типа SocketException, IOException, DbException и им подобные. По сути, это подвид пользовательских исключений с определенным смыслом – что-то «физически» пошло не так.

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

Сложностей здесь может быть две:

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

Отдельную категорию между пользовательскими и системными ошибками, занимает NullReferenceException. Оно генерируется CLR и не является ни системной ошибкой, ни пользовательским исключением. Но чем бы формально не являлось это исключение, по факту – это баг в коде, восстановиться програмно от которого невозможно.

Пользовательские ошибки

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

Существует несколько подвидов пользовательских ошибок:

  • Исключения о невалидных аргументах метода (ArgumentException и его наследники).
  • Исключения о нарушенной бизнес-логике (обычно, кастомные пользовательские исключения).
  • Исключения о нарушенных инвариантах реализации (InvalidOperationException, InvalidCastException и другие).

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

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

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

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

Тут мы переходим к простому правилу: даже обабатываемое исключение перестает быть таковым, если оно поднимается по стеку вызовов от места генерации. Это значит, что мы можем обработать InvalidOperationException прямо в месте вызова метода Enumerable.First, но если мы его проигнорируем, то вызывающий метод его обработать уже не сможет!

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

Исключения и контракты

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

Поэтому библиотека Code Contracts по умолчанию генерирует специальный класс ContractException, который является внутренним и недоступным коду приложения. И по этой же причине, совершенно не нужно использовать конструкции вида: Contract.Requires<EntryNotFoundException>(HasEntry(id)), поскольку EntryNotFoundException является восстанавливаемым, а нарушение контракта таковым быть не должно.

21 комментарий:

  1. Я и InvalidCast и IndexOutOfRange (не путать с ArgumentOutOfRange) отношу к одному роду с NullReference - однозначно недосмотр в коде, они все выбрасываются рантаймом, а не библиотекой (если библиотека праивльно написана, иначе библиотека может и throw NRE сделать).

    ExecutionEngineException устарел уже мнгого лет назад, его и вспоминать не стоит.

    ОтветитьУдалить
    Ответы
    1. Теоретически, InvalidCast может быть восстанавливаемым, если известно, что идет преобразование типов, которое может упасть. Не самое лучшее решение, но я встречал кейсы, когда "восстановление" было возможно путем возврата дефолтного значения результата конвертации.

      По поводу ExcecutionEngine: спасибо! Мне казалось, что я сталкивался с ним еще недавно...

      Удалить
    2. Так и NRE может быть "восстанавливаемым", но важно то, что бросает его, как правило и по-правильному, не библиотека какая-то, а среда выполнения.

      Вот, кстати, подходящий критерий: в хелпе про исключение, возникновение которого однозначно говорит об ошибке программиста, о непраитвльно написанном коде, сказано: "The following intermediate language (IL) instructions throw"… Таких исключений не много. Но использовать "инструкции" кажется, нужно корректно.

      Удалить
  2. Как у человека, пересевшего на C# с явы, тема исключений для меня немного больная. Иногда проскакивает отсвет того первого недоумения — "как это - в C# нет checked exceptions"?

    Ну то есть, если кто не помнит, в яве есть два типа исключений - checked и unchecked. Unchecked по смыслу - это невосстанавливаемые, типа OutOfMemory, когда все, крышка, jvm от вашего кода тошнит. Это в частности понятно по тому, что они являются наследниками Error или RuntimeException.

    Про эти исключения особо рассказывать нечего по сравнению с С#.

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

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

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

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

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

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

    ОтветитьУдалить
    Ответы
    1. Михаил, прекрасный комментарий!

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

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

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

      Не то, чтобы я пропагандировал подход с проверяемыми исключениями. В том виде, как это сделано в Яве, это реально боль. Но с другой стороны, не кажется ли вам, что отказ от проверяемых (в момент компиляции) исключений сродни отказу от строгой статической (проверяемой в момент компиляции) типизации? Смысл-то тут во-многом тот же:
      1. Мы соглашаемся с тем, что ошибки, которые мог бы проверить компилятор, будут случаться вместо этого в рантайме.
      2. Сложнее понять, что автор хотел сказать своим кодом.

      Удалить
    3. Я полностью согласен, что исключение является контрактов и было бы здорово иметь гарантии во время компиляции. Т.е. с идеей я согласен. Но мне тоже показалось из обсуждений и легкого знакомства с Java, что с проверяемыми исключениями там не все хорошо и что хорошая идея таки не взлетела (как и в С++, в котором спецификация исключений уже официально объявлена устарешей; правда там была тотальная жесть - если вылетало исключение, противоречащая спецификации, то приложение падало с terminate, а статической проверки вообще не было).

      З.Ы. В качестве заключения: с идеей я согласен, но вменяемой имплементации статической проверки исключений в жизни пока не встречал.

      Удалить
    4. Да вот, в том-то и проблема. Как вариант, в языках с алгебраическими типами кажется предлагается включать исключения в тип возвращаемого значения. Типа вместо

      int Integer.ParseInt(String str) throws NumberFormatException;

      будет

      int|NumberFormatException Integer.ParseInt(String str);

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

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

      Удалить
    6. В книжке "Чистый код" есть глава посвященная тому, почему Checked исключения использовать нельзя.

      Удалить
    7. Специально нашел и прочел эту главу. Точнее - главку (одна страница). Честно говоря — не убедительно. Основной аргумент: "смотрите в C# нет проверяемых исключений, и ничего, норм язык". Аналогично можно сказать "в Питоне нет явной типизации, и ничего, люди на нем пишут".

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

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

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

      Удалить
    8. Насчет того, как это обычно реализуется в языках с алгебраическими типами...
      К примеру у меня есть алгебраический тип данных:

      type SomeType = | Constructor1 of int | Constructor2 of string

      и функция:

      let SomeFunc (a:SomeType) = match a with
      | Constructor1 v -> if(v>0) then None else Some(v)
      | _ -> None

      Таким образом, если в ходе вычислений что-то пошло не так, я не кидаю исключние, а просто говорю, что результата вычислений нет (None).
      Т.е. функция вместо 'int' возвращает 'int option', где type 'a Option = None | Some of 'a, т.е. обычный алгебраический тип.

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

      P.s.:да, это обычная монада Maybe.

      Удалить
    9. А если в целом, то как там говорится..."плюсую неистово"))
      Я однозначно за то, что checked exceptions лучше того, что мы имеем в C#. По крайней мере, мы явно декларируем для всех клиентов нешего класса что при выполнении метода что-то может пойти не так.

      Вот иду я по дороге, вижу поле с табличкой "Осторожно, мины". Подумаю немного...и не пойду туда...или пойду, но хотя бы каску надену.
      Или вот вижу другое поле. Красивое такое, в ромашках. Конечно пойду, прогуляюсь. Только метров через 10 наступаю на мину. "Ну хоть бы табличку поставили, думаю". А самое главное, что прогулявшись так несколько раз начинаю в каждом безобидном поле видеть минное.

      Мораль: я за честность))

      P.s.: C# всех делает подозрительными, или только меня?)

      Удалить
  3. Я бы тоже отнес InvalidCast и IndexOutOfRange к одному роду с NullReference.
    Кроме того, меня терзают смутные сомнения по поводу критериев восстановимости.
    Пусть у нас есть некоторый "объект работы". Это пользовательский сценарий или его чаcть. И мы пытаемся этот объект работы работать :-)
    NullReference может быть невосстановимым в пределах этого объекта работы, но вне его можно просто написать "дерьмо случилось во время этой операции", залогировать и остальную программу это никак не затронет.

    ОтветитьУдалить
  4. Сергей, спасибо за хороший пост!
    На самом деле я сторонник того, чтобы вообще обходиться без исключений. Для примера в F# есть замечательный тип type 'a Option = None | Some of 'a, который можно использовать для возврата значений из функций, которые потенциально могут завершиться некорректно: None - что-то пошло не так, Some a - все вычислено, результат = a.
    Разумеется, и на С# так писать можно.
    Из минусов только то, что по возвращении из функции результат нужно "распаковывать", но при наличии паттерн-матчинга это не так болезненно (кстати, он ведь будет в C#7, если ничего не путаю).

    Очень интересно знать, как Вы к такому подходу относитесь?

    ОтветитьУдалить
    Ответы
    1. Хороший подход. Это для коммуникации ошибок, которые не являются критами и не сигнализируют о наличии багов. Если это ошибка валидации юзер-инпута - это не исключении и должно быть выражено через Result в Maybe (Option) типе.
      Для валидации аргументов нужно юзать исключения, как и для fail-fast сценариев: кидаем исключение и падаем в конце, если никто не в состоянии обработать.

      Удалить
  5. Вот статья которая исчерпывающе покрывает тему:
    http://joeduffyblog.com/2016/02/07/the-error-model/

    ОтветитьУдалить
  6. Сергей, есть одна идея - как насчет контрактов на сами исключение. Пример - я хочу что бы классы унаследованные от интерфейса A кидали только определенный тип исключений. для этого хотелось бы иметь контракт который прописывается для интерфейса вроде такого ContractException(MyException). Style cop мог бы проверять есть ли в классе унаследованном от интерфейса A прямые вызовы "throw NotMyException"

    Существует ли что-то подобное и если нет может подскажете технические возможности ее реализации

    ОтветитьУдалить
    Ответы
    1. Это ровно то, что сделано в Java с их Checked Exceptions, и время показало, что это не лучшая идея.

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

      Например, метод foo, вызывает bar, который вызывает baz. ПРи этом часть может быть внутри условий и т.п. Понять, какие реально исключения могут возникнуть попадает в категорию halting problem, которая в общем случае не решаема.

      Удалить
  7. Сергей, большое спасибо за статью!
    У меня возник один вопрос: Вы пишете, что исключения, являющиеся нарушением бизнес-логики чаще заменяют паттерном «спросил, а потом сделал» или же специальными кодами возврата. Мне интересно, чем коды возврата лучше исключений вроде UserAlreadyRegisteredException.
    На мой взгляд, коды сложнее поддерживать и они усложняют клиентский код (после вызова каждого метода придется писать switch с анализом этих кодов).

    ОтветитьУдалить
  8. Сергей, большое спасибо за статью!

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

    2. Генерируйте исключения только для действительно исключительных
    ситуаций

    3. Не используйте исключения по мелочам

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

    5. Генерируйте исключения на правильном уровне абстракции

    6. Вносите в описание исключения всю информацию о его причинах

    7. Избегайте пустых блоков catch

    7. Выясните, какие исключения генерирует используемая библиотека

    8. Рассмотрите вопрос о централизованном выводе информации об исключениях

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

    Steve McConnell, CODE COMPLETE

    Простите за мое скромное мнение, но не будучи профессиональном программистом, мне кажется, что последняя реккомендация Стива основопологающая. Технических решений конкретной проблемы всегда несколько и у каждого свои +/-. Но если обсудить, прийти к консенсусу, а не к компромиссу, то от это выиграет и проект и комманда. А если будут промахи, то любой сделает выводы, которые учтет в следуюший раз.

    ОтветитьУдалить