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

    В этом посте пойдет речь об одной интересной фичи в 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.
    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More
    Ads

    Comments 39

      –22
      RAII неплохая идиома, но у нее весьма конкретная сфера применения, и за приведенный код я бы отрывал ноги.
        –17
        Комментарий не с целью критики статьи, просто хочу пресечь злоупотребления идиомой.
          +18
          Написали бы, почему оторвали ноги. С++ такая штука, что одни пользуются половиной языка и оторвут ноги за другую половину, а другая половина — наоборот
            –25
            Во-первых, этот код не компилируется в VC2012.
            Во-вторых, использование fopen() deprecated.
            В-третьих, если я, например, буду искать в этом коде трудноуловимый баг, то я хочу, чтобы каждая функция стояла на отдельной строчке, чтобы я мог поставить брейкпоинт — то есть вместо file_(fopen()) я хочу всегда видеть { file_ = fopen();… }
            В-четвертых, пусть код немного другой, исключение происходит после некоторого первого конструктора, но перед инициализацией переменной file_. В деструкторе вызывается fclose(0xCCCCCCCC); и имеем повторное исключение в обработчике.
              +18
              Думаю ваши претензии совершенно не обоснованны. Здесь показана идея, а не законченный рабочий код. Поэтому и воспринимать его нужно соответственно. Вы же не берете код из книжки Саттера и не суете его в свой продукт один в один? Вот и здесь так же.
                –18
                RAII неплохая идиома, но у нее весьма конкретная сфера применения, и за приведенный код я бы отрывал ноги.
                  +11
                  А я бы отрывал ноги за попытку рекурсии в комментариях.
                    +3
                    а зачем отрывать ноги, если код и комментарии пишут обычно руками?
                      +11
                      Чтобы не убежал, а сидел переписывал.
                +2
                Открываете окошко Disassmebly и ставить брейкпоинты на те функции, которые вас интересует. Да и из гуи это вроде бы неплохо делается — брейкпоинты на строку разворачиваются на кучу отдельных одним кликом мышки (возможно, там не очень понятно только какой брейкпоинт к чему относится, поэтому проще на call поставить бряк). В чем проблема-то?
                  –5
                  Проблема, видимо, в том, что я трачу свое время на отладку чужого кода.
                  +11
                  1. Значит VS2012 недостаточно поддерживает C++11. Или в чём проблема?
                  2. И на что его заменить? Я вообще что-то не вижу в какой редакции стандарта (Си или C++) её объявили deprecated?
                  3. Это Ваши личные предпочтения, не более. Если функция без кода, то всё равное не получите ничего более внятного, чем возвращённое значение (через дизассемблер всё равно можно добраться). Если есть код, то можно поставить бряк на первую строчку этой функции.
                  4. При правильной реализации после выполнения первого конструктора все поля должны быть инициализированы. В том числе file_. Хотя бы NULL'ом. Тогда в деструкторе добавляется один if и всё.
                    +7
                    2. Гугл мне подсказал, что это богомерзкий Майкрософт так определил.

                    Пишу программ мало, исключительно чтобы раз что-то посчитала. Набрел на эту фичу поэтому случайно, но по-моему, она очень хороша. 0x3f00, зачем отрывать ноги за код, который понятен, работает надежно и удовлетворяет свежим стандартам? Я понимаю, за неоптимальность… Но за чуждую философию — это косность и зашоренность :)
                      –13
                      И действительно, кто такой этот богомерзкий Майкрософт и какое отношение он имеет к языкам программирования и инструментам разработки.
                        +6
                        Понимаете, есть стандарт языка. И если есть желание писать кроссплатформенный код, то лучше следовать стандарту, а не использовать расширения от MS. Расширения GCC ещё понимаю, поскольку им можно получить код под Windows, OSX, Linux, FreeBSD, etc.
                          +7
                          Да успокойтесь вы :)
                          Может человек сидит в своем загончике «Windows+x86+M$VS» и ему большего не нужно. Правда, непонятно почему он считает себя вправе давать остальным вредные советы, да еще в такой категоричной форме. Но оставим это на его совести.
                            +1
                            а по-моему, это обычный тролль, которому скучно
                          +1
                          Они очень многие POSIX функции из стандарта объявили почему-то deprecated, интересно, почему? Приходится дефайн _POSIX_ подавать студии чтобы заткнулась и не возникала.
                          +5
                          2. Да, я так и подумал. Но fopen_s только их фича, не более того. Более того, в чём смысл большинства этих «безопасных» не вполне понимаю. Говорит мне cl.exe, что strcpy опасна используйте strcpy_s. Зачем, если есть вполне стандартная strncpy?
                            +2
                            Ну, дело не только в буферах: тут приводят выдержки из святого писания. Говорится еще и о каких-то правах доступа к файлу. Не вчитывался.
                            When creating a file, the fopen_s and freopen_s functions improve security by protecting the file from unauthorized access by setting its file protection and opening the file with exclusive access.
                            И вправду, зачем эта приблуда, если пишешь не под Windows? Или не только под него (кроссплатформенное приложение).

                            0x3f00, да я не отрицаю вклада Майкрософта, только они тянут одеяло на себя постоянно, монополизировали рынок. Не единственное, но одно из самых их важных достижений, приносящее бабки, имхо, то, что специфический добротный софт есть только под их ОС. Тот софт, который стал стандартом де факто. Типа AutoCAD, что-то для программирования контроллеров, офисные приложения и подобное. Слава богам, весь нормальный научный софт уже достаточно давно отлично работает под Linux. Дело в том, что уже наверно большинство ученых за рубежом используют Mac или Linux. Да и сервера часто делают на на Windows же. Так что не нужно продвигать выдумки этих капиталистов как последнюю истину :)
                              +1
                              Ну strcpy_s хороша как минимум тем, что у нее есть шаблонная перегрузка, которая сама опредлеяет размер статически размещенных буферов, так что ее вызов выглядит ровно так же, как и вызов просто strcpy. Кроме того, если среди n байтов в исходной строке при вызове strncpy не окажется нуля, то целевой буфер его не получит, и это чревато боком.
                                0
                                Гм, не знал о таком поведении strncpy(). И вообще считал её наиболее безопасным вариантом, избегая «чистого» strcpy() везде где возможно.
                                  –1
                                  Ок, а как еще по другому можно? Естественно если указать num меньше чем реальная длина строки src, то нуля в конце dest мы не получим. А с какой стати функция его должна ставить и самое главное куда? При условии что программист может забыть выделить под dest место равное длина строки + длина символа завершения.

                                  strcpy_s тоже может повредить кучу, если недостаточно выделить места под dest
                                  strcpy_s автоматически определяет размер статического массива, если передается статический массив в качестве назначения, то есть она имеет соответствующую специализацию шаблона. Написано что всегда ставит null в конце строки, значит в dst копируется из src sizeof(dst)-1 элементов и затем в dst добавляется в конце null.

                                  Круто.

                                  А вот интересно, что будет если скормить strcpy_s вот это:
                                  char dst[1]; // Статический массив из одного элемента — не строка ибо не так :char dst[] = «a»;!!!
                                  strcpy_s(dst, «A»);
                                  В dst будет только null terminated символ?
                                  Ну в принципе безопасно, но тоже не однозначно.

                                  Ну лан пофиг strcpy и иже с ними пришли из древнего C.

                                  Для наибольшей безопасности, если она так нужна, пользуйтесь string'ом и его перегруженными операторами, insert'ом, replace'ом и т.д.

                                  Я вообще не понимаю, почему бы Микрософту не сделать свой API совместимым с STL.
                                  Наверно потому, что нужна поддержка C и таких программистов очень много. Сейчас кстати C и C++ развиваются разными путями. Например в C есть ключевое слово restrict. В C++ его нет и не предвидится.
                                  Страуструп здесь маху дал. Надо бы было поддержку C11 включить в стандарт C++11 или хотя бы в C++14 ну или в C++17
                                  Иначе мы получим несовместимость, некроссплатформенность, кучу геммора при попытке скомпилить современный Си код вместе с C++ кодом в одной единице трансляции и два разных языка, которые в принципе могли бы быть одним единственным, как было в 80 — 90 годах
                                  Жесть, одним словом (((
                            +2
                            По поводу пункта «В-третьих»: для начала, брейкпойнты не нужны. Как и вообще отладчики.

                            К тому же, предложенный вами стиль заведомо хуже. Если поле — тоже объект, то в таком случае он создастся конструктором по умолчанию, и только потом выполнится оператор присваивания. А в некоторых случаях (я знаю три) такое присваивание вообще невозможно.
                              –10
                              1. Это означает, что я не смогу показать очень красивый, элегантный и полностью безопасный по отношению к исключениям код, стабильность которого, тем не менее, будет зависеть от фазы луны и случайного содержимого стека.

                              Обычно это происходит так. Приходит багрепорт: «У половины наших клиентов с вин2003 программа крэшится». Я лично с удовольствием бы писал много нового кода на С++11, однако приходится браться за эту ошибку. Просто потому что у меня больше опыта в отладке подобных проблем. Я вообще три вещи делаю очень хорошо: пишу код на С++, отлаживаю код на С++ и пишу очень красивые configure.ac. Если кто не верит, может зайти в профиль и посчитать мой возраст.

                              И поскольку я не собираюсь менять род деятельности и кровно заинтересован в экономии своего времени, то настаиваю на своей мысли: RAII не следует использовать для fopen() и подобного.
                                +3
                                Если Вы 80-ого года рождения (всего-то 32), то у Вас длинее? Вам не нужно — не используйте, но не называйте это говном. А так и говорите: " в моей практике применение RAII затруднительно потому-то и потому". Каждый сделает тогда вывод сам.
                                  –1
                                  Применение RAII в моей практике вовсе не затруднительно. Однако опыт показывает, что между «тяжелым» конструктором (RAII) и «легким» (с отложенной инициализацией) лучше выбрать второй.
                                  +3
                                  Спасибо, нам очень важно ваше мнение. До свидания.
                        • UFO just landed and posted this here
                          • UFO just landed and posted this here
                              0
                              Я понимаю теоретиков от программирования, который тащатся, написав такой «красивый» код, но блин!
                              1. Как сюда добавить какую-нибудь проверку, обработку ошибок?
                              2. Как сюда добавить логирование?
                              3. Как такое отлаживать?
                              4. Нафига писать код открытия файла, который нужно вдумчиво читать и пытаться понять, вместо банального линейного кода, который каждый первокурсник поймёт?
                              • UFO just landed and posted this here
                                  +1
                                  Это сугубо практичный подход.
                                  Есть проект, у него есть требования на логирование, обработку ошибок и прочие соглашения.
                                  Что бы этот код не дублировать везде где используются внешние ресурсы (а мы помним поему дублирование кода плохо да?).
                                  Логирование добавляется на 1-2-3 — делаешь приватные статические методы _fopen/_fclose — в них добавляешь логирование и все утилитарную логику. Остальной код класса работает с этими методами. Вопрос снят?
                                  Подобный код обычно отлаживать не надо — тк требования к нему хорошо формализовану в документации по используемому системном API, что позволяет легко написать юнит тесты на его 99% покрытие.
                                  0
                                  Сорри что немного не по теме, но в чем смысл писать typedef _после_ изпользования синонима в коде?
                                    0
                                    Хм, вроде оба typedef'а объявлены до использования? В конструкторе typedef'ы не фигурируют, а вызывается конструктор объекта
                                      0
                                      Видимо я не совсем корретно выразился.
                                      Фигурирует file и его конструирование, с передачей deleter 2м параметром.
                                    • UFO just landed and posted this here
                                        0
                                        Да, спасибо, в этом случае действительно все typedef относятся только в деталям реализции и не «светят» в паблик методах класса.
                                  0
                                  Хей, да это прям как двухфазное конструирование в Symbian C++, тока из коробки и без лишнего кода.

                                  Only users with full accounts can post comments. Log in, please.