Ошибки в обработке ошибок являются наиболее распространенным источником ошибок
Бредня, пришедшая в голову при написании этой статьи
Основные баталии по поводу того, что лучше использовать при программировании на C# – исключения или коды возврата для обработки, ушли в далекое прошлое (*), но до сих пор не утихают баталии другого рода: да, хорошо, мы остановились на обработке исключений, но как же нам их обрабатывать «правильно»?
Существует множество точек зрения о том, что же такое «правильно», большая часть из которых сводится к тому, что нужно перехватывать только те исключения, которые ты можешь обработать, а все остальные пробрасывать вызывающему коду. Ну, а в случае, если на верхний уровень пробралось дерзким образом непонятное исключение, то стрелять все приложение целиком, поскольку уже не понятно, находится ли оно, родимое, в согласованном состоянии или нет.
Существует множество «за» и «против» такого способа перехвата и обработки исключений, но сегодня я хочу рассмотреть несколько другую тему. А именно тему обеспечения согласованного состояния приложения в свете возникновения исключения – три уровня безопасности исключений.
В конце 90-х годов Дейв Абрахамс (Dave Abrahams) предложил три уровня безопасности исключений: базовая гарантия, строгая гарантия и гарантия отсутствия исключений. Эта идея была тепло встречена сообществом С++ разработчиков, а после ее популяризации (и некоторой модификации) Гербом Саттером, гарантии безопасности исключений стали широко применяться в boost-е, в стандартной библиотеке С++, а также при разработке прикладных приложений.
Изначально эти гарантии были предложены Дейвом Абрахамсом для реализации библиотеки STLPort на языке С++, но сама идея безопасности исключений не привязана к конкретному языку программирования и может использоваться в других языках, использующих исключения в качестве основного механизма обработки ошибок, таких как Java или C#. Кроме того, в настоящее время существует две версии определений гарантии безопасности исключений: (1) исходная версия, предложенная Дейвом Абрахамсом и (2) модифицированная версия, популяризированная Саттером и Страуструпом, и более подходящая не только для библиотек, но и для прикладных приложений.
Исходное определение: “в случае возникновения исключений не должно быть никаких утечек ресурсов”.
Современное определение: “при возникновении любого исключения в некотором методе, состояние программы должно оставаться согласованным”. Это означает, не только отсутствие утечек ресурсов, но и сохранение инвариантов класса, что является более общим критерием, по сравнению с базовым определением.
Разница между этими двумя формулировками обусловлена тем, что изначально эта гарантия были предложена для реализации библиотеки на языке С++ и не имела никакого отношения к прикладным приложениями. Но если говорить о более общем случае (т.е. о приложении, а не только о библиотеке), то можно сказать, что утечки ресурсов является лишь одним из источников багов, но далеко не единственным. Сохранение инварианта в любой устойчивый момент времени (**) является залогом того, что никакой внешний код не сможет «увидеть» рассогласованного состояния приложения, что, согласитесь, не менее важно, чем отсутствие утечек ресурсов. Мало какого пользователя банковского приложения будут интересовать утечки памяти, если при переводе денег с одного счета на другой, деньги могут «уйти» с одного счета, но «не дойти» до другого.
Что касается определения строгой гарантии исключений, то исходное и современные определения являются аналогичными и сводятся к следующему: “если при выполнении операции возникает исключение, то это не должно оказать какого-либо влияния на состояние приложения ”.
Другими словами, строгая гарантия исключений обеспечивает транзакционность операций, когда мы получаем либо все, либо ничего. В этом случае, при возникновении исключения мы должны откатиться к состоянию приложения, которое было перед выполнением операции, и переходить в новое состояние только в случае удачного завершения всей операции.
Гарантия отсутствия исключений сводится к следующему: “ни при каких обстоятельствах функция не будет генерировать исключения”.
Эта гарантия наиболее простая с точки зрения определения, однако, она не так проста, как кажется. Во-первых, ее практически невозможно обеспечить в общем случае, особенно в среде .Net, когда исключение может произойти практически в любой точке приложения. На практике, лишь единицы операций следуют этой гарантии, и именно на основании таких операций строятся гарантии предыдущих уровней. В языке C#, то одной из немногих операций, обеспечивающих эту гарантию, является присваивание ссылок, а в языке C++ — функция swap, реализующая обмен значений. Именно на основании этих функций зачастую и реализуется строгая гарантия исключений, когда вся «грязная работа» выполняется во временном объекте, который затем присваивается результирующему значению.
Во-вторых, в некоторых случаях невозможно обеспечить нормальную работу других функций, если некоторые операции не будут следовать гарантии отсутствия исключений. Так, например, в языке С++, для обеспечения даже базовой гарантии исключений (а точнее утечки ресурсов) в контейнерах необходимо, чтобы деструктор пользовательского типа не генерировал исключения.
Рассмотренные выше три гарантии безопасности исключений идут от наиболее слабой к наиболее сильной; при этом каждая последующая гарантия является надмножеством предыдущей. Это означает, что выполнение строгой гарантии автоматом влечет за собой выполнение базовой гарантии, а гарантия отсутствия исключений влечет за собой выполнение строгой гарантии. Если же код не отвечает даже базовой гарантии исключений, то он является миной замедленного действия в вашем приложении и рано или поздно приведет к неприятным последствиям, развалив к чертям его состояние.
Теперь давайте рассмотрим несколько примеров.
Главным способом предотвращения утечек памяти и ресурсов в языке C++ является идиома RAII (Resource Acquisition Is Initialization), которая заключается в том, что объект захватывает ресурс в конструкторе и освобождает его в деструкторе. А поскольку вызов деструктора осуществляется автоматически при выходе объекта из области видимости по любой причине, в том числе и при возникновении исключения, то неудивительно, что эта же идиома используется и для обеспечения безопасности исключений.
В язык C# эта идиома перекочевала в виде интерфейса IDisposable и конструкции using, однако, в отличие от С++ она применима для управления временем жизни ресурса в некоторой области видимости, и не подходит для управления множеством ресурсов, захватываемых в конструкторе.
Давайте рассмотрим такой пример:
Итак, у нас есть два disposable-класса: DisposableA и DisposableB, каждый из которых захватывает некоторый управляемый ресурс в конструкторе и освобождает его в методе Dispose. Давайте пока не будем рассматривать финализатор, поскольку он никак не поможет нам гарантировать детерминированный порядок освобождения ресурсов, что в некотором случае является жизненно важным.
В данном случае, при генерации исключения конструктором класса DisposableB мы никогда не вызовем метод Dispose, поскольку объект disposable никогда не существовал. В этом плане поведение у большинства mainstream языков программирования более или менее одинаковое, но есть и некоторые отличия. Сходство заключается в том, что если конструктор «упадет», то вызывающий код так и не сможет получить ссылку на еще не сконструированный объект и явно освободить его ресурсы. Однако, в отличие от языка С++, в котором вызов деструктора полностью сконструированных полей осуществляется автоматически, в «управляемом» языке C# этого не происходит (***).Если конструктор класса DisposableB сгенерирует исключение и не освободит уже захваченные ранее ресурсы самостоятельно, мы получим «утечку ресурсов» (или, как минимум, недетерминированное их освобождение).
Эта же проблема может проявляться и более тонким образом. В рассмотренном ранее случае, явно видно, что мы создали экземпляр disposable-объекта, после чего генерируется исключение. Но бывают случаи, когда отсутствие базовой гарантии исключений увидеть немного сложнее.
Генерация исключения в конструкторе класса Derived нарушает базовую гарантию исключений и приводит к утечке ресурсов, поскольку метод Dispose класса Base не вызывается (****). Опять таки, поскольку компилятор знает об интерфейсе IDisposable только через призму конструкции using, то во всех случаях, когда disposable объект является полем другого класса, за вызов метода Dispose отвечает только программист.
Помимо базового класса, подобную же шутку может сыграть и инициализаторы полей, когда конструктор одного из полей может генерировать исключение:
В данном случае, если конструктор класса DisposableB при инициализации поля disposableB сгенерирует исключение, то перехватить его и освободить уже захваченные ресурсы будет невозможно. В С++ существует такая вещь, как перехват исключений, возникших в списке инициализации (см. Exception and Member Initialization), однако такая возможность в языке C# отсутствует, поэтому выход из этой ситуации один: старайтесь ее не допускать.
Что касается всех предыдущих случаев, то обеспечение базовой гарантии исключений полностью ложится на плечи разработчика, поскольку никакого «сахара» для этих целей язык C# не предоставляет. Все, что нам остается, это либо создавать подобъекты в нужном порядке и disposable поле создавать в самом конце конструктора, либо оборачивать их создание в блок try/catch и очищать все ресурсы, в случае возникновения исключения.
Примеры нарушения базовой гарантии исключений, приведенные выше, хоть и не являются надуманными, но встречаются не так и часто. И если в случае создания объектов, содержащих несколько управляемых ресурсов, компилятор языка C# ничем нам помочь не может, то он может помочь в некоторых других случаях, например, при создании объектов и коллекций.
Инициализатор объектов и коллекций (object initializer и collection initializer) обеспечивают атомарность создания и инициализации объекта или заполнения коллекции списком элементов. Давайте рассмотрим следующий пример.
С первого взгляда может показаться, что это всего лишь синтаксический сахар для следующего:
Однако на самом деле, при вызове инициализатора объекта создается временная переменная, затем изменяются свойства именно этой переменной, и только потом она присваивается новому объекту:
Это обеспечивает атомарность процесса создания объекта и невозможность использования частично инициализированного объекта в случае генерации исключения одним из setter-ов. Аналогичный принцип лежит и в инициализаторе коллекций, когда объекты добавляются во временную коллекцию и лишь после ее заполнения временная переменная присваивается новому объекту.
Принцип, заложенный в основе этих двух концепций, может легко быть использован при собственной реализации строгой гарантии исключений в собственном коде. Для этого достаточно все изменения внутреннего состояния объекта выполнять в некоторой временной переменной и лишь после их завершения атомарно изменять его реальное состояние.
Корректная обработка исключений дело не простое, и, как показали некоторые примеры, иногда даже базовая гарантия исключений дается с трудом. Однако обеспечение подобных гарантий является жизненно необходимым условием при разработке приложений, поскольку значительно легче всю сложность по работе ресурсами упрятать в одном месте, нежели размазать ее тонким слоем по всему приложению. Золотое правило, сформулированное десяток лет назад Скоттом Мейерсом все еще остается в силе: создавайте классы, которые легко использовать правильно и сложно использовать неправильно, и гарантия исключений в этом играет явно не последнюю роль.
Если говорить о практическом применении данных гарантий, то следует помнить несколько моментов. Во-первых, код, не выполняющий базовую гарантию исключений некорректен; на его основе просто невозможно создать приложение, чье состояние не будет разламываться при его использовании или изменении (*****). Во-вторых, не стоит параноить и добиваться максимальной гарантии. Добиться гарантии отсутствия исключений на 100% вообще практически невозможно из-за наличия асинхронных исключений, но даже реализация строгой гарантии во многих случаях может быть неоправданно дорогой.
В заключение можно сказать следующее: гарантии безопасности исключений – не панацея, но отличный фундамент для построения надежных приложений.
----------------------
(*) На самом деле, «горячих» дебатов, в общем-то, и не было по одной простой причине: вы не можете программировать на платформе .Net без обработки исключений. Подобные дебаты актуальны, например, в языке C++, особенно, когда речь заходит о низкоуровневом программировании.
(**) В общем случае, никто не требует сохранение инварианта всегда; обычно требуется сохранение инварианта «до» и «после» вызова открытого метода, но совсем необязательно его сохранение после вызова закрытого метода, выполняющего лишь «часть» работы.
(***) Может показаться весьма забавным тот факт, что более «навороченный» язык, такой как C# может не делать чего-то, что делает старина С++, но это действительно так. Давайте в качестве примера, перепишем рассмотренный ранее код с C# на C++:
Как уже было сказано ранее в языке С++ (в отличие от языка C#), при генерации исключения в конструкторе класса деструкторы уже сконструированных полей (т.е. подобъектов) будут вызваны автоматически. Это значит, что в данном случае вызов деструктора объекта Resource1 произойдет автоматически и никаких утечек ресурсов не будет.
Такие отличия в поведения языков C# и C++ легко объяснимо. В языке С++ ресурсом является все, включая динамически выделенную память, поэтому и средства управления ресурсами находятся на более высоком уровне. Прикладной же программист, работающий с языком C#, значительно чаще использует ресурсы в блоке using, нежели захватывает ресурсы в конструкторе. И если же он сталкивается с такой задачей, то решить ее придется ему самостоятельно, без помощи компилятора.
Кстати, Герб Саттер уже рассказывал об этом когда-то в своей заметке: “Constructor Exceptions in C++, C#, and Java”.
(****) Может я уже и достал с этими примечаниями, но это достаточно важное и, кажется, предпоследнее. Подобный пример достаточно часто любят задавать на собеседованиях, так что теперь, мои читатели знают на него правильный ответ!
(*****) Все сказанное в этой статье относится только к синхронным исключениям, поскольку гарантировать согласованное возникновении «асинхронных» исключений, таких как OutOfMemoryException или ThreadAbortException практически невозможно. За пруфом сюда: «О вреде метода Thread.Abort».
Бредня, пришедшая в голову при написании этой статьи
Основные баталии по поводу того, что лучше использовать при программировании на C# – исключения или коды возврата для обработки, ушли в далекое прошлое (*), но до сих пор не утихают баталии другого рода: да, хорошо, мы остановились на обработке исключений, но как же нам их обрабатывать «правильно»?
Существует множество точек зрения о том, что же такое «правильно», большая часть из которых сводится к тому, что нужно перехватывать только те исключения, которые ты можешь обработать, а все остальные пробрасывать вызывающему коду. Ну, а в случае, если на верхний уровень пробралось дерзким образом непонятное исключение, то стрелять все приложение целиком, поскольку уже не понятно, находится ли оно, родимое, в согласованном состоянии или нет.
Существует множество «за» и «против» такого способа перехвата и обработки исключений, но сегодня я хочу рассмотреть несколько другую тему. А именно тему обеспечения согласованного состояния приложения в свете возникновения исключения – три уровня безопасности исключений.
Три типа гарантий
В конце 90-х годов Дейв Абрахамс (Dave Abrahams) предложил три уровня безопасности исключений: базовая гарантия, строгая гарантия и гарантия отсутствия исключений. Эта идея была тепло встречена сообществом С++ разработчиков, а после ее популяризации (и некоторой модификации) Гербом Саттером, гарантии безопасности исключений стали широко применяться в boost-е, в стандартной библиотеке С++, а также при разработке прикладных приложений.
Изначально эти гарантии были предложены Дейвом Абрахамсом для реализации библиотеки STLPort на языке С++, но сама идея безопасности исключений не привязана к конкретному языку программирования и может использоваться в других языках, использующих исключения в качестве основного механизма обработки ошибок, таких как Java или C#. Кроме того, в настоящее время существует две версии определений гарантии безопасности исключений: (1) исходная версия, предложенная Дейвом Абрахамсом и (2) модифицированная версия, популяризированная Саттером и Страуструпом, и более подходящая не только для библиотек, но и для прикладных приложений.
Базовая гарантия
Исходное определение: “в случае возникновения исключений не должно быть никаких утечек ресурсов”.
Современное определение: “при возникновении любого исключения в некотором методе, состояние программы должно оставаться согласованным”. Это означает, не только отсутствие утечек ресурсов, но и сохранение инвариантов класса, что является более общим критерием, по сравнению с базовым определением.
Разница между этими двумя формулировками обусловлена тем, что изначально эта гарантия были предложена для реализации библиотеки на языке С++ и не имела никакого отношения к прикладным приложениями. Но если говорить о более общем случае (т.е. о приложении, а не только о библиотеке), то можно сказать, что утечки ресурсов является лишь одним из источников багов, но далеко не единственным. Сохранение инварианта в любой устойчивый момент времени (**) является залогом того, что никакой внешний код не сможет «увидеть» рассогласованного состояния приложения, что, согласитесь, не менее важно, чем отсутствие утечек ресурсов. Мало какого пользователя банковского приложения будут интересовать утечки памяти, если при переводе денег с одного счета на другой, деньги могут «уйти» с одного счета, но «не дойти» до другого.
Строгая гарантия
Что касается определения строгой гарантии исключений, то исходное и современные определения являются аналогичными и сводятся к следующему: “если при выполнении операции возникает исключение, то это не должно оказать какого-либо влияния на состояние приложения ”.
Другими словами, строгая гарантия исключений обеспечивает транзакционность операций, когда мы получаем либо все, либо ничего. В этом случае, при возникновении исключения мы должны откатиться к состоянию приложения, которое было перед выполнением операции, и переходить в новое состояние только в случае удачного завершения всей операции.
Гарантия отсутствия исключений
Гарантия отсутствия исключений сводится к следующему: “ни при каких обстоятельствах функция не будет генерировать исключения”.
Эта гарантия наиболее простая с точки зрения определения, однако, она не так проста, как кажется. Во-первых, ее практически невозможно обеспечить в общем случае, особенно в среде .Net, когда исключение может произойти практически в любой точке приложения. На практике, лишь единицы операций следуют этой гарантии, и именно на основании таких операций строятся гарантии предыдущих уровней. В языке C#, то одной из немногих операций, обеспечивающих эту гарантию, является присваивание ссылок, а в языке C++ — функция swap, реализующая обмен значений. Именно на основании этих функций зачастую и реализуется строгая гарантия исключений, когда вся «грязная работа» выполняется во временном объекте, который затем присваивается результирующему значению.
Во-вторых, в некоторых случаях невозможно обеспечить нормальную работу других функций, если некоторые операции не будут следовать гарантии отсутствия исключений. Так, например, в языке С++, для обеспечения даже базовой гарантии исключений (а точнее утечки ресурсов) в контейнерах необходимо, чтобы деструктор пользовательского типа не генерировал исключения.
Рассмотренные выше три гарантии безопасности исключений идут от наиболее слабой к наиболее сильной; при этом каждая последующая гарантия является надмножеством предыдущей. Это означает, что выполнение строгой гарантии автоматом влечет за собой выполнение базовой гарантии, а гарантия отсутствия исключений влечет за собой выполнение строгой гарантии. Если же код не отвечает даже базовой гарантии исключений, то он является миной замедленного действия в вашем приложении и рано или поздно приведет к неприятным последствиям, развалив к чертям его состояние.
Теперь давайте рассмотрим несколько примеров.
Примеры нарушения базовой гарантии
Главным способом предотвращения утечек памяти и ресурсов в языке C++ является идиома RAII (Resource Acquisition Is Initialization), которая заключается в том, что объект захватывает ресурс в конструкторе и освобождает его в деструкторе. А поскольку вызов деструктора осуществляется автоматически при выходе объекта из области видимости по любой причине, в том числе и при возникновении исключения, то неудивительно, что эта же идиома используется и для обеспечения безопасности исключений.
В язык C# эта идиома перекочевала в виде интерфейса IDisposable и конструкции using, однако, в отличие от С++ она применима для управления временем жизни ресурса в некоторой области видимости, и не подходит для управления множеством ресурсов, захватываемых в конструкторе.
Давайте рассмотрим такой пример:
// Некоторый класс, содержащий управляемые ресурсы
class DisposableA : IDisposable
{
public void Dispose() {}
}
// Еще один класс с управляемыми ресурсами
class DisposableB : IDisposable
{
public DisposableB()
{
disposableA = new DisposableA();
throw new Exception("OOPS!");
}
public void Dispose() {}
private DisposableA disposableA;
}
// Где-то в приложении
using (var disposable = new DisposableB())
{
// Упс! Метод Dispose не будет вызван ни для
// DisposableB, ни для DisposableA
}
* This source code was highlighted with Source Code Highlighter.
Итак, у нас есть два disposable-класса: DisposableA и DisposableB, каждый из которых захватывает некоторый управляемый ресурс в конструкторе и освобождает его в методе Dispose. Давайте пока не будем рассматривать финализатор, поскольку он никак не поможет нам гарантировать детерминированный порядок освобождения ресурсов, что в некотором случае является жизненно важным.
В данном случае, при генерации исключения конструктором класса DisposableB мы никогда не вызовем метод Dispose, поскольку объект disposable никогда не существовал. В этом плане поведение у большинства mainstream языков программирования более или менее одинаковое, но есть и некоторые отличия. Сходство заключается в том, что если конструктор «упадет», то вызывающий код так и не сможет получить ссылку на еще не сконструированный объект и явно освободить его ресурсы. Однако, в отличие от языка С++, в котором вызов деструктора полностью сконструированных полей осуществляется автоматически, в «управляемом» языке C# этого не происходит (***).Если конструктор класса DisposableB сгенерирует исключение и не освободит уже захваченные ранее ресурсы самостоятельно, мы получим «утечку ресурсов» (или, как минимум, недетерминированное их освобождение).
Эта же проблема может проявляться и более тонким образом. В рассмотренном ранее случае, явно видно, что мы создали экземпляр disposable-объекта, после чего генерируется исключение. Но бывают случаи, когда отсутствие базовой гарантии исключений увидеть немного сложнее.
class Base : IDisposable
{
public Base()
{
// Захватываем некоторый ресурс
}
public void Dispose() {}
}
class Derived : Base, IDisposable
{
public Derived(object data)
{
if (data == null)
throw new ArgumentNullException("data");
// OOPS!!
}
}
// И снова где-то в приложении
using (var derived = new Derived(null))
{}
* This source code was highlighted with Source Code Highlighter.
Генерация исключения в конструкторе класса Derived нарушает базовую гарантию исключений и приводит к утечке ресурсов, поскольку метод Dispose класса Base не вызывается (****). Опять таки, поскольку компилятор знает об интерфейсе IDisposable только через призму конструкции using, то во всех случаях, когда disposable объект является полем другого класса, за вызов метода Dispose отвечает только программист.
Помимо базового класса, подобную же шутку может сыграть и инициализаторы полей, когда конструктор одного из полей может генерировать исключение:
class ComposedDisposable : IDisposable
{
public void Dispose() {}
private readonly DisposableA disposableA = new DisposableA();
// А что, если конструктор DisposableB упадет? OOPS!!
private readonly DisposableB disposableB = new DisposableB();
}
* This source code was highlighted with Source Code Highlighter.
В данном случае, если конструктор класса DisposableB при инициализации поля disposableB сгенерирует исключение, то перехватить его и освободить уже захваченные ресурсы будет невозможно. В С++ существует такая вещь, как перехват исключений, возникших в списке инициализации (см. Exception and Member Initialization), однако такая возможность в языке C# отсутствует, поэтому выход из этой ситуации один: старайтесь ее не допускать.
Что касается всех предыдущих случаев, то обеспечение базовой гарантии исключений полностью ложится на плечи разработчика, поскольку никакого «сахара» для этих целей язык C# не предоставляет. Все, что нам остается, это либо создавать подобъекты в нужном порядке и disposable поле создавать в самом конце конструктора, либо оборачивать их создание в блок try/catch и очищать все ресурсы, в случае возникновения исключения.
Пример строгой гарантии исключений. Object initializer и collection initializer
Примеры нарушения базовой гарантии исключений, приведенные выше, хоть и не являются надуманными, но встречаются не так и часто. И если в случае создания объектов, содержащих несколько управляемых ресурсов, компилятор языка C# ничем нам помочь не может, то он может помочь в некоторых других случаях, например, при создании объектов и коллекций.
Инициализатор объектов и коллекций (object initializer и collection initializer) обеспечивают атомарность создания и инициализации объекта или заполнения коллекции списком элементов. Давайте рассмотрим следующий пример.
class Person
{
public string FirstName { get; set; }
public string LastName { get; set; }
public int Age { get; set; }
}
var person = new Person
{
FirstName = "Bill",
LastName = "Gates",
Age = 55,
};
* This source code was highlighted with Source Code Highlighter.
С первого взгляда может показаться, что это всего лишь синтаксический сахар для следующего:
var person = new Person();
person.FirstName = "Bill";
person.LastName = "Gates";
person.Age = 55;
* This source code was highlighted with Source Code Highlighter.
Однако на самом деле, при вызове инициализатора объекта создается временная переменная, затем изменяются свойства именно этой переменной, и только потом она присваивается новому объекту:
var tmpPerson = new Person();
tmpPerson.FirstName = "Bill";
tmpPerson.LastName = "Gates";
tmpPerson.Age = 55;
var person = tmpPerson;
* This source code was highlighted with Source Code Highlighter.
Это обеспечивает атомарность процесса создания объекта и невозможность использования частично инициализированного объекта в случае генерации исключения одним из setter-ов. Аналогичный принцип лежит и в инициализаторе коллекций, когда объекты добавляются во временную коллекцию и лишь после ее заполнения временная переменная присваивается новому объекту.
Принцип, заложенный в основе этих двух концепций, может легко быть использован при собственной реализации строгой гарантии исключений в собственном коде. Для этого достаточно все изменения внутреннего состояния объекта выполнять в некоторой временной переменной и лишь после их завершения атомарно изменять его реальное состояние.
Заключение
Корректная обработка исключений дело не простое, и, как показали некоторые примеры, иногда даже базовая гарантия исключений дается с трудом. Однако обеспечение подобных гарантий является жизненно необходимым условием при разработке приложений, поскольку значительно легче всю сложность по работе ресурсами упрятать в одном месте, нежели размазать ее тонким слоем по всему приложению. Золотое правило, сформулированное десяток лет назад Скоттом Мейерсом все еще остается в силе: создавайте классы, которые легко использовать правильно и сложно использовать неправильно, и гарантия исключений в этом играет явно не последнюю роль.
Если говорить о практическом применении данных гарантий, то следует помнить несколько моментов. Во-первых, код, не выполняющий базовую гарантию исключений некорректен; на его основе просто невозможно создать приложение, чье состояние не будет разламываться при его использовании или изменении (*****). Во-вторых, не стоит параноить и добиваться максимальной гарантии. Добиться гарантии отсутствия исключений на 100% вообще практически невозможно из-за наличия асинхронных исключений, но даже реализация строгой гарантии во многих случаях может быть неоправданно дорогой.
В заключение можно сказать следующее: гарантии безопасности исключений – не панацея, но отличный фундамент для построения надежных приложений.
----------------------
(*) На самом деле, «горячих» дебатов, в общем-то, и не было по одной простой причине: вы не можете программировать на платформе .Net без обработки исключений. Подобные дебаты актуальны, например, в языке C++, особенно, когда речь заходит о низкоуровневом программировании.
(**) В общем случае, никто не требует сохранение инварианта всегда; обычно требуется сохранение инварианта «до» и «после» вызова открытого метода, но совсем необязательно его сохранение после вызова закрытого метода, выполняющего лишь «часть» работы.
(***) Может показаться весьма забавным тот факт, что более «навороченный» язык, такой как C# может не делать чего-то, что делает старина С++, но это действительно так. Давайте в качестве примера, перепишем рассмотренный ранее код с C# на C++:
class Resource1
{
public:
Resource1()
{
// Захватываем некоторый ресурс, будь-то выделяем память
// в куче или создаем дескриптор ОС
}
~Resource1()
{
// Освобождаем захваченный ресурс
}
};
class Resource2
{
public:
Resource2()
{
// В этой точке кода объект resource1_ уже проинициализирован
throw std::exception("Yahoo!");
}
private:
Resource1 resource1_;
};
// где-то в приложении создаем экземпляр класса Resource2
Resource2 resource2;
* This source code was highlighted with Source Code Highlighter.
Как уже было сказано ранее в языке С++ (в отличие от языка C#), при генерации исключения в конструкторе класса деструкторы уже сконструированных полей (т.е. подобъектов) будут вызваны автоматически. Это значит, что в данном случае вызов деструктора объекта Resource1 произойдет автоматически и никаких утечек ресурсов не будет.
Такие отличия в поведения языков C# и C++ легко объяснимо. В языке С++ ресурсом является все, включая динамически выделенную память, поэтому и средства управления ресурсами находятся на более высоком уровне. Прикладной же программист, работающий с языком C#, значительно чаще использует ресурсы в блоке using, нежели захватывает ресурсы в конструкторе. И если же он сталкивается с такой задачей, то решить ее придется ему самостоятельно, без помощи компилятора.
Кстати, Герб Саттер уже рассказывал об этом когда-то в своей заметке: “Constructor Exceptions in C++, C#, and Java”.
(****) Может я уже и достал с этими примечаниями, но это достаточно важное и, кажется, предпоследнее. Подобный пример достаточно часто любят задавать на собеседованиях, так что теперь, мои читатели знают на него правильный ответ!
(*****) Все сказанное в этой статье относится только к синхронным исключениям, поскольку гарантировать согласованное возникновении «асинхронных» исключений, таких как OutOfMemoryException или ThreadAbortException практически невозможно. За пруфом сюда: «О вреде метода Thread.Abort».