Обработка исключений появилась в mainstream языках программирования вот уже более трех десятилетий назад, но сегодня все еще можно встретить разработчиков, которые боятся их использовать. Некоторые считают, что генерация исключений в конструкторе повредит их хрупкой карме и их настигнет кара в виде поддержки кода двадцатилетней давности, написанного стадом безумных индусов. Некоторые все еще застряли в эпохе языка С и даже в языке C# интенсивно используют коды возврата в виде магических чисел или даже строк, считая, что исключения придумали трусы, а настоящие самураи могут обойтись и без них. (Хотя мы-то с вами знаем, что настоящие самураи следуют “Принципу самурая” и никаких кодов возврата не используют).
Дополнительную сложность добавляют конкретные платформы и языки программирования. Сегодня на собеседовании C# разработчика, когда речь заходит об обработке исключений, обязательно прозвучит вопрос: “А в чем отличие “проброса” исключения с помощью конструкций throw; и throw ex;?”. И хотя, это страшный баян и большинство разработчиков давно знают правильный ответ на этот вопрос, в реальном коде встретить “некошерный” вариант очень даже просто.
Особенность платформы .NET заключается в том, что в ней не существует (а точнее, как мы вскоре увидим – не существовало) способа перехвата исключения в одном месте и последующего его генерирования в другом. Если разработчик бизнес-приложения или библиотеки сталкивался с такой задачей, то решалась она очень простым способом: исходное исключение заворачивалось в другой объект в виде вложенного исключения и пробрасывалось уже новое исключение.
Давайте рассмотрим такой пример. Предположим у нас есть кастомный класс исключения по имени CustomException, а также простой класс SampleClass, конструктор которого генерирует это самое исключение.
Теперь давайте создадим объект этого класса с помощью generic метода, а также сделаем синглтон этого класса:
Вопрос в следующем, какой блок catch будет выполнен при вызове метода CreateInstance<SampleClass>() или при обращении к SampleClassSingleton.Instnace?
Не думаю, что для кого-то этот код будет большим откровением, но в обоих случаях “ожидаемый” блок catch(CustomException e) выполнен не будет, вместо этого будет выполнен блок catch(Exception e).
В первом случае (т.е. при использовании generic метода CreateInstance) на самом деле конструктор по умолчанию не вызывается напрямую, вместо этого используется Activator.CreateInstance, который оборачивает исходное исключение в TargetInvocationException. Именно поэтому срабатывает блок catch(Exception e), поскольку TargetInvocationException никак не подходит нашему первому блоку обработки исключений.
ПРЕДУПРЕЖДЕНИЕ
Не пишите в логи только e.Message, поскольку когда ляжет ваш продакш сервер от этой информации вам будет ни холодно ни жарко, поскольку наиболее ценная информация может таиться в одном из вложенных исключений. Даже в составе .Net Framework существует множество мест, которые оборачивают исходное исключение и пробрасывают его в качестве вложенного, ни говоря уже за кастомный код, который может обернуть нужное вам исключение в десяток вложенных.
При использовании синглтона, ситуация аналогична: поскольку инициализация поля _instance происходит в статическом конструкторе, то любые исключения, которые происходят во время его выполнения делают тип «невалидным». В результате, все последующие обращения к этому типу приводят к генерации исключения TypeLoadExceptionс соответствующим вложенным исключением
ПРИМЕЧАНИЕ Подробнее о том, для чего нужен пустой статический конструктор и к каким проблемам может привести его отсутствие см. О синглтонах и статических конструкторах.
Поведение в обоих случаях является хорошо документированным и известным, так что если когда-то его могли считать багом, то теперь это уже фича. Но исправить эту ситуацию, к сожалению, мы не можем. Даже если мы перехватим исключение в методе CreateInstance и постараемся пробросить вложенное исключение, то мы потеряем стек вызовов, что сделает результирующее исключение значительно менее полезным:
Аналогичная проблема ждет нас, если мы попробуем сохранить исключение при создании экземпляра _instance и будем пробрасывать его при обращении к свойству Instance. (Да, отложенная инициализация решит эту конкретную проблему, но сейчас речь не об этом).
Однако в .Net Framework 4.5 появился класс, способный помочь в решении этой проблемы. Это класс ExceptionDispatchInfo, способный сохранить исходное исключение и потом пробросить его заново не теряя информацию о стеке вызовов. Конечно, основной смысл его применения не связан с “выпрямлением” статических конструкторов или метода Activator.CreateInstance. Главная его задача заключается в решении вопросов асинхронности и многопоточности, когда исключение происходит в одном потоке, а пробрасывается в другом.
Давайте рассмотрим следующий код:
Если “задача” падает с исключением, то исходное исключение (в нашем случае CustomException) будет завернуто в AggregateException. Причин такому поведению несколько: во-первых, хотя задача по своей природе представляет собой оболочку над некоторой длительной асинхронной операцией с единственным результатом, в некоторых случаях результат одной задачи может основываться на результатах параллельного выполнения нескольких задач. Например, мы можем объединить несколько задач в одну с помощью Task.WaitAll или мы можем связать несколько задач с помощью продолжений. Второй причиной такого поведения является невозможность проброса исходного исключения без искажения его стека вызовов.
Однако после появления в языке C# 5.0 новых возможностей по работе с асинхронностью, приоритеты несколько изменились. Одной из главных фич ключевых слов await и async является простота преобразования синхронного кода в асинхронный, но помимо «выпрямления» потока исполнения (который даже в случае применение задач оставляет желать лучшего), должна быть решена и задача обработки исключений. Поэтому при получении результатов задачи с помощью ключевого слова await, исходное AggregateException разворачивается и пробрасывается исходное исключение:
ПРИМЕЧАНИЕ
Если вы не знакомы такими фичами языка C# 5.0, как async и await, то восполнить этот пробел можно с помощью статьи: Асинхронные операции в C# 5
Как уже было сказано, реализовано такое поведение с помощью нового класса ExceptionDispatchInfo, который позволяет сохранить исходное исключение и пробросить его позднее, возможно даже в новом потоке. Причем реализовано это настолько просто, что нам не составит труда сделать это самостоятельно.
Для начала давайте для класса Task<T> создадим класс с методом расширения GetResult, который будет очень похож на свойство Result, но будет «выпрямлять» AggregateExcpetion и пробрасывать вложенное исключение без потери стека вызовов.
ПРИМЕЧАНИЕ
Такое поведение уже реализовано в составе .Net Framework 4.5 с помощью Task<T>.GetAwaiter().GetResult(), но давайте забудем об этом и сделаем тоже самое самостоятельно.
Пользоваться классом ExceptionDispatchInfo довольно просто: для этого достаточно захватить исключение в одном месте с помощью статического метода Capture, а затем пробросить это исключение в другом месте (и, возможно даже в другом потоке) с помощью метода Throw.
Теперь, если изменить предыдущий фрагмент кода и заменить task.Result на вызов метода Task.GetResult, то мы сможем перехватывать конкретный тип исключения вместо исключения AggregateException.
Аналогичным образом можно изменить наш метод CreateInstance, который будет перехватывать исключение TargetInfocationException и пробрасывать вложенное исключение:
Теперь, при попытке вызвать этот метод из функции Main мы получим вразумительное исключение с нормальным стеком исполнения:
Класс синглтона можно реализовать подобным образом.
Класс ExceptionDispatchInfo едва ли будет киллер-фичей новой версии .Net Framework, однако с наступлением эры асинхронности у него точно найдется достойная область применения. Так, например, он уже используется в библиотеке реактивных расширений для реализации паттерна await (эта реализация доступна только в экспериментальном релизе) и может использоваться всеми, кто захочет реализовать этот паттерн самостоятельно.
Дополнительную сложность добавляют конкретные платформы и языки программирования. Сегодня на собеседовании C# разработчика, когда речь заходит об обработке исключений, обязательно прозвучит вопрос: “А в чем отличие “проброса” исключения с помощью конструкций throw; и throw ex;?”. И хотя, это страшный баян и большинство разработчиков давно знают правильный ответ на этот вопрос, в реальном коде встретить “некошерный” вариант очень даже просто.
Особенность платформы .NET заключается в том, что в ней не существует (а точнее, как мы вскоре увидим – не существовало) способа перехвата исключения в одном месте и последующего его генерирования в другом. Если разработчик бизнес-приложения или библиотеки сталкивался с такой задачей, то решалась она очень простым способом: исходное исключение заворачивалось в другой объект в виде вложенного исключения и пробрасывалось уже новое исключение.
Давайте рассмотрим такой пример. Предположим у нас есть кастомный класс исключения по имени CustomException, а также простой класс SampleClass, конструктор которого генерирует это самое исключение.
// Простое кастомное исключение, чтобы было, что перехватывать
class CustomException : Exception { }
class SampleClass
{
// Совершенно бесполезный класс,
// конструктор которого только и делает, что бросает исключение
public SampleClass() { throw new CustomException(); }
}
Теперь давайте создадим объект этого класса с помощью generic метода, а также сделаем синглтон этого класса:
// Простой фабричный метод, создающий экземпляр объекта
public static T CreateInstance<T>() where T : new()
{
return new T();
}
// Простой класс синглтона
class SampleClassSingleton
{
private static SampleClass _instance = new SampleClass();
static SampleClassSingleton() { }
public static SampleClass Instance { get { return _instance; } }
}
Вопрос в следующем, какой блок catch будет выполнен при вызове метода CreateInstance<SampleClass>() или при обращении к SampleClassSingleton.Instnace?
try
{
CreateInstance<SampleClass>();
// или
var instance = SampleClassSingleton.Instance;
}
catch (CustomException e)
{
Console.WriteLine(e);
}
catch (Exception e)
{
Console.WriteLine(e);
}
Не думаю, что для кого-то этот код будет большим откровением, но в обоих случаях “ожидаемый” блок catch(CustomException e) выполнен не будет, вместо этого будет выполнен блок catch(Exception e).
В первом случае (т.е. при использовании generic метода CreateInstance) на самом деле конструктор по умолчанию не вызывается напрямую, вместо этого используется Activator.CreateInstance, который оборачивает исходное исключение в TargetInvocationException. Именно поэтому срабатывает блок catch(Exception e), поскольку TargetInvocationException никак не подходит нашему первому блоку обработки исключений.
ПРЕДУПРЕЖДЕНИЕ
Не пишите в логи только e.Message, поскольку когда ляжет ваш продакш сервер от этой информации вам будет ни холодно ни жарко, поскольку наиболее ценная информация может таиться в одном из вложенных исключений. Даже в составе .Net Framework существует множество мест, которые оборачивают исходное исключение и пробрасывают его в качестве вложенного, ни говоря уже за кастомный код, который может обернуть нужное вам исключение в десяток вложенных.
При использовании синглтона, ситуация аналогична: поскольку инициализация поля _instance происходит в статическом конструкторе, то любые исключения, которые происходят во время его выполнения делают тип «невалидным». В результате, все последующие обращения к этому типу приводят к генерации исключения TypeLoadExceptionс соответствующим вложенным исключением
ПРИМЕЧАНИЕ Подробнее о том, для чего нужен пустой статический конструктор и к каким проблемам может привести его отсутствие см. О синглтонах и статических конструкторах.
Поведение в обоих случаях является хорошо документированным и известным, так что если когда-то его могли считать багом, то теперь это уже фича. Но исправить эту ситуацию, к сожалению, мы не можем. Даже если мы перехватим исключение в методе CreateInstance и постараемся пробросить вложенное исключение, то мы потеряем стек вызовов, что сделает результирующее исключение значительно менее полезным:
public static T CreateInstance<T>() where T : new()
{
try
{
return new T();
}
catch (TargetInvocationException e)
{
// Исходный стек вызовов потерян, теперь все будут думать, что виноваты мы!
throw e.InnerException;
}
}
Аналогичная проблема ждет нас, если мы попробуем сохранить исключение при создании экземпляра _instance и будем пробрасывать его при обращении к свойству Instance. (Да, отложенная инициализация решит эту конкретную проблему, но сейчас речь не об этом).
Однако в .Net Framework 4.5 появился класс, способный помочь в решении этой проблемы. Это класс ExceptionDispatchInfo, способный сохранить исходное исключение и потом пробросить его заново не теряя информацию о стеке вызовов. Конечно, основной смысл его применения не связан с “выпрямлением” статических конструкторов или метода Activator.CreateInstance. Главная его задача заключается в решении вопросов асинхронности и многопоточности, когда исключение происходит в одном потоке, а пробрасывается в другом.
Давайте рассмотрим следующий код:
Task<int> task = Task<int>.Factory.StartNew(() => { throw new CustomException(); });
try
{
int result = task.Result;
}
catch (CustomException e)
{
// Неа, сюда нам с вами не попасть:(
Console.WriteLine("CustomException caught: " + e);
}
catch (AggregateException e)
{
// А вот и наше исходное исключение!
var inner = e.GetBaseException();
Console.WriteLine("Aggregate excpetion caught: " + inner);
}
Если “задача” падает с исключением, то исходное исключение (в нашем случае CustomException) будет завернуто в AggregateException. Причин такому поведению несколько: во-первых, хотя задача по своей природе представляет собой оболочку над некоторой длительной асинхронной операцией с единственным результатом, в некоторых случаях результат одной задачи может основываться на результатах параллельного выполнения нескольких задач. Например, мы можем объединить несколько задач в одну с помощью Task.WaitAll или мы можем связать несколько задач с помощью продолжений. Второй причиной такого поведения является невозможность проброса исходного исключения без искажения его стека вызовов.
Однако после появления в языке C# 5.0 новых возможностей по работе с асинхронностью, приоритеты несколько изменились. Одной из главных фич ключевых слов await и async является простота преобразования синхронного кода в асинхронный, но помимо «выпрямления» потока исполнения (который даже в случае применение задач оставляет желать лучшего), должна быть решена и задача обработки исключений. Поэтому при получении результатов задачи с помощью ключевого слова await, исходное AggregateException разворачивается и пробрасывается исходное исключение:
public static async void SimpleTask()
{
Task<int> task = Task<int>.Factory.StartNew(() => { throw new CustomException(); });
try
{
// await «разворачивает» исходное исключение сгенерированное внутри задачи
// и пробрасывает именно его, а не AggregateExcpetion!
int result = await task;
}
catch (CustomException e)
{
// Теперь вызывается этот обработчик, как и в случае синхронного вызова
Console.WriteLine("CustomException caught: " + e);
}
}
ПРИМЕЧАНИЕ
Если вы не знакомы такими фичами языка C# 5.0, как async и await, то восполнить этот пробел можно с помощью статьи: Асинхронные операции в C# 5
Как уже было сказано, реализовано такое поведение с помощью нового класса ExceptionDispatchInfo, который позволяет сохранить исходное исключение и пробросить его позднее, возможно даже в новом потоке. Причем реализовано это настолько просто, что нам не составит труда сделать это самостоятельно.
Для начала давайте для класса Task<T> создадим класс с методом расширения GetResult, который будет очень похож на свойство Result, но будет «выпрямлять» AggregateExcpetion и пробрасывать вложенное исключение без потери стека вызовов.
ПРИМЕЧАНИЕ
Такое поведение уже реализовано в составе .Net Framework 4.5 с помощью Task<T>.GetAwaiter().GetResult(), но давайте забудем об этом и сделаем тоже самое самостоятельно.
Пользоваться классом ExceptionDispatchInfo довольно просто: для этого достаточно захватить исключение в одном месте с помощью статического метода Capture, а затем пробросить это исключение в другом месте (и, возможно даже в другом потоке) с помощью метода Throw.
static class TaskExtensions
{
public static T GetResult<T>(this Task<T> task)
{
try
{
T result = task.Result;
return result;
}
catch (AggregateException e)
{
ExceptionDispatchInfo di = ExceptionDispatchInfo.Capture(e.InnerException);
di.Throw();
return default(T);
}
}
}
Теперь, если изменить предыдущий фрагмент кода и заменить task.Result на вызов метода Task.GetResult, то мы сможем перехватывать конкретный тип исключения вместо исключения AggregateException.
Task<int> task = Task<int>.Factory.StartNew(() => { throw new CustomException(); });
try
{
int result = task.GetResult();
}
catch (CustomException e)
{
// Теперь мы можем перехватывать CustomException, а не AggregateException
Console.WriteLine("CustomException caught: " + e);
}
Аналогичным образом можно изменить наш метод CreateInstance, который будет перехватывать исключение TargetInfocationException и пробрасывать вложенное исключение:
public static T CreateInstance<T>() where T : new()
{
try
{
var t = new T();
return t;
}
catch (TargetInvocationException e)
{
// Захватываем вложенное исключение в ExceptionDispatchInfo
ExceptionDispatchInfo di = ExceptionDispatchInfo.Capture(e.InnerException);
// Пробрасываем это исключение с сохранением всей информации
di.Throw();
// Компилятор не знает, что di.Throws() всегда генерит исключение, поэтому
// без этой строки кода мы получим ошибку компиляции, что метод не всегда
// возвращает результат
return default(T);
}
}
Теперь, при попытке вызвать этот метод из функции Main мы получим вразумительное исключение с нормальным стеком исполнения:
ConsoleApplication1.CustomException: Exception of type 'ConsoleApplication1.CustomException' was thrown.<br>at ConsoleApplication1.SampleClass..ctor() in <br>c:\Projects\ConsoleApplication1\ConsoleApplication1\Program.cs:line 19<br>--- End of stack trace from previous location where exception was thrown ---<br>at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()<br>at ConsoleApplication1.Program.CreateInstance[T]() in c<br>\Projects\ConsoleApplication1\ConsoleApplication1\Program.cs:<br>line 50<br>at ConsoleApplication1.Program.Main(String[] args) in c:\ \Projects\ConsoleApplication1\ConsoleApplication1\Program.cs:line 63
Класс синглтона можно реализовать подобным образом.
Класс ExceptionDispatchInfo едва ли будет киллер-фичей новой версии .Net Framework, однако с наступлением эры асинхронности у него точно найдется достойная область применения. Так, например, он уже используется в библиотеке реактивных расширений для реализации паттерна await (эта реализация доступна только в экспериментальном релизе) и может использоваться всеми, кто захочет реализовать этот паттерн самостоятельно.