November 2015

Volume 30 Number 12

Главное в .NET - Обработка исключение на C#

By Марк Михейлис | November 2015

Mark MichaelisДобро пожаловать в новую рубрику «Главное в .NET». В этой рубрике вы сможете следите за всем, что творится в мире Microsoft .NET Framework, будь то достижения в C# vNext (в настоящее время C# 7.0), усовершенствования во внутреннем устройстве .NET или события на фронте Roslyn и .NET Core (например, перевод MSBuild в статус ПО с открытым исходным кодом).

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

Я живу в Спокане (штат Вашингтон), где являюсь «главным корифеем» в консалтинговой компании сегмента «high-end», которая называется IntelliTect (IntelliTect.com). IntelliTect специализируется на решении трудных задач и великолепно справляется с ними. Я был Microsoft MVP (в настоящее время в области C#) в течении 20 лет и восемь из них занимал должность регионального директора Microsoft. Сегодня я открываю рубрику обсуждением обновленных правил обработки исключений.

В C# 6.0 включили два новых средства обработки исключений. Во-первых, появилась поддержка условий исключения (exception conditions) — возможность предоставлять выражение, которое отфильтровывает исключение, предотвращая его попадание в блок catch до раскрутки стека. Во-вторых, введена поддержка асинхронности в блоке catch — того, что было немыслимо в C# 5.0, хотя именно тогда в язык добавили асинхронность. Кроме того, в последних пяти версиях C# и соответствующих выпусках .NET Framework было много других изменений, которые в некоторых случаях достаточно значимы, чтобы потребовать правок в правилах кодирования на C#. В этом выпуске рубрики я дам обзор этих изменений и представлю обновленные правила кодирования, относящиеся к обработке исключений — их захвату.

Захват исключений: обзор

Как хорошо понятно, генерация исключения определенного типа позволяет механизму захвата (catcher) использовать сам тип исключения для идентификации проблемы. Иначе говоря, не обязательно захватывать исключение и применять выражение switch к сообщению об исключении, чтобы определить, какое действие надо предпринять в связи с этим исключением. Вместо этого C# поддерживает множество блоков catch, каждый из которых нацелен на исключение конкретного типа, как показано на рис. 1.

Рис. 1. Захват исключений разных типов

using System;

public sealed class Program
{
  public static void Main(string[] args)

    try
    {
      / ...
      throw new InvalidOperationException(
        "Arbitrary exception");
      // ...
    }
    catch(System.Web.HttpException exception)
      when(exception.GetHttpCode() == 400)
    {
      // Обрабатываем System.Web.HttpException,
      // где exception.GetHttpCode() равен 400
    }
    catch (InvalidOperationException exception)
    {
      bool exceptionHandled=false;
      // Обрабатываем InvalidOperationException
      // ...
      if(!exceptionHandled)
        // В C# 6.0 замените это условием исключения
      {
        throw;
      }
    }
    finally
    {
      // Здесь обрабатываем любой код очистки, выполняемый
      // независимо от того, было ли какое-либо исключение
    }
  }
}

Когда возникает исключение, управление передается первому блоку catch, способному обработать это исключение. Если с try сопоставлено более одного блока catch, близость совпадение определяется цепочкой наследования (предполагая отсутствие условия исключения C# 6.0), и обработка исключения передается первому catch в этой цепочке. Например, в коде на рис. 2 генерируется исключение типа System.Exception, и оно обрабатывается вторым блоком catch, так как System.InvalidOperationException в конечном счете наследует от System.Exception. Поскольку InvalidOperationException наиболее близко совпадает со сгенерированным исключением, оно будет захвачено блоком catch(InvalidOperationException...), а не catch(Exception...), если бы таковой блок был в этом коде.

Блоки catch должны появляться в порядке (вновь предполагая отсутствие условия исключения в C# 6.0) от наиболее специфичных до наиболее универсальных, чтобы избежать ошибки при компиляции. Например, добавление блока catch(Exception...) до любого другого приведет к ошибке при компиляции, поскольку все предыдущие исключения наследуют от System.Exception в той или иной точке цепочки наследования. Также заметьте, что именованный параметр для блока catch не обязателен. По сути, заключительный catch, к сожалению, допускается даже без указания типа параметра.

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

Сценарий 1 Захваченное исключение не в полной мере идентифицирует проблему, из-за которой возникло это исключение. Например, при вызове System.Net.WebClient.DownloadString с допустимым URL исполняющая среда может сгенерировать System.Net.WebException, если нет сетевого соединения; то же самое исключение генерируется при указании несуществующего URL.

Сценарий 2 Захваченное исключение включает закрытые данные, которые не должны раскрываться выше по цепочке вызовов. Например, в очень ранней версии CLR v1 (фактически в версии pre-alpha) было исключение, сообщающее нечто вроде «Security exception: You do not have permission to determine the path of c:\temp\foo.txt» («Исключение защиты: у вас нет прав на определение пути c:\temp\foo.txt»).

Сценарий 3 Тип исключения слишком специфичен для обработки вызвавшим кодом. Например, исключение System.IO (вроде UnauthorizedAccessException, IOException, FileNotFoundException, DirectoryNotFoundException, PathTooLongException, NotSupportedException или SecurityException, ArgumentException) возникает на сервере при вызове веб-сервиса для поиска почтового кода ZIP.

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

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

Второй вариант при захвате исключения — определять, что вы фактически не в состоянии должным образом обработать его. В этом случае вы захотите повторно сгенерировать точно такое же исключение, отправив его следующему обработчику вверх по цепочке вызовов. Блок catch(InvalidOperationException...) на рис. 1 как раз и демонстрирует этот вариант. В выражении throw не указана идентификация генерируемого им исключения (присутствует только ключевое слово throw), хотя экземпляр исключения (exception) появляется в области видимости этого блока catch и его можно было бы сгенерировать повторно. Генерация специфического исключения привела бы к обновлению всей информации стека для соответствия новой позиции throw. В итоге вся информация стека, указывающая место вызова, где возникло исходное исключение, была бы утрачена, что сильно затруднило бы диагностику проблемы. Так что, определив, что данный блок catch не может полноценно обработать исключение, это исключение следует генерировать повторно, используя пустое выражение throw.

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

Генерация существующих исключений без замены информации стека

В C# 5.0 был добавлен механизм, позволяющий генерировать ранее сгенерированное исключение, не теряя информации трассировки стека в исходном исключении. Это дает возможность повторно генерировать исключения, например, даже извне блока catch, а значит, без использования пустого выражения throw. Хотя потребность в этом возникает весьма редко, в некоторых случаях исключения обертываются или сохраняются до тех пор, пока поток выполнения программы не выйдет из блока catch. Так, многопоточный код мог бы обертывать исключение в AggregateException. .NET Framework 4.5 предоставляет класс System.Runtime.ExceptionServices.ExceptionDispatchInfo специально для этого случая, для чего используются его статический метод Capture и метод экземпляра Throw. Рис. 2 демонстрирует повторную генерацию исключения без сброса информации трассировки стека или применения пустого выражения throw.

Рис. 2. Использование ExceptionDispatchInfo для повторной генерации исключения

using System
using System.Runtime.ExceptionServices;
using System.Threading.Tasks;
Task task = WriteWebRequestSizeAsync(url);
try
{
  while (!task.Wait(100))
{
    Console.Write(".");
  }
}
catch(AggregateException exception)
{
  exception = exception.Flatten();
  ExceptionDispatchInfo.Capture(
    exception.InnerException).Throw();
}

В случае метода ExeptionDispatchInfo.Throw компилятор не интерпретирует его как выражение возврата в том же стиле, в каком это могло бы быть сделано для обычного выражения throw. Например, если бы сигнатура метода возвращала значение, но на самом деле никакого значения по пути кода с ExceptionDispatchInfo.Throw не возвращалось бы, компилятор выдавал бы ошибку, указывающую на отсутствие возвращенного значения. Иногда разработчикам приходится следовать ExceptionDispatchInfo.Throw с выражением return, хотя такое выражение никогда не будет выполнено — вместо этого будет генерироваться исключение.

Захват исключений в C# 6.0

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

Вместо того чтобы после генерации исключения раскручивать стек вызовов в блоке catch лишь для повторной генерации исключения из-за того, что при дальнейшем анализе стала понятной невозможность его полноценной обработки, очевидно, было бы предпочтительнее вообще не захватывать это исключение. Начиная с C# 6.0, в блоках catch доступно дополнительное условное выражение. Вместо подбора блока catch только по типу исключения в C# 6.0 введена поддержка проверки по условию. Выражение when позволяет вам предоставлять булево выражение, которое дополнительно фильтрует блок catch и определяет, что он способен обработать исключение, только если условие дает true. Блок System.Web.HttpException на рис. 1 демонстрирует это с помощью оператора сравнения на равенство (equality comparison operator).

Интересный результат условия исключения в том, что, когда указывается это условие, компилятор не принуждает к расстановке блоков catch в порядке цепочки наследования. Например, блок catch для типа System.ArgumentException с сопутствующим условием исключения теперь может появляться до более специфического блока catch для типа System.ArgumentNullException, хотя последний наследует от первого. Это важно, так как позволяет писать специфическое условие исключения, которое сопоставлено универсальному типу исключения, следующему за более специфическим типом исключения (с условием исключения или без него). Поведение в период выполнение остается согласованным с более ранними версиями C#: исключения захватываются первым совпадающим блоком catch. Просто теперь вопрос о том, можно ли считать какой-то блок catch совпадающим, решается на основе комбинации типа и условия исключения, а компилятор вводит порядок относительно только блоков catch, не имеющих условий исключения. Например, catch(System.Exception) с условием исключения может появляться до catch(System.ArgumentException) с условием исключения или без него.

Однако, как только появляется catch без условия исключения, более специфический блок catch [скажем, catch(System.ArgumentNullException)] может быть блокирован, даже если в нем есть условие исключения. Это оставляет программисту свободу в кодировании условий исключений, которые потенциально могут появляться не по порядку, — при этом более ранние условия исключения, захватывающие исключения, предназначенные для более поздних условий, что потенциально может даже сделать последних непреднамеренно недостижимыми. В конечном счете порядок ваших блоков catch аналогичен тому, как вы располагали бы выражения if-else. Когда условие удовлетворяется, все остальные блоки catch игнорируются. Однако в отличие от условий в выражениях if-else все блоки catch должны включать проверку типа исключения.

Обновленные правила обработки исключений

Пример оператора сравнения на рис. 1 тривиален — условие исключения не обязательно должно быть столь простым. Вы могли бы, например, вызывать какой-то метод для проверки условия. Единственное требование в том, что выражение должно быть предикатом — возвращать булево значение. Иначе говоря, вы можете фактически выполнять любой код из цепочки вызовов захвата исключения. Это открывает возможность полного предотвращения повторного захвата и генерации того же исключения; по сути, вы можете достаточно сузить контекст до захвата исключения и захватывать его только в том случае, если оно действительно будет обработано. Таким образом, правило избегать захвата исключений, которые вы не в состоянии полностью обработать, становится реальностью. Любая проверка условий, окружающая пустое выражение throw, вероятно, может служить признаком плохого кода (code smell), и этого следует избегать. Подумайте о добавлении условия исключения вместо использования пустого выражения throw, кроме случая сохранения измененяемого состояния (volatile state) перед завершением процесса.

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

Универсальный блок catch

C# требует, чтобы любой объект, генерируемый кодом, был производным от System.Exception. Однако это требование не универсально для всех языков. Так, C/C++ позволяет генерировать объект любого типа, в том числе управляемые исключения, не производные от System.Exception, или даже элементарные типы вроде int или string. Начиная с C# 2.0, все исключения — производные они от System.Exception или нет — будут передаваться в C#-сборки как производные от System.Exception. Результат заключается в том, что блоки catch для System.Exception будут захватывать все «достоверно обрабатываемые» исключения, не захваченные предшествующими блоками. Однако до C# 1.0, если исключение, не производное от System.Exception, генерировалось при вызове какого-то метода (находящего в сборке, написанной не на C#), то оно не захватывалось блоком catch(System.Exception). По этой причине C# также поддерживает универсальный блок catch (catch{ }), который теперь ведет себя идентично блоку catch(System.Exception exception) с тем исключением, что в нем не указывается ни тип, ни имя переменной. Недостаток такого блока в том, что у вас нет экземпляра исключения, к которому вы могли бы обратиться, и нет способа узнать, какие действия следовало бы предпринять. Нельзя даже запротоколировать это исключение или распознать маловероятный случай, где такое исключение безопасно.

На практике вы должны избегать блока catch(System.Exception) и универсального блока catch (далее обобщенно называемых блоком catch для System.Exception), кроме как под предлогом «обработки» исключения простым его протоколированием перед завершением процесса. Следуя универсальному правилу захватывать только те исключения, которые вы можете обработать, было бы слишком самонадеянным писать код, для которого программист объявляет, что этот catch может обрабатывать любые возможные исключения. Во-первых, попытка перечислить всевозможные исключения (особенно в теле Main, где находится самое большое количество выполняемого кода и где контекст скорее всего минимален) — задача неподъемная, кроме как в простейших программах. Во-вторых, существует уйма исключений, которые могут генерироваться неожиданно для вас.

До C# 4.0 существовал третий набор исключений из-за поврежденного состояния, при которых программу, как правило, нельзя было восстановить хотя бы в принципе. Этот набор не столь значим, начиная с C# 4.0, но catch(System.Exception) (или универсальный блок catch) на самом деле не будет захватывать такие исключения. (С технической точки зрения, метод можно дополнить System.Runtime.ExceptionServices.HandleProcessCorruptedStateExceptions, чтобы захватывать даже эти исключения, но вероятность того, что вы сумеете в достаточной мере обработать их, крайне низка. Подробнее на эту тему см. по ссылке bit.ly/1FgeCU6.)

Одна техническая деталь, которую стоит отметить в исключениях из-за поврежденного состояния, заключается в том, что при генерации в период выполнения они передаются только через блоки catch для System.Exception. Явно сгенерированное исключение из-за поврежденного состояния, такое как System.StackOverflowException или другое System.SystemException, будут фактически захвачены. Однако генерация такого исключения будет грубейшей ошибкой и на самом деле поддерживается только для обратной совместимости. Современные правила требуют, чтобы вы не генерировали какое-либо из этих исключений (в том числе System.StackOverflowException, System.SystemException, System.OutOfMemoryException, System.Runtime.InteropServices.COMException, System.Runtime.InteropServices.SEHException и System.ExecutionEngineException).

Подведем итог. Избегайте использования блока catch для System.Exception, если только он не должен обработать исключение с помощью какого-то кода очистки и запротоколировать факт этого исключения до повторной генерации или корректного завершения приложения. Например, если бы блок catch мог успешно сохранять любые изменяемые данные (на что в любом случае не стоит полагаться, так как они тоже могут быть повреждены) до закрытия приложения или повторной генерации исключения. При возникновении ситуации, в которой приложение следует завершить, потому что продолжать выполнение небезопасно, код должен вызывать метод System.Environment.FailFast. Используйте System.Exception и универсальные блоки catch только для протоколирования исключения перед завершением приложения.

Заключение

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

  • избегайте захвата исключений, которые вы не в состоянии полноценно обработать;
  • избегайте скрытия (отбрасывания) исключений, которые вы не полностью обрабатываете;
  • используйте throw для повторной генерации исключения, а не throw <объект исключения> внутри блока catch;
  • указывайте в свойстве InnerException исключения-оболочки захваченное исключение, если только это не приводит к раскрытию конфиденциальных данных;
  • подумайте о применении выражения условия вместо повторной генерации исключения после захвата исключения, которое вы не можете обработать;
  • избегайте генерации исключений из условного выражения исключения;
  • будьте осторожны при повторной генерации других исключений;
  • используйте System.Exception и универсальные блоки catch только в редких случаях — при необходимости запротоколировать исключение перед завершением приложения;
  • избегайте отчета об исключении или его протоколирования ниже по стеку вызовов.

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

Заметьте, что многое из этого материала взято из следующего издания моей книги «Essential C# 6.0 (5th Edition)» (Addison-Wesley, 2015), которая доступна сейчас на itl.tc/EssentialCSharp.


Марк Михейлис*(Mark Michaelis) — учредитель IntelliTect, где является главным техническим архитектором и тренером. Почти два десятилетия был Microsoft MVP и региональным директором Microsoft с 2007 года. Работал в нескольких группах рецензирования проектов программного обеспечения Microsoft, в том числе C#, Microsoft Azure, SharePoint и Visual Studio ALM. Выступает на конференциях разработчиков, автор множества книг, последняя из которых — «Essential C# 6.0 (5th Edition)». С ним можно связаться в Facebook (facebook.com/Mark.Michaelis), через его блог (IntelliTect.com/Mark), в Twitter (@markmichaelis) или по электронной почте mark@IntelliTect.com.*

Выражаю благодарность за рецензирование статьи экспертам Кевину Босту (Kevin Bost), Джейсону Питерсону (Jason Peterson) и Мэдсу Торгерсону (Mads Torgerson).