Чистый С++: Как правильно разрушать

от автора


Добрый день, Серега вновь добрался до клавиатуры и рассуждает о C++. Сегодня поговорим о том, зачем еще в C++ нужны классы, как работают деструкторы и на какие еще грабли можно наступить, если смешать два языка. Под катом ничего нового и выдающегося для тех, кто знает C++ еще со времен ДОСа. Если же вы еще только изучаете этот язык — добро пожаловать.

Как вы, наверное, заметили в прошлый раз, у класса есть очень важная вещь — деструктор! Эта функция будет вызвана ВСЕГДА, когда класс разрушается. Не важно что произошло, выход из функции посередине или даже выброшенное исключение, деструктор класса будет вызван в любом случае, пока программа работает. (Для справки, программа уже не работает, если исключение никто не ловит, поэтому в main нужно ставить try…catch на все типы исключений.). На C деструктор вызывается вручную, что заставляет писать много лишних подробностей.

if(!Create1(...))     return -1; if(!Create2(...)) {     Destroy1(...);     return -1; } … if(!CreateN(...)) {     Destroy1(...);     Destroy2(...);     ...     DestroyN-1(...);     return -1; } 

А дополнительные ветвления и циклы лишь добавляют путаницы и громоздкости в коде.
Деструктор не заменяется секцией finalize, доступной в других языках! Дело в том, что он вызывается только у уже созданных объектов, а разобраться в том, кого уже создали, а кого нет в finalize возможно далеко не всегда. Приходится городить вложенные блоки и множественные секции финализации, что также делает код излишне запутанным. Вот хороший пример подобной ситуации:

#include <iostream> #include <stdexcept>  class Foo { private:     static int sm_count;     int instance; public:     Foo()     {         instance = ++sm_count;         if(instance > 5)             throw std::runtime_error("Нельзя создавать больше 5ти объектов.");         std::cout << "Экземпляр № " << instance << " класса Foo создан.\n";      }     ~Foo()     {         std::cout << "Экземпляр № " << instance << " класса Foo разрушен.\n";     } };  int Foo::sm_count = 0;  int main() {     try     {         Foo* pFoo = new Foo[10];     }     catch(const std::exception& e)     {         std::cout << e.what() << "\n";     }     return 0; }  

Грамотное и повсеместное использование деструкторов приближает C++ по стилю программирования к языкам со сборщиком мусора. При таком подходе программист занят построением модели, а не выслеживанием симметричных выделений и освобождений ресурсов. Однако такое сближение сильно обманчиво и часто заканчивается обращением к удаленному с кучи объекту и аварийным выходом из программы. А если при этом еще активно пользоваться конструкциями языка C (упомянутый в прошлый раз «Ц с классами»), то однажды гарантированно произойдет обращение к данным объекта одного типа через указатель на другой тип. Вот очень хороший пример (делить на заголовочные файлы не обязательно, но полезно, чтобы было легко менять порядок их включения):

// HeaderFile1  class Interface1 { public:     virtual void SomeFunc(int a) = 0; };  class Interface2 { public:     virtual void AnotherFunc(double b) = 0; };  // HeaderFile2  class Implementation;  class Storage { private:     Implementation* m_pImpl; public:     Storage(Implementation* in_pImpl)         : m_pImpl(in_pImpl)     {}      Interface1* GetInterface1()     {         return (Interface1*)m_pImpl;     }     Interface2* GetInterface2()     {         return (Interface2*)m_pImpl;     } };  // HeaderFile3  #include <iostream>  class ImplementationBase { protected:     virtual void BaseFunc()     {         std::cout << "BaseFunc была выполнена.\n";     } };  class Implementation     : private ImplementationBase     , public Interface1     , public Interface2 { public:     virtual void SomeFunc(int a)     {         std::cout << "SomeFunc была выполнена с аргументом a = " << a << ".\n";     }      virtual void AnotherFunc(double b)     {         std::cout << "AnotherFunc была выполнена с аргументом b = " << b << ".\n";     } };  // C++ source file // #include <HeaderFile1> // #include <HeaderFile2> // #include <HeaderFile3>  int main() {     Implementation impl;     Storage storage(&impl);     storage.GetInterface1()->SomeFunc(42);     storage.GetInterface2()->AnotherFunc(37.7);     return 0; }  

Попробуйте в этом примере поменять порядок включения второго и третьего заголовочных файлов. Очень хорошо, когда подобные ошибки легко обнаружить, как в этом изолированном примере. Когда-же код разбросан по многим файлам, скрыт несколькими уровнями иерархии наследования и виртуальной перегрузкой операторов, то легче сойти с ума, чем понять что-же на самом деле происходит. Если же использовать в этом примере чистый C++, то компилятор выдаст ошибку во время компиляции.

Вернемся к деструкторам. При их написании главное не бросить случайно исключение. Дело в том, что деструктор может быть вызван именно в процессе обработки исключения. В этом случае, процесс размотки стека процедуры размотки стека заканчивается самым простым и ожидаемым образом: немедленным выходом из программы. Так что внимательно за ними следите. Ни в коем случае не выделяйте в них память, ресурсы и уж тем более не бросайте исключений явно. Это требование диктует определенное отношение ко всяким «закрывающим» функциям. Например, какой нибудь Socket::Close() более не может сделать throw std::runtime_error("Close socket error: socket was not opened"). Дело в том, что самое логичное, что можно сделать с «закрывающей» функцией — вызвать ее в деструкторе. А вот обкладывать этот вызов различными проверками условий — совсем даже не логично. И если вы работаете в коллективе, то кто-то обязательно постарается закрыть уже закрытое. Так-что запишите себе куда нибудь простое правило: в любой «закрывающей» функции нужно тихо и без лишних телодвижений делать именно то, что от нее требуется — «закрыть» то, что просят, даже если это физически невозможно.
Еще раз обращаю внимание на то, что нельзя в таких функциях и деструкторах ничего выделять или захватывать. Очень распространенная ошибка:

~object::object() {     g_logger.put_message(std::string("Object ") + m_name + std::string(" was deleted.")); } 

Если этот деструктор будет вызван в процессе обработки исключения вида «кончилась память», то вы долго будете искать причину вылета программы. При этом даже упомянутый тут логгер не поможет, т.к. не сумеет сформировать и сохранить так нужное сообщение. Как-же быть в такой ситуации? Самый простой способ, но не самый «красивый» — сгенерировать сообщение заранее, когда было все хорошо, и держать его в приватной части до поры до времени. Более «продвинутый» вариант:

~object::object() {     g_logger << "Object " << m_name << " was deleted."; } 

Надеюсь понятно, что g_logger здесь не имеет права заниматься выделениями памяти и открытиями файлов, а обязан иметь наготове буффер фиксированного размера и сливать его в заранее открытый файл по заполнению?

Плавно перейдем к выделению ресурсов. Правильный подход — сначала выделить, а затем использовать. Вот неправильный пример:

 std::vector<int> items; ... items.push_back(item1); items.push_back(item2); Правильно делать так: std::vector<int> items; ... items.reserve(items.size() + 2); items.push_back(item1); items.push_back(item2);  

Нужно постоянно помнить о том, что память может кончится в самый неподходящий момент, и состояние программы в такой момент обязано оставаться определенным. Если обязано быть два элемента в массиве — значит либо два целых, либо ни одного. Никаких «недосозданных» структур данных быть не должно, т.к. это прямой путь к сбою при работе деструкторов. Хорошая программа — это такая, которая даже при нехватке памяти корректно сохраняет свои данные и тихо выходит. Ну, может быть не тихо, а вежливо попрощавшись. К сожалению, это не всегда так легко достижимо. Например, std::list не имеет метода reserve. Для таких случаев приходится заводить «пустое» состояние элемента данных, вроде null_ptr для указателей или -1 для индексов, и класть его сначала в структуру данных. А в деструкторе аккуратно обходить такие элементы. Здесь уместно вспомнить про увлечение всякими операторами, создающими на стеке временные объекты. Эти объекты, в свою очередь, выделяют ресурсы, которые не выделяются, а бросают исключение прямо посередине сложного выражения, оставляя части данного выражения в полувычесленном состоянии. Например итератор, сдвигаемый оператором ++ в середине выражения будет абсолютно бесполезен в секции catch.

Чтобы целостность структур данных получалась сама собой, без лишних телодвижений со стороны программиста, нужно стремиться к тому, чтобы всякие такие структуры создавались в конструкторах и представляли из себя объект, а разрушались деструкторами, корректно исключая себя из общей структуры данных программы. Указанный выше пример с целыми числами следовало бы написать например так:

 typedef std::pair<int, int> DataItem; std::vector<DataItem> items; ... items.push_back(DataItem(item1, item2));  

Однако это уже не такая простая тема моделирования предметной области поставленной задачи.

ссылка на оригинал статьи http://habrahabr.ru/company/intel/blog/163907/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *