понедельник, 15 августа 2011 г.

Гарантии безопасности исключений

Ошибки в обработке ошибок являются наиболее распространенным источником ошибок
Бредня, пришедшая в голову при написании этой статьи

Основные баталии по поводу того, что лучше использовать при программировании на 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
}

Итак, у нас есть два 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))
{}

Генерация исключения в конструкторе класса 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();
}

В данном случае, если конструктор класса 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,
};

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

var person = new Person();
person.FirstName = "Bill";
person.LastName = "Gates";
person.Age = 55;

Однако на самом деле, при вызове инициализатора объекта создается временная переменная, затем изменяются свойства именно этой переменной, и только потом она присваивается новому объекту:

var tmpPerson = new Person();
tmpPerson.FirstName = "Bill";
tmpPerson.LastName = "Gates";
tmpPerson.Age = 55;
var person = tmpPerson;

Это обеспечивает атомарность процесса создания объекта и невозможность использования частично инициализированного объекта в случае генерации исключения одним из 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;

Как уже было сказано ранее в языке С++ (в отличие от языка C#), при генерации исключения в конструкторе класса деструкторы уже сконструированных полей (т.е. подобъектов) будут вызваны автоматически. Это значит, что в данном случае вызов деструктора объекта Resource1 произойдет автоматически и никаких утечек ресурсов не будет.

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

Кстати, Герб Саттер уже рассказывал об этом когда-то в своей заметке: “Constructor Exceptions in C++, C#, and Java”.

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

(*****) Все сказанное в этой статье относится только к синхронным исключениям, поскольку гарантировать согласованное возникновении «асинхронных» исключений, таких как OutOfMemoryException или ThreadAbortException практически невозможно. За пруфом сюда: «О вреде метода Thread.Abort».

8 комментариев:

  1. Сергей, я тебя огорчу, но последний пример с class Resource - не работает(во всяком случае VS2010). Чтобы это проверить вставь в деструктор Resource1(){ cout << "Destoructor Resource1"}. Запусти код и ты увидишь, что деструктор - не вызывается.

    ОтветитьУдалить
  2. Hi, please test following code (sorry for my poor english, by I can't use russian letters here).

    Try to run following code:

    #include "stdafx.h"
    #include

    class Resource1
    {
    public:
    Resource1()
    {
    std::cout<<"Resource1::ctor"<<std::endl;
    // Захватываем некоторый ресурс, будь-то выделяем память
    // в куче или создаем дескриптор ОС
    }
    ~Resource1()
    {
    // Освобождаем захваченный ресурс
    std::cout<<"Resource1::dtor"<<std::endl;
    }
    };

    class Resource2
    {
    public:
    Resource2()
    {
    std::cout<<"Resource2::ctor"<<std::endl;
    // В этой точке кода объект resource1_ уже проинициализирован
    throw std::exception("Yahoo!");
    }
    private:
    Resource1 resource1_;
    };

    int _tmain(int argc, _TCHAR* argv[])
    {
    try
    {
    Resource2 r2;
    }
    catch(std::exception &e)
    {
    std::cout<<"Exception catched: "<<e.what()<<std::endl;
    }
    std::cin.get();
    return 0;
    }

    I got following results:

    Resource1::ctor
    Resource2::ctor
    Resource1::dtor
    Exception catched: Yahoo!

    ОтветитьУдалить
  3. Хм, понятно в чем дело. Я не перехватывал исключение в main. Попробуй убери try/catch из main. Увидишь другой результат. Хотя согласен - это не совсем честно. Ок. Тогда вопрос закрыт.

    ОтветитьУдалить
  4. В C# тоже есть деструкторы и правильно вызывать Dispose И из деструктора тоже, а если Dispose вызвался явно, то объект надо выкинуть из очереди объектов завершения GC. Для примера можно посмотреть на класс System.ComponentModel.Component.

    ОтветитьУдалить
  5. @Alexn: не нужно слепо следовать рекомендациям, и Disposable паттерн не исключение. Финализатор (а не деструктор) нужно объявлять только если есть "неуправляемые" ресурсы, а в случае "управляемых" ресурсов финализатор никак не поможет.

    ОтветитьУдалить
  6. Я конечно извиняюсь, может мой вопрос не совсем по теме, но все же интересно, а что же такого можно делать в Dispose(), что не исполнение этого метода может сильно повлиять на инвариант. Если там очищаются управляемые ресурсы, то они и так очистятся GC, а если есть не управляемые ресурсы, то должен быть реализован финализатор по правильному. Неужели в Dispose засовывают какие - нибудь алгоритмы ? Я просто не встречался с подобным или не обращал внимание раньше, поэтому интересно, когда это используется. И как же правильно делать, реализовывать в конструкторе блок try/catch ?

    ОтветитьУдалить
  7. Вот этот код:

    class Base : IDisposable
    {
    public Base()
    {
    // Захватываем некоторый ресурс
    }
    public void Dispose() {}
    }

    class Derived : Base, IDisposable
    {
    public Derived(object data)
    {
    if (data == null)
    throw new ArgumentNullException("data");
    // OOPS!!
    }
    }

    ,как я понимаю, получается вообще не верным, т.е. в том смысле, что тогда получается нельзя кидать исключение в конструкторе, если класс наследуется от класса, кот. реализует IDisposable, либо необходимо делать так: base.Dispose(); throw new Exception(); Так ведь ?

    ОтветитьУдалить
  8. Артем> что же такого можно делать в Dispose(), что не исполнение этого метода может сильно повлиять на инвариант.

    Закрытие ресурсов ОС (файлов, именованных примитивов синхронизации и т.д.), на доступность которых рассчитывает соседний код. В результате из-за ошибки в одном месте (отсутствии вызова Dispose), мы получим ошибку совершенно в другом месте, что приведет к такому себе эффекту бабочки.

    Артем> И как же правильно делать?
    На самом деле, такая проблема происходит не так и часто. Если следовать тому же принципу единой ответственности и прятать ресурсы в объекты, которые толком ничего не делают, то этой проблемы не будет. Достаточно следовать принципу - один ресурс - один класс и все.

    По поводу второго комментария.
    Артем> тогда получается нельзя кидать исключение в конструкторе, если класс наследуется от класса, кот. реализует IDisposable, либо необходимо делать так: base.Dispose(); throw new Exception(); Так ведь?

    Да, да придется вызывать метод Dispose самостоятельно либо перед генерацией исключения, либо в блоке catch с последующим пробросом исключения далее.

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