Добрый день, Серега вновь добрался до клавиатуры и рассуждает о 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/
Добавить комментарий