Исключения и управление состоянием

Процесс обработки исключений (exception handling) состоит из нескольких шагов. Сначала нужно определить, что именно считать ошибкой. Затем нужно выяснить, как возникает ошибка и как от неё избавиться. Скорее всего, в момент ошибки код находится в каком-то промежуточном состоянии, и его потребуется вернуть в состояние до момента возникновения ошибки.

Определение "исключения"

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

Механика обработки исключений

В основе обработки исключений в .NET лежит структурная обработка исключений (Structured Exception Handling, SEH).

private void SomeMethod()
{
  try
  {
    // Код, требующий корректного восстановления или очистки ресурсов
  }
  catch (InvalidOperationException)
  {
    // Код восстановления работоспособности после исключения InvalidOperationException
  }
  catch (IOException)
  {
   // Код восстановления работоспособности после исключения IOException
  }
  catch
  {
    // Код восстановления работоспособности после остальных исключений.
    // После перехвата исключений их обычно генерируют повторно
    throw;
  }
  finally
  {
    // Здесь находится код, выполняющий очистку ресурсов после операций, начатых в блоке try.
    // Этот код выполняется ВСЕГДА вне зависимости от наличия исключения
  }

  // Код, следующий за блоком finally, выполняется, если в блоке try не генерировалось исключение
  // или если исключение было перехвачено блоком catch, а новое не генерировалось
}

В большинстве случаев используются комбинации try-catch или try-finally.

Блок try

В данный блок помещается код, требующий очистки ресурсов (прим. Комбинацией try-finally можно заменить ключевое слово using) и/или восстановления после исключения. Код очистки содержится в блоке finally. Также в блоке try может располагаться код, приводящий к генерации исключения. Код восстановления вставляют в один или несколько блоков catch. Блок try не может определяться сам по себе, так как это не имеет смысла, и C# запрещает такие определения.

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

Блок catch

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

Выражение в скобках после ключевого слова catch называется типом исключения (catch type). В C# эту роль играет тип System.Exception и его производные. Если тип исключения не указывается, то отлавливаются все типы исключений, однако информация о них не доступна.

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

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

В блоке catch выбирается способ восстановления после исключения из трёх вариантов:

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

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

Блок finally

Код блока finally выполняется всегда, кроме случаев, когда поток прерывается функцией TerminateThread() или методом System.Environment.FailFast(). Обычно этот блок производит очистку после выполнения блока try. Если же поместить очистку в код после finally, то она может не выполниться в случае необработанного исключения. Код блоков catch и finally должны быть максимально короткими и работающими без исключений. Если же в данных блоках возникают исключения, это может свидетельствовать о наличии серьёзных ошибок (скорее всего, о повреждении текущего состояния). Данное исключение не должно обрабатываться, так как оно уничтожает повреждённое состояние. В противном случае это может привести к непредсказуемым результатам и появлению дефектов в системе безопасности.

CLS-совместимые и CLS-несовместимые исключения

Все языки, ориентированные на CLR, должны поддерживать создание объектов класса Exception, так как этого требует CLS. Однако в CLR можно создавать исключения любых типов, хотя компилятор C# не разрешает генерировать их.

До выхода версии CLR 2.0 в блоках catch перехватывались только CLS-совместимые исключения. Если же метод на C# вызывал метод, написанный на другом языке, и тот генерировал CLS-несовместимое исключение, то его невозможно было отловить, что было чревато нарушением защиты.

Начиная с CLR 2.0 появился класс System.Runtime.CompilerServices.RuntimeWrappedException, который является производным от Exception и CLS-совместимым. При генерации CLS-несовместимого исключения автоматически создавался экземпляр данного класса, закрытому полю которого присваивалась ссылка на выброшенный объект. Таким образом исключение становилось CLS-совместимым. До версии 2., перехват исключений происходил примерно так:

private void SomeMethod()
{
  try
  {
    // Внутрь блока try помещают код, требующий корректного восстановления работоспособности или очистки ресурсов
  }
  catch (Exception e)
  {
    // До C# 2.0 этот блок перехватывал только CLS-совместимые исключения
    // В C# 2.0 этот блок научился перехватывать также CLS-несовместимые исключения
    throw; // Повторная генерация перехваченного исключения
  }
  catch
  {
    // Во всех версиях C# этот блок перехватывает и совместимые, и несовместимые с CLS исключения
    throw; // Повторная генерация перехваченного исключения
  }
}

Если этот код перекомпилировать для CLR 2.0, последний блок catch никогда не будет выполняться. Для решения этой проблемы есть два способа: можно объединить последние блоки catch в один или можно сообщить CLR, чтобы обработка исключений работала по старым правилам (для этого сборка помечается специальным атрибутом).

Класс System.Exception

System.Exception - очень простой тип с небольшим набором свойств, которые используются при отладке. Среди них есть сообщение с текстом описания причины ошибки, имя сгенерировавшей исключение сборки, стек трейс (имена и сигнатуры методов, вызов которых стал источником исключения), имя вызвавшего исключение метода, адрес документации и исключение, в ходе обработки которого было выброшено текущее.

Информация в StackTrace может быть крайне полезной для поиска объекта, ставшего источником исключения, и последующего исправления кода. При обращении к этому свойству фактически происходит обращение к коду в CLR, потому что свойство не просто возвращает строку. При создании объекта типа, производного от Exception, данному свойству присваивается null. При появлении исключения CLR делает запись с указанием места его возникновения. И если в блоке catch обратиться к свойству StackTrace, то код обратится к CLR, где и будет указана строка, содержащая имена всех методов от точки, где оно было выброшено, до точки, где оно было перехвачено. При появлении исключения CLR обнуляет его начальную точку. Однако если в блоке catch написать throw; без указания исключения, то обнуление информации о стеке не производится.

private void SomeMethod() {
  try { ... }
  catch (Exception e)
  {
    ...
    throw e; // CLR считает, что исключение возникло тут
             // FxCop сообщает об ошибке
  }
  
  try { ... }
  catch (Exception e)
  {
    ...
    throw; // CLR не меняет информацию о начальной точке исключения.
           // FxCop НЕ сообщает об ошибке
  }
}

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

private void SomeMethod()
{
  Boolean trySucceeds = false;

  try
  {
    ...
    trySucceeds = true;
  }
  finally
  {
    if (!trySucceeds) { /* код перехвата исключения */ }
  }
}

Так как StackTrace не включает в себя имена методов, расположенных в стеке вызова выше точки принятия исключения блоком catch. Для отслеживания всего стека используется тип System.Diagnostics.StackTrace.

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

Классы исключений, определённые в FCL

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

Генерирование исключений

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

  1. Стоит очень осмотрительно выбирать производный от System.Exception тип, чтобы он как можно более полно описывал возникшую проблему, и чтобы вызывающий метод мог более точно выполнить восстановительные работы. Если необходимого типа нет в FCL или дополнительных библиотеках, стоит определить новый тип. Если при написании класса исключения создаётся иерархия, то стоит подумать над тем, чтобы в ней было как можно меньше базовых классов, потому что в таком случае будет меньше возможностей указать в блоке catch базовый класс и описать более точное восстановительное поведение.
  2. Следует максимально полно описывать причину ошибки в тексте сообщения об ошибке исключения. Эту информацию можно свободно писать в логи, так как у пользователей нет доступа к этим данных, а разработчикам будет проще исправлять ошибки, возникшие во время выполнения, а не отладки

Создание классов исключений

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

Продуктивность вместо надёжности

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

Продуктивность разработки достигается не только за счёт композиционности кода, но и благодаря некоторым возможностям компиляторов, среди которых неявные:

  • Вставка в вызываемый метод необязательных параметров.
  • Упаковка экземпляров значимого типа.
  • Создание и инициализация массивов параметров.
  • Связывание с членами динамических и переменных выражений.
  • Связывание с методами расширения.
  • Связывание с перегруженными операторами и их вызов.
  • Создание делегатов.
  • Автоматическое определение типа при вызове обобщённых методов, объявлении локальных переменных и использовании лямбда выражений.
  • Определение и создание классов замыканий (closure) для лямбда-выражений и итераторов.
  • Определение, создание и инициализация анонимных типов и их экземпляров.
  • Написание кода поддержки LINQ.

Кроме этого, CLR умеет неявно:

  • Вызывать виртуальные и интерфейсные методы.
  • Загружать сборки и JIT-компилируемые методы, которые могут стать причиной некоторых исключений.
  • Пересекать границы домена приложения для доступа к объектам, которые могут стать источником исключений.
  • Сериализовывать и десериализовывать объекты при пересечении границы домена.
  • Заставлять потоки генерировать исключения.
  • Вызывать методы, выполняющие завершающие операции до освобождения памяти сборщиком мусора.
  • Создавать типы в куче загрузчика при работе с обобщёнными типами.
  • Вызывать статический конструктор типа, который может стать источником исключения.
  • Генерировать прочие исключения.

И, разумеется, .NET Framework поставляется с обширной библиотекой классов, любая часть из которых может стать источником ошибки.

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

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

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

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

  • CLR запрещает аварийно завершать потоки во время выполнения кода в блоках catch и finally. Тем не менее не стоит помещать весь код внутрь этих блоков. Этот приём можно использовать только для изменения самых чувствительных состояний.
  • Класс System.Diagnostics.Contracts.Contract позволяет применять к методам контракты кода. Они позволяют проверять аргументы и другие переменные перед модификацией состояния. В случае соответствия контракте вероятность повреждения состояния минимальна. Если проверка не проходит, генерируется исключение.
  • Области ограниченного исполнения (CER) позволяют избежать имеющихся в CLR неоднозначностей. Перед входом в блок try можно загрузить все требуемые сборки и скомпилировать код внутри catch и finally. Это позволит избежать часть исключений, связанных с CLR.
  • В зависимости от местоположения состояния можно использовать транзакции.
  • Можно сделать методы более явными.

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

Приёмы работы с исключениями

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

Активно используйте блоки finally

В C# при использовании конструкций lock, using и foreach, а также при переопределении деструктора класса (метод Finalize()) блоки try/finally создаются автоматически. Написанный разработчиком код помещается в метод try, а в блок finally помещаются соответственно:

  • Снятие блокировки.
  • Вызов метода Dispose() для объекта.
  • Вызов метода Dispose() для объекта IEnumerator.
  • Вызов метода Finalize() базового класса.

Не надо перехватывать все исключения

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

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

Вполне допустимо перехватывать System.Exception с целью обработки и передачи его дальше по стеку. перехват и поглощение (без повторного генерирования) недопустимо, так как оно приводит к сокрытию факта сбоя.

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

Корректное восстановление после исключения

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

Отмена незавершённых операций при невосстановимых исключениях

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

Сокрытие деталей реализации для сохранения контракта

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

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

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

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

При вызове метода через рефлексию CLR автоматически перехватывает все генерируемые этим методом исключения и преобразует их в тип TargetInvocationException. В результате для поиска сведений приходится идти в отладчик и смотреть свойство InnerException. Именно поэтому многие разработчики предпочитают использовать тип dynamic, так как он не перехватывает исключения и не генерирует TargetInvocationException, а перемещает исходное исключение вверх по стеку.

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

При появлении исключения CLR начинает в стеке вызовов поиск блока catch с соответствующим типом исключения. Если не найден ни один такой блок, возникает необработанное исключение (unhandled exception). Обнаружив в процессе поток в необработанным исключением, CLR его уничтожает. Необработанное исключение указывает на непредусмотренную программистом ситуацию и должно считаться признаком ошибки в приложении.

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

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

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

Отладка исключений

В отладчике Microsoft Visual Studio есть возможность отметить, на выкидывании каких исключений стоит останавливаться, чтобы отловить ошибку на месте. По умолчанию выставлено только необработанное исключение, так как предполагается, что, если исключение обработано, значит, программа работает так, как и задумано. Кроме того, в отладчике доступна возможность добавления своих собственных типов исключений (не только унаследованных от System.Exception).

Скорость обработки исключений

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

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

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

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

Области ограниченного выполнения

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

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

Областью ограниченного выполнения (Constrained Execution Region, CER) называется фрагмент кода, который должен быть устойчивым к сбоям. Так как домены допускают выгрузку всего состояния, CER обычно служат для управления распределённым между доменами или процессами состоянием. Особенно они полезны при работе с состоянием, для которого возможно неожиданные исключения, которые ещё называют асинхронными (asynchronous exceptions).

При создании области ограниченного выполнения до блока try, JIT-компилятор немедленно начинает компилировать соответствующие блоки catch и finally (загружать сборки, создавать типы, вызывать статические конструкторы и компилировать методы). Если хотя бы одна из операций даст сбой, то блок try не будет выполняться. Для этого надо пометить методы соответствующими атрибутами, которые сообщат компилятору, что данные элементы не повредят домен приложения или состояние процесса.

Чтобы написать надёжный метод, стоит делать его как можно меньше и ограничивать его сферу действия. Стоит также убедиться, что не выделяется память под объекты, внутри не вызываются виртуальные или интерфейсные методы, не использованы делегаты или отражения, так как в данном случае JIT-компилятор не сможет определить, какой именно метод вызывается на самом деле.

Контракты кода

Контракты кода (code contracts) — это механизм декларативного документирования решений, принятых в ходе проектирования кода, внутри самого кода. Контракты бывают трёх видов:

  • Предусловия (preconditions) - используются для проверки аргументов.
  • Постусловия (postconditions) - используются для проверки состояния завершения метода вне зависимости от наличия или отсутствия исключения.
  • Инварианты (object invariants) - позволяют удостовериться, что данные объекта находятся в хорошем состоянии на протяжении всего объекта.

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

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

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