Добрый день, Серега вновь добрался до клавиатуры и рассуждает о 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));
Однако это уже не такая простая тема моделирования предметной области поставленной задачи.