Pull to refresh

RAII и делегирующие конструкторы в C++11

Reading time 4 min
Views 29K
В этом посте пойдет речь об одной интересной фичи в C++11, которая называется делегирующие конструкторы (delegating constructors): почему она интересна, и как ее можно применить для более эффективного управления ресурсами, т.е. реализации идиомы RAII.



Кратко об RAII (ну очень кратко)

Когда нам нужно автоматизировать управление каким-нибудь “голым” ресурсом, мы его “заворачиваем” в отдельный класс. Продемонстрируем это на примере такого ресурса как FILE из стандартной библиотеки C:

#include <stdio.h>

class File
{
public:
    File(char const * filename, char const * mode)
        : file_(fopen(filename, mode))
    {}

    ~File()
    {
        fclose(file_);
    }

    File(File const &) = delete;
    File operator=(File const &) = delete;

    // file operations
    // ...

private:
    FILE * file_;
};


Здесь мы создаем FILE ресурс в конструкторе и освобождаем в деструкторе. Теперь ресурс FILE управляется в полном соответствии с идиомой RAII.

Немного более усложненный случай RAII

Допустим теперь, что в дополнение к открытию файла в конструкторе нам нужно провести с ним некоторую операцию. Например, будем в заново открытый файл записывать время последнего открытия, time stamp. Для этого создадим в классе File функцию put_time_stamp, которая в каким-то образом помещает в файл time stamp, а в случае неудачи выбрасывает какое-то исключение.

Реализуется это дело как-то так:

#include <stdio.h>

class File
{
public:
    File(char const * filename, char const * mode)
        : file_(fopen(filename, mode))
    {
        put_time_stamp();
    }

    ~File()
    {
        fclose(file_);
    }

    File(File const &) = delete;
    File operator=(File const &) = delete;

    // file operations

    void put_time_stamp()
    {
        // throws on error
        // ...
    }

private:
    FILE * file_;
};


Но как видно, в данной реализации есть небольшая проблема. Конструктор File перестал быть exception safe. Если из put_time_stamp вылетит исключение, то оно не приведет в вызову деструктора объекта File, так как его конструктор еще не завершился. Поэтому ресурс file_ будет потерян.

Как нам решить эту проблему? Тупое решение “в лоб” заключается в оборачивании вызова put_time_stamp в блок try/catch:

class File
{
public:
    File(char const * filename, char const * mode)
    try
        : file_(fopen(filename, mode))
    {
        put_time_stamp();
    }
    catch (...)
    {
        destruct_obj();
    }

    ~File()
    {
        destruct_obj();
    }

private:
    void destruct_obj()
    {
        fclose(file_);
    }

    FILE * file_;
};


Этот подход работает, но он немного некрасив из-за необходимости иметь явный try/catch блок и отдельный метод для явного разрушения объекта, чтобы не дублировать одну и ту же функциональность в catch блоке и в деструкторе.

Мы можем немного улучшить данное решение, если введем дополнительный класс специально для хранения и удаления FILE, FileHandle:

class File
{
    struct FileHandle
    {
        FileHandle(FILE * fh)
            : fh_(fh)
        {}

        ~FileHandle()
        {
            fclose(fh_);
        }

        FILE * fh_;
    }

public:
    File(char const * filename, char const * mode)
        : file_(fopen(filename, mode))
    {
        put_time_stamp();
    }

    ~File() = default;

private:
    FileHandle file_;
};


Как видно, теперь явный try/catch блок уже не нужен. Объект file_ будет корректно разрушен, даже если из конструктора класса File вылетит исключение, и ресурс FILE будет освобожден. Но в этом решении все равно есть некоторый недостаток, заключающийся в отдельном классе FileHandle, который разносит создание и освобождение ресурса FILE на два разных класса: FILE создается в классе File, а освобождается в классе FileHandle.

Делегирующие конструкторы

Рассмотрим теперь одну очень полезную фичу из C++11 под названием делегирующие конструкторы, которая позволит нам еще более улучшить предыдущий код класса File. Но для начала, посмотрим, как вообще работают эти делегирующие конструкторы.

Допустим, у нас есть класс с двумя конструкторами: один от параметра типа int, а другой от double. Конструктор для int делает то же самое, что и конструктор для double, только сначала он переводит параметр от типа int к типу double. Т.е. конструктор для int делегирует создание объекта конструктору для double. Вот как это выглядит в коде:

class MyClass
{
public:
    MyClass(double param)
    {
        // construct object for double parameter
    }

    MyClass(int param)
        : MyClass(double(param))  // call ctor for double
    {
        // do some additional operations for int parameter
        // if necessary
    }
};


После того, как конструктор для double закончит выполнение, конструктор для int может продолжить выполняться и “доконструировать” объект. Сама по себе это очень полезная фича, без которой в коде выше нам наверняка пришлось бы ввести дополнительную функцию init(double param) для инкапсуляции общего кода по созданию объект от типа double.

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

Продемонстрируем это поведение на следующем примере:

class MyClass
{
public:
    MyClass(double)
    {
        cout << "ctor(double)\n";
    }

    MyClass(int val)
        : MyClass(double(val))
    {
        cout << "ctor(int)\n";
        throw "oops!";
    }

    ~MyClass()
    {
        cout << "dtor\n";
    }
};

int main()
try
{
    MyClass obj(10);
    cout << "obj created";
}
catch (...)
{
    cout << "exception\n";
}


Конструктор MyClass(int) вызывает другой конструктор MyClass(double), после чего сам выбрасывает исключение. Это исключение ловится в catch(...), и при раскрутке стека вызывается деструктор ~MyClass. На консоль при выполнении данного кода выведется следующее:

ctor(double)
ctor(int)
dtor
exception


Делегирующие конструкторы и RAII

Нетрудно заметить, что такое интересное поведение конструкторов при делегировании можно очень эффективно использовать в нашем примере реализации RAII для FILE. Теперь нам не нужно вводить никакой дополнительный класс FileHandle для освобождения ресурса FILE, а тем более не нужен и try/catch. Нужно ввести всего лишь один дополнительный конструктор, которому будет произведена делегация из основного конструктора. То есть:

class File
{
    File(FILE * file)
        : file_(file)
    {}

public:
    File(char const * filename, char const * mode)
        : File(fopen(filename, mode))
    {
        put_time_stamp();
    }

    ~File()
    {
        fclose(file_);
    }

    void put_time_stamp() { ... }

private:
    FILE * file_;
};


И это все что нам необходимо. Очень красиво, элегантно и полностью безопасно по отношению к исключениям (exception safe). Вывод: подобная техника существенно облегчит реализацию идиомы RAII в новом коде с использованием делегирующих конструкторов из C++11.
Tags:
Hubs:
+66
Comments 39
Comments Comments 39

Articles