Эта статья (как и весь мой цикл статей о программировании) предназначена для тех, кто уже познакомился с C++, но ещё не успел постичь всех тонкостей. Поэтому, что такое деструктор в C++, я надеюсь, объяснять не надо. В этой статье речь пойдёт о том, как правильно и наиболее эффективно пользоваться этим инструментом.
Итак, зачем же он всё-таки понадобился? Все мы помним, что он из себя представляет. Это – всего лишь метод, который вызывается при удалении объекта. Но зачем понадобилось вводить для этого специально обученный метод? Ведь всё то, что он делает, всегда можно оформить и в виде обычного метода (можно назвать его Clean или Close). Забегая вперёд, я скажу, что часто иметь такой метод в дополнение к деструктору даже оказывается очень полезно. В таком случае деструктор будет состоять просто из вызова этого метода. Так какие же преимущества даёт деструктор программисту C++?
Думаю, вы уже догадались, к чему я клоню. Достоинство деструктора в том, что он вызывается автоматически, или, выражаясь более точно, обращение к нему автоматически формирует для нас компилятор. Но это – лишь поверхностный ответ на вопрос. Более глубокий ответ заключается в том, что он позволяет инкапсулировать процедуру освобождения использованных ресурсов. А инкапсуляция – мощнейший инструмент, позволяющий сделать код программы более простым, наглядным и удобным для работы. Далее мы рассмотрим, как это работает на практике.
В повседневной практике программисты постоянно сталкиваются с задачей освобождения неких ресурсов, таких как динамически распределяемые области памяти, открытые файлы, объекты ядра, пользовательского интерфейса, графические объекты. Как правило, такие ресурсы выделяются по запросу нашей программы операционной системой, а по завершению работы с ними должны быть освобождены вызовом специальной функции. В качестве примера возьмём открытый файл. Вот как работа с ним могла выглядеть во времена «чистого C»:
{
HANDLE hFile = CreateFile( /* у этой функции Widows API целая куча аргументов, котрые мы здесь опустим, далее я буду просто заменять их многоточеем */ );
if( hFile == INVALID_HANDLE_VALUE ) return;
...
VERIFY( CloseHandle( hFile ) );
return;
}
Всё было бы хорошо, если бы у нашей процедуры была бы единственная точка выхода, перед которой мы бы и разместили вызов CloseHandle
. Но на практике такого не бывает практически никогда. К примеру, функция чтения данных из файла всегда может завершиться неудачно, а в этом случае, скорее всего, и нашу процедуру придётся завершить раньше времени. (Вообще-то, следовало бы ещё позаботиться о том, чтобы информировать пользователя об ошибке ввода-вывода, но об этом в другой раз, сейчас мы это опустим).
BOOL ok = ReadFile( hFile, ... );
if( !ok ) return;
Если мы просто вставим этот кусок в процедуры выше как есть, очевидно CloseHandle
в случае ошибки вызван не будет, и мы получим утечку ресурсов. Как же быть? Можно попытаться вставить CloseHandle
перед каждым return. Увы, это сильно загромоздит нашу процедуру. Точек выхода обычно бывает несколько, да и освобождаемых ресурсов может быть не один. Другой вариант, вместо return везде вставлять переход к единой точке выхода:
{
HANDLE hFile = CreateFile( ... );
if( hFile == INVALID_HANDLE_VALUE ) return;
...
BOOL ok = ReadFile( hFile, ... );
if( !ok ) goto exit;
...
ok = ReadFile( hFile, ... );
if( !ok ) goto exit;
...
exit:
VERIFY( CloseHandle(hFile) );
return;
}
Чуть лучше. По крайней мере, весь код, ответственный за «чистку» расположен в одном месте. Правда мы использовали «нецензурный» оператор goto
, употребление которого, по мнению некоторых, является столь же дурным тоном, что и употребление матерных слов в речи :-) Очевидная альтернатива использовать вложенные операторы if
выглядит привлекательной лишь в простейших процедурах. В сложных процедурах со многими ReadFile
и аналогичными вызовами мы получим такое нагромождение вложенных if
, что легко запутаемся. Получится даже ещё хуже, чем с goto
.
На всякий случай, покажу вам простой трюк, как переписать тоже самое не используя goto, но при этом сохранить код достаточно понятным. Для этого удобно использовать поддельный оператор for
(никакого цикла в данном случае нет):
{
HANDLE hFile = CreateFile( ... );
if( hFile == INVALID_HANDLE_VALUE ) return;
for(;;) { // fake for
BOOL ok = ReadFile( hFile, ... );
if( !ok ) break;
...
ok = ReadFile( hFile, ... );
if( !ok ) break;
...
break;
}
VERIFY( CloseHandle( hFile ) );
return;
}
Такой трюк с поддельным for
периодически оказывается достаточно удобным. И всё же, сути он меняет. На самом деле, реальная проблема данного подхода состоит не в использовании goto
, как такового, а в том, что слишком легко забыться и поставить вместо него просто return
.
Более надёжный способ чистки состоит в использовании не совсем стандартного оператора __finally
:
{
HANDLE hFile = CreateFile( ... );
if( hFile == INVALID_HANDLE_VALUE ) return;
__try {
BOOL ok = ReadFile( hFile, ... );
if( !ok ) break;
...
ok = ReadFile( hFile, ... );
if( !ok ) break;
...
return;
}
__finally {
VERIFY( CloseHandle( hFile ) );
}
}
Опустим проблемы, связанные с тем, что вообще-то, __finally
не входит в стандарт C++ и является его расширением. Тем более что я привожу здесь этот вариант лишь как пример возможного подхода к решению проблемы. Отметим лишь два положительных момента. Во-первых, здесь код, отвечающий за освобождение ресурсов, оказался чётко выделен в специальный блок. Во-вторых, оператор __finally
срабатывает даже в том случае, если выход из процедуры произошёл не нормальным образом, а через выброс исключения. Строго говоря, примеры, приведённые выше, рассчитаны на использование в «чистом C». При переходе к C++, мы должны учитывать также возможность выброса исключений, что снова приведёт к усложнению кода при вышеописанном подходе. __finally
автоматически решает эту проблему.
Почему же, в отличие от некоторых других современных языков, в C++ конструкция try/finally
не стала стандартной подобно try/catch
? Ответ, на мой взгляд, заключается именно в том, что механизм деструкторов в большинстве случаев обеспечивает лучшее решение проблемы освобождения ресурсов.
Итак, как же мы должны модифицировать наш пример, чтобы задействовать механизм деструкторов? Для этого, нам необходимо создать класс-оболочку вокруг хэндла файла, который будет выглядеть примерно так:
class File {
public: // methods
File( void );
File( LPCSTR* _pszFileName, ... );
~File( void );
void Open( LPCSTR* _pszFileName, ... );
void Close( void );
void Read( ... );
private: // data
HANDLE m_hFile;
};
inline File::File( void ) :
m_hFile ( INVALID_HANDLE_VALUE )
{}
inline File::File( LPCSTR* _pszFileName, ... )
{
Open( _pszFileName, ... );
}
inline File::~File( void )
{
Close( void );
}
void File::Open( LPCSTR* _pszFileName, ... )
{
ASSERT( m_hFile == INVALID_HANDLE_VALUE );
m_hFile = ::CreateFile( _pszFileName, ... );
if( m_hFile == INVALID_HANDLE_VALUE ) throw Exception( ... );
}
void File::Close( void )
{
if( m_hFile != INVALID_HANDLE_VALUE ) {
VERIFY( ::CloseHandle( m_hFile ) );
m_hFile = INVALID_HANDLE_VALUE;
}
}
Теперь, с помощью этого простого класса мы можем переделать нашу процедуру следующим образом:
{
File file( ... ); // теперь и открытие и закрытие файла инкапсулированы в одной этой строчке
...
file.Read( ... );
...
file.Read( ... );
}
И всё! Наша процедура разительно упростилась! Попутно, мы спрятали в наш класс-оболочку код обработки ошибок. (Предполагается, что метод Read
также должен выбрасывать исключение, но об исключениях в другой раз) Тем не менее, кроме нашей процедуры, нам пришлось реализовать специальный класс. Может показаться, что нам пришлось проделать дополнительную работу. Так стоила ли игра свеч?
К счастью, реализовать класс-оболочку нужно лишь один раз. (К тому же, библиотеки MFC и ATL уже содержат такие оболочки для большинства ресурсов Windows API) А вот использовать его можно будет во многих процедурах. Более того, по моему опыту, так обычно и случается. Если вам однажды понадобился некий ресурс, который необходимо освобождать, будьте уверенны, он вам наверняка понадобиться где-нибудь ещё. Так что смело пишите класс-оболочку, если это ещё не сделал кто-нибудь до вас.
Что нам дал наш класс-оболочка? Сделал код более объектно-ориентированным? Ничуть! На самом деле, по большому счёту безразлично писать ли file.Read
или ReadFile( hFile )
. Основное его значение заключается именно в том, что он инкапсулировал процедуру закрытия файла. Теперь просто объявив переменную типа File
, мы уже можем быть уверены, что чтобы ни делала наша процедура дальше, то по её завершению файл гарантированно будет закрыт, и мы можем больше не возвращаться к этому вопросу.
К сожалению, многие программисты просто ленятся это делать, и их код в результате пестрит примерами в стиле языка C приведёнными выше. И зря! Обычно это добавляет работы в дальнейшем.
Давайте вспомним, в каких ситуациях компилятор C++ автоматически формирует обращение к деструктору. Таких ситуаций две.
Во-первых, если в некой функции мы создали локальную переменную, то по завершении функции для неё будет вызван деструктор. Пример этого мы только что рассмотрели. Тоже самое относится к статическим и глобальным переменным, с той разницей, что для них деструктор будет вызван по окончании программы. Поэтому, я не выделяю их, как особый случай. Именно это свойство деструкторов и делает их такими полезными.
Во-вторых, если наш класс имеет базовые классы или содержит переменные – члены неких классов, то деструктор нашего класса автоматически вызовет деструкторы и тех и других. Действительно, если мы удаляем наш класс, очевидно мы обязаны удалить все его составные части, то есть вызвать их деструкторы. И в этом случае компилятор также формирует обращение к деструкторам без дополнительного участия программиста.
Для наглядности, пусть у нас есть, к примеру, такой класс:
class Foo : public Bar {
private: // data
File m_file1;
File m_file2;
}
Компилятор автоматически сформирует для него деструктор, который, если бы нам пришлось писать его самим, выглядел бы примерно следующим образом:
Foo::~Foo( void ) // псевдокод
{
m_file2.~File();
m_file1.~File();
~Bar();
}
Очень удобно! Ведь на самом деле нам для этого не потребовалось ни строчки кода. Кстати, если мы, тем не менее, решим написать собственный деструктор, компилятор всё равно добавит в него описанный код.
Это – те случаи, когда обращение к деструктору формируется «незаметно» для программиста, в том смысле, что от него для этого не требуется ни строчки кода. Нелишне будет напомнить, что в иных случаях, деструктор необходимо вызвать явным образом: оператором delete и прямым вызовом деструктора. Оператор delete на самом деле вызывает две функции: сначала деструктор, а затем функцию, освобождающую динамически выделенный блок памяти. Прямой вызов деструктора используется тогда, когда требуется вызвать только его, но не функцию освобождения памяти. Скажем прямо, последнее встречается достаточно редко, лишь когда программисту очень хочется порулить распределением объектов в памяти вручную. Однако оба эти случая подробно описываются во всех учебниках по C++, поэтому я не стану останавливаться на них здесь. Важно лишь то, что они используются, когда компилятор не способен сформировать вызов деструктора автоматически. Практически всегда это связано с созданием объекта в динамически выделяемой области памяти. Компилятору обычно не известно ничего о предполагаемом времени жизни такого объекта. Поэтому, ответственность за его удаление ложиться на программиста. Тем не менее, и здесь чаще всего можно снять с себя часть ответственности, снова используя деструктор. Только это будет деструктор специального класса-охранника либо умного указателя.
Деструктор – настолько удобный и полезный инструмент, что нередко очень полезными оказываются классы, вся функциональность которых находится в деструкторе. Назначение таких классов – вызвать процедуру очистки по завершении некой функции. Не знаю, есть для таких классов общепринятое название, лично я называю их охранниками (Guard по-английски). Чаще всего охранник стережёт некий динамически выделяемый ресурс, который необходимо освободить по завершению его использования. Но это – не единственное его применение. Он пригодиться всегда, когда необходимо «не забыть» что-то сделать или вернуть на место по окончании некой сложной процедуры.
Приведу простой пример. Пусть у нас имеется некая процедура выполняющая относительно длительную (но не слишком) операцию. Правила проектирования пользовательского интерфейса Windows требуют, чтобы на время этой операции курсор превращался в песочные часы. Но по её завершении необходимо вернуть прежний курсор, независимо от того, как она завершилась. Мне приходилось встречать программы, в которых в подобной ситуации после возникновения ошибки курсор оставался в виде песочных часов. Как этого гарантированно избежать? Очень просто, использовать класс-охранник:
class SetCursorGuard { // упрощённая реализация
public: // methods
SetCursorGuard( unsigned short _idCursor=IDC_WAIT );
~SetCursorGuard( void );
private: // data
HCURSOR m_hDefaultCursor;
};
inline SetCursorGuard::SetCursorGuard( unsigned short _idCursor )
{
m_hDefaultCursor = ::SetCursor( ::LoadCursor(_idCursor) );
}
inline SetCursorGuard::~SetCursorGuard( void )
{
::SetCursor( m_hDefaultCursor );
}
Я умышленно привожу здесь предельно упрощённую реализацию. Класс, которым я пользуюсь на практике, несколько сложнее, поскольку для работы с курсором обычно недостаточно просто вызвать SetCursor
, надо также обрабатывать сообщение WM_SETCURSOR
, либо менять курсор, связанный с классом окна функцией SetClassLong. Кроме того, классу охраннику полезно иметь ещё пару методов, которые мы рассмотрим в следующем примере. Тем не менее, этот пример наглядно иллюстрирует идею использования таких классов. Вместо пары вызовов SetCursor в начале и в конце процедуры, нам достаточно создать один SetCursorGuard в начале и больше не вспоминать о нём.
Этот простой пример наглядно показывает, что деструктор может пригодиться не только для освобождения ресурсов, но и в иных ситуациях.
Рассмотрим другой пример, на этот раз чуть более детально. При работе Windows GDI постоянно используется функция SelectObject
. Её назначение – установить для данного графического контекста некий объект графического ядра, который далее будет использоваться в операциях рисования. Такими объектами являются растровые изображения (bitmap), кисти, шрифты и другие. При этом важное правило использования SelectObject
– по окончании рисования необходимо установить тот графический объект, что был установлен по умолчанию. Иначе, например, выбранный объект не может быть корректно удалён. Опытный программист сразу поймёт, что здесь необходим класс-охранник:
class SelectObjectGuard {
public: // methods
SelectObjectGuard( void );
SelectObjectGuard( HDC _hdc, HGDIOBJ _hGdiObject );
~SelectObjectGuard( void );
void Open( HDC _hdc, HGDIOBJ _hGdiObject );
void Close( void );
private: // data
HDC m_hdc;
HGDIOBJ m_hDefaultObject;
};
inline SelectObjectGuard::SelectObjectGuard( void ) :
m_hdc ( NULL ),
m_hDefaultObject ( NULL )
{}
inline SelectObjectGuard::SelectObjectGuard( HDC _hdc, HGDIOBJ _hGdiObject ) :
m_hdc ( NULL ),
m_hDefaultObject ( NULL )
{
Open( _hdc, _hGdiObject );
}
inline SelectObjectGuard::~SelectObjectGuard( void )
{
Close( void );
}
inline void SelectObjectGuard::Open( HDC _hdc, HGDIOBJ _hGdiObject )
{
ASSERT( !m_hdc );
m_hdc = _hdc;
m_hDefaultObject = ::SelectObject( _hdc, _hGdiObject );
}
inline void SelectObjectGuard::Close( void )
{
if( !m_hdc ) return;
::SelectObject( m_hdc, m_hDefaultObject );
m_hdc = NULL;
m_hDefaultObject = NULL;
}
В отличие от предыдущего, этот пример я реализовал более полно, почти без упрощений, используемых в целях обучения. Вы уже наверняка обратили внимание, и возможно даже удивились, что в этом классе присутствуют также методы Open
и Close
, которые фактически дублируют конструктор и деструктор. Зачем они нужны? Дело в том, что у конструктора есть одно в принципе полезное свойство. Он всегда вызывается одновременно с выделением памяти объекту. Увы, иногда это свойство превращается в недостаток. Рассмотрим пример. Пусть у нас есть процедура рисования, которая должна использовать SelectObject
следующим образом:
::SelectObject( hdc, hBrush ); // 1
if( someCondition > 0 ) {
::SelectObject( hdc, hFont ); // 2
}
// drawing code
// deselect GDI objects
Чтобы код корректно освобождал графические объекты, мы хотим использовать SelectObjectGuard
вместо SelectObject
. Но если с первым из них всё понятно, то со вторым возникает вопрос. Если поместить SelectObjectGuard
внутрь оператора if
, то его деструктор будет вызван сразу по его завершению. Нам же нужно, чтобы он был вызван по окончании процедуры. Это возможно только если переменная-охранник будет создана в рамках процедуры, а не оператора if
. Выход – разделить инициализацию переменной-охранника (выделение памяти для неё) и её активизацию (вызов SelectObject
):
SelectObjectGuard selectBrushGuard( hdc, hBrush );
SelectObjectGuard selectFontGuard;
if( someCondition > 0 ) {
selectFontGuard.Open( hdc, hFont );
}
// drawing code
Аналогично, в некоторых случаях возникает потребность закрыть объект прежде, чем это будет сделано автоматически по выходе из области видимости переменной. Поэтому, я всегда стараюсь в своих классах дублировать конструкторы и деструктор обычными методами. Чаще всего я даю им названия Open и Close. Но пожалуй, более подробно я расскажу об этом в специальной статье посвящённой конструкторам.
Кстати, по моему опыту как раз при использовании объектов-охранников ситуация наподобие приведённого примера возникает очень часто, поэтому я рекомендую при проектировании такого класса сразу предусмотреть метод Open дублирующий конструктор.
Что касается Close
, то без него в принципе можно было бы и обойтись. Дело в том, что в отличие от конструктора, деструктор можно вызвать явным образом. Я использую «для симметрии», а также потому, что из двух записей:
selectFontGuard.~SelectObjectGuard();
selectFontGuard.Close();
вторая, на мой взгляд, выглядит более понятно и естественно. Впрочем, возможно это уже дело вкуса.
Итак, классы-охранники – это довольно простой и очень эффективный инструмент. Он пригодиться во всех случаях, когда необходимо «не забыть» выполнить некое действие по завершению процедуры.
Разновидностью классов-охранников можно считать и популярные среди программистов C++ так называемые «умные указатели» (smart pointers). Про них уже написаны целые книги, поэтому здесь я лишь коротко остановлюсь на этой теме.
Чаще всего класс умного указателя устроен так, чтобы внешне его использование максимально походило на использование обычного указателя языка C. Для этого используются переопределение операторов, автоматическое приведение типов и другие «фичи» языка C++. Но по сравнению с «обычным» указателем, умный имеет некие дополнительные возможности. Одной из них почти всегда является функция охранника.
Приведу пример одной из простейших разновидностей умных указателей, так называемого ведущего указателя. Фактически единственной его дополнительной функцией как раз является удаление объекта на который он указывает, когда удаляется сам указатель. То есть, по сути, это и есть класс-охранник:
template<class TYPE>
class MasterPointer {
public: // methods
MasterPointer( void );
~MasterPointer( void );
// assign, not copy!
MasterPointer( TYPE* _pObject );
MasterPointer& operator = ( TYPE* _pObject );
void Init( TYPE* _pObject );
void InitNew( void );
void Reset( void );
// copy object
MasterPointer( const MasterPointer& _rPointer );
MasterPointer& operator = ( const MasterPointer& _rPointer );
TYPE* operator -> ( void );
const TYPE* operator -> ( void ) const;
operator TYPE* ( void );
operator const TYPE* ( void ) const;
bool operator ! ( void ) const;
TYPE* Extract( void );
private: // data
TYPE* m_pObject;
};
template<class TYPE>
MasterPointer<TYPE>::~MasterPointer( void )
{
if( m_pObject ) {
delete m_pObject;
}
}
Ввиду очевидности, я не привожу здесь реализацию большинства методов этого класса.
Другой популярной разновидностью умных указателей являются указатели на объекты имеющие счётчик ссылок, как например COM-интерфейс. В отличие от ведущего указателя, который, как предполагается, единолично владеет объектом, счётчик ссылок позволяет организовать совместное использование объекта. То есть можно создать несколько умных указателей на такой объект, и объект будет существовать, пока существует хотя бы один из них. Для этого объект и снабжается счётчиком ссылок. Каждый раз, когда создаётся новый указатель на него, счётчик ссылок увеличивается, а когда указатель удаляется, счётчик уменьшается. Если после этого он стал равен нулю, значит, объект больше никому не нужен и должен быть удалён. И здесь, как и в предыдущих примерах, возникает задача «не забыть» удалить ссылку и уменьшить значение счётчика, с чем идеально справляется деструктор умного указателя. Наоборот, попытка «вручную» управлять счётчиком ссылок неизбежно приводит к ошибкам, которые к тому же ловятся достаточно тяжело. Поэтому счётчик ссылок практически всегда используется совместно с умными указателями.
С другой стороны, только что описанный ведущий указатель обладает весьма важным преимуществом: он предельно прост и не требует никакой дополнительной поддержки со стороны объекта, на который он указывает. Поэтому, его можно использовать с любым классом без доработки последнего. Единственное его ограничение: в представленном примере предполагается, что объект обязан быть создан динамически (то есть оператором new). Хотя, даже это ограничение можно обойти.
Раз уж речь зашла об умных указателях, не могу не упомянуть, хотя бы вскользь, об одной проблеме, которой последнее время уделяется повышенное внимание, причём на мой взгляд, совершенно незаслуженно. Речь идёт о проблеме определения времени жизни объекта.
Что у нас есть в C++ для решения этой проблемы? Собственно именно это мы только что обсуждали: деструкторы и умные указатели. По моему опыту, аккуратная реализация деструкторов и использование простейшего ведущего указателя бывает достаточно, ну наверно в 2/3 случаев. В чуть более сложной ситуации пригодится счётчик ссылок и умный указатель для работы с ним. Тоже достаточно простой и эффективный инструмент. Готовые классы есть во всех популярных библиотеках (MFC/ATL, Qt, STL), но если они вас чем-то не устраивают, реализовать собственный не составит труда. Казалось бы, что ещё нужно?
И вот последнее время Internet заполнили обсуждения систем «сборщиков мусора». Причина очевидна – это агрессивная реклама языков Java и C#, в которой сборщик мусора преподносится буквально как откровение, чуть ли не как главная фишка этих языков. Очень кратко, суть сборщика мусора состоит в том, что этот алгоритм периодически перебирает все созданные объекты, проверяет их взаимосвязи друг с другом и на основании этого определяет, какие объекты уже не нужны и соответственно удаляет их. Спрашивается, зачем нужен такой тяжеловесный и заведомо неэффективный алгоритм, если эту задачу можно решить очень простыми, эффективными и хорошо зарекомендовавшими себя инструментами, о которых мы только что говорили?
Маркетологи на это заявляют следующее. Я что если у нас образуются кольцо из ссылок? Скажем, объект A хранит ссылку на объект B, тот на объект C, а последний в свою очередь на объект A. Таким образом, даже если на эту тройку объектов нет других ссылок извне, счётчик ссылок каждого будет равен единице. Получается, они будут как бы удерживать друг друга, мешая счётчикам ссылок определить, что они уже не нужны. И вот тут-то вас спасёт наше новейшее супер-мега-ещё-несколько-рекламных-слоганов изобретение, сборник мусора! Тут, по закону жанра, рекламщик делает многозначительную паузу.
В чём проблема такого подхода для нас, разработчиков? Всё дело в том, что если в вашей программе возможно образование закольцованных ссылок – это означает, что в ней творится полнейший бардак! И проблема утечек памяти из-за закольцованных ссылок будет лишь одной из множества проблем.
Программисту с такой системой работать крайне трудно, в ней тяжело разобраться, её тяжело модифицировать, тяжело искать баги… Именно про такие системы умудрённые опытом программисты любят рассказывать новичкам страшные истории за чашкой крепкого кофе!
Почему так происходит? Структура разумно спроектированной программной системы напоминает пирамиду, где классы верхнего уровня как бы опираются на классы более низкого. В этом случае закольцованных ссылок быть не может, в принципе!!! Нарушение пирамидальной структуры всегда связано с нарушением другого важнейшего принципа: инкапсуляции.
Например, часто встречающаяся картина: монструозно большущий класс, куда запихнуто куча разного функционала, причём всё переплетено так, что разобраться в этом очень тяжело. Требуется доработать что-то одно, при этом задеваешь что-то другое, и всё вообще рассыпается. Короче, сплошная головная боль! Надо ли говорить, что внутри и вокруг такого класса, как правило, целое сплетение ссылок на разные объекты.
Другой пример нарушения инкапсуляции, когда некая функциональность не заключена в один класс, а оказывается размазана по двум, а иногда даже больше, классам. Тоже ситуация ничем не лучше только что описанной! А что касается ссылок, то почти всегда все эти классы оказываются переплетены взаимными ссылками.
Как избежать такого головняка? Очень просто: аккуратно проектировать структуру классов, строго соблюдая принцип инкапсуляции. Чтобы один функционал всегда был заключён в одном классе, и наоборот, разные функционалы были разделены по своим классам. Тогда структура вашей программы будет напоминать стройную пирамиду, где все ссылки направлены строго от вышестоящих классов к нижестоящим. Тогда проблема закольцованных ссылок просто не сможет возникнуть. Но главное, с такой структурой программисту легко и удобно работать!
Часто можно услышать такую отмазку: нам де надо программировать, у нас тут дедлайн и злой начальник, нам некогда проектированием заниматься! Увы, это не более чем попытка оправдать свой непрофессионализм. Разумное проектирование структуры классов на этапе создания нового кода практически не требует дополнительного времени. Зато сэкономит уйму времени в дальнейшем, на этапе отладки и дальнейшего развития программы.
Но, боюсь, я слишком отклонился от темы нашей статьи. Тому, как строить программную систему, так чтобы она была изящной, простой и понятной я постараюсь посвятить отдельную статью. Для этого требуется всего лишь на всего строго придерживаться достаточно простых правил. И тогда разработка вашего проекта будет продвигаться намного успешней. Однако тема эта простая и сложная одновременно, так что требует отдельного разговора.
Но разве сборщик мусора, тем не менее, не может быть полезен, спросит настойчивый читатель? Всё-таки хоть одну проблему он решает! Я считаю крайне порочным сам подход, когда вместо того, чтобы ликвидировать корень проблемы, мы пытаемся вместо этого бороться лишь с последствиями, да ещё вдобавок привлекаем для этого сложную, громоздкую и заведомо неэффективную систему! Скажем прямо, на жаргоне программистов такое решение называется «вставить костыль».
К сожалению, в мозгах программистов, как впрочем и других «технарей», существует такой баг: когда мы встречаем некую проблему, мы в первую очередь пытаемся найти техническое решение. В данном случае, эффективное решение лежит не столько в технической области, сколько в «гуманитарной» (если можно так выразиться): в планировании, архитектуре, правильной организации работы… Но обо всём этом стоит поговорить отдельно!
Возвращаясь же к нашей теме, хочу подчеркнуть, чем мне нравится деструктор. Тем, что это очень простой и понятный инструмент. В отличие от сборщиков мусора, ему не требуется тащить за собой тяжеловесную run-time библиотеку. Но при всей своей простоте, он очень эффективен, значительно упрощает код и облегчает работу программиста.
Но ведь деструктор не универсален, – скажет всё тот же рекламщик, вот наш сборщик мусора… И здесь мы касаемся ещё одной проблемы проектирования программных систем: баланса универсальных и специальных решений. При обучении программированию нам буквально вдалбливают мысль, что чем универсальнее решение, тем лучше. Действительно, бывает очень неприятно, когда оказывается что стоящая перед вами задача совсем чуть-чуть выходит за рамки какого-то готового решения. Вот если бы его разработчик подумал о … Сталкиваться с такими ситуациями приходится. Но! Как показывает практика, чем универсальнее решение, тем оно сложнее. Но практике, более простое и удобное решение которые хорошо работает, скажем в 95% случаев, часто оказывается более ценным, чем более сложное и громоздкое, но покрывающее все 100%. Боюсь, что умение соблюдать этот баланс приходит только с опытом, и это тоже тема для отдельного разговора…
Что же касается «сборщиков мусора» – то причина их популярности лежит скорее в области маркетинга, нежели в технических достоинствах данной технологии.
В заключение, хочу напомнить пару тонких моментов, связанных с использованием деструкторов. Хотя они обычно описываться в учебниках, напомнить о них не помешает.
Сами деструкторы могут быть виртуальными функциями. Более того, виртуальный деструктор – это очень удобный и часто используемый инструмент. Но они обычно так подробно описываются в учебниках, что ещё раз повторять это я не вижу смысла.
Но вот вызывать из деструктора виртуальную функцию совершенно бессмысленно! А если ваш компилятор формирует код деструктора не совсем аккуратно, то и опасно. Дело в том, что если наш класс (скажем A) является базовым для некоторого другого класса (скажем B), то деструктор A будет вызван после того, как деструктор B уже отработал, и следовательно, B уже как бы не существует. Поэтому пытаться вызвать виртуальную функцию, переопределённую в производном классе B бессмысленно.
Что реально произойдёт, если мы попытаемся вытворить такое? Это зависит от компилятора. Если он строго следует стандарту, то после исполнения деструктора B, но перед деструктором A он должен поменять таблицу виртуальных функций так, чтобы она была такой, как если бы A был создан не как часть производного класса, а сам по себе. В таком случае при вызове виртуальной функции будет вызван тот её вариант, что изначально определён в классе A. Всё бы ничего, но если это чистая виртуальная функция, то будет выброшено исключение. Парадоксальность ситуации как раз и состоит в том, что таким образом можно вызвать чистую виртуальную функцию, хотя конечно же не нужно!
Ну а если компилятор «поленился» подменить таблицу виртуальных функций, будет вызвана реализация производного класса, который уже «уничтожен» деструктором. Ни к чему хорошему это тоже привести не может.
Основная задача деструктора – просто освободить ресурсы, используемые данным классом (например, освободить память, закрыть файлы и т.п.). При этом абсолютно никакой необходимости вызывать виртуальные функции просто не возникает. Но, в некоторых случаях, может возникнуть потребность доделать некую незавершённую операцию. Пример мы обсудим чуть ниже. Вот в таком случае можно ненароком вызвать виртуальную функцию. Честно скажу, встречается такое крайне редко, но знать об этой тонкости надо.
Ещё одно правило, которое повторяет каждый учебник: деструктор не должен выбрасывать исключений. Причина понятна: деструктор может вызываться из обработчика исключений. Это происходит для всех локальных объектов, созданных на стеке в функции, которая выбросила исключение и для всех функций, через которые оно «пролетает», пока не будет где-то перехвачено инструкцией catch. Если один из деструкторов выбросит исключение, это будет исключение во время обработки исключения. C runtime в этом случае просто убивает программу, и честно говоря, придумать что-то более разумное здесь сложно. Так что выброс исключения из деструктора совершенно недопустим!
Всё это хорошо известно. Но давайте поговорим, каким правилам стоит следовать при написании деструктора, чтобы с одной, стороны исключение из деструктора не вылетело, а с другой, возможные ошибки не были проигнорированы.
Вернемся к примеру из самого начала этой статьи с классом-оболочкой вокруг функции Win32 API для работы с файлами. Приведу его ещё раз.
inline File::~File( void )
{
Close( void );
}
void File::Close( void )
{
if( m_hFile != INVALID_HANDLE_VALUE ) {
VERIFY( ::CloseHandle( m_hFile ) );
m_hFile = INVALID_HANDLE_VALUE;
}
}
В этом, казалось бы, простом примере в несколько строк, есть несколько моментов далеко не очевидных для новичка, знаю по себе.
Возможность явно закрыть объект до того, как он будет уничтожен автоматически. Как я уже упоминал выше, лично я стараюсь всегда добавлять в класс метод, фактически дублирующий функции деструктора, в данном случае это Close. В принципе, поскольку деструктор может быть вызван явно, без специального метода можно обойтись, но мне кажется, так получается более наглядно. В общем, мой подход таков, но не настаиваю! Но как минимум возможность явно закрыть объект должна быть.
Следующий момент вытекает из предыдущего. Метод Close (или деструктор) может быть вызван несколько раз. Иначе говоря, если объект уже закрыт, повторный вызов Close не должен вызывать ошибку. Обычно, для этого достаточно вставить оператор if, проверяющий что объект не находится уже в «закрытом» или нейтральном состоянии. (О том, почему у любого класса, практически без исключений, обязательно должно быть такое нейтральное состояние, мы поговорим в статье посвящённой конструкторам).
И наконец, самое главное: как обрабатывать нештатные ситуации в деструкторе? Скажем, если некая функция Windows API вернула значение означающее ошибку. Обычно в таком случае мы выбрасываем исключение. Но в деструкторе это строго запрещено! Может быть, вообще, игнорировать ошибку?
Если дело касается функций освобождающих некие ресурсы, а как правило, деструкторы вызывают именно их, самое разумное использовать макросы ASSERT
или, как в нашем примере VERIFY
. (Об использовании этих макросов я уже рассказывал) В таком случае, отладочная сборка в случае ошибки выдаст специальное сообщение. А в релизной сборке ошибка, в самом деле, будет проигнорирована. Но разве это допустимо, игнорировать ошибки?
Давайте зададим себе вопрос, в каком случае в приведённом примере CloseHandle
может вернуть ошибку? Очевидно только в одном: ей был передан некорректный handle. Либо он уже был закрыт ранее, либо это оказалось вовсе какое-то совершенно левое число, но в любом случае это может означать лишь одно: где-то мы накосячили! В корректно написанной программе такого быть просто не может. Именно поэтому в релизной версии код возврата можно проигнорировать. Но в отладочной версии ASSERT
поможет нам быстрее заметить баг!
Но что делать, если деструктор не просто освобождает ресурсы, а выполняет более сложную работу?
Представим себе такой пример. Пусть наш класс осуществляет буферизацию неких данных перед записью на диск (или, к примеру, отправкой по сети). И в том и в другом случае буферизация может быть весьма полезна для производительности. Вместо того, чтобы писать данные маленькими кусочками, мы сохраняем их в неком буфере в памяти, а когда там накопится достаточный объём данных, мы записываем его за один раз. Очевидно, что в деструкторе мы должны проверить, все ли данные были сброшены на диск, и если нет, сделать это.
Как в таком случае обрабатывать возможные ошибки? Очевидно, что весь код, который, хотя бы чисто теоретически может выбросить исключение, необходимо заключить в конструкцию try-catch. Ведь то, что деструктор не должен выбрасывать исключения, не означают, что они не могут быть использованы внутри него. Главное, чтобы исключение случайно не вылетело наружу. То есть деструктор нашего класса будет выглядеть примерно так:
BufferingWriter::~BufferingWriter( void )
{
try {
FlushData();
}
catch( Exception& ex ) {
ReportException( ex );
}
Close();
}
Вместе с тем, если вам пришлось сделать класс, деструктор которого должен выполнить некую сложную работу, как в нашем примере, я настоятельно рекомендую вынести эту часть в отдельный метод, и вызывать его по окончании работы в явном виде, даже если это может сделать деструктор. То есть, процедура, использующая наш BufferingWriter, будет выглядеть примерно так:
{
BufferingWriter writer;
writer.Open( ... );
...
writer.Write( ... );
...
writer.Write( ... );
...
writer.FlushData();
}
Для чего это нужно? Подумаем, что произойдёт, если вся предыдущая работа прошла корректно, но именно во FlushData
случилось что-то непредвиденное, и она выбросила исключение. Мы предполагаем, что где-то выше в одной из функций, которая вызвала данный код, имеется конструкция try-catch, так что исключение в любом случае будет обработано (иначе наша программа написана некорректно). Пользователь в любом случае увидит сообщение об ошибке. Но! Если мы явно не вызываем FlushData
, и исключение произойдёт в его вызове из деструктора, то никуда дальше оно не попадёт и вызывающая функция будет уверена в том, что весь код отработал корректно, хотя это не так. А при явном вызове FlushData
, вызывающая функция сможет как-то отреагировать на произошедшее исключение в дополнение к тому, чтобы просто проинформировать пользователя. Как видим, это ещё одна причина, по которой имеет смысл дублировать деструктор обычным методом.
Подведём итог. Если функционал вашего класса требует, чтобы перед его удалением он провёл некую завершающую работу, более сложную, нежели освобождение ресурсов, оформите этот код в виде специального метода, подобно FlushData
в нашем примере. Вставьте его в деструктор, не забыв обернуть в try-catch. Это гарантирует, что завершающая операция будет гарантированно выполнена. Например, если в коде, работающим с нашим классом, произошло исключение. Но не пренебрегайте явным вызовом завершающего метода. Это гарантирует, что если в нём произойдёт исключение, вышестоящий код получит его и сможет отреагировать соответствующим образом.
Надеюсь, после прочтения этой части, у вас не сложилось превратное впечатление, что деструктор – это что-то сложное. На самом деле, деструктор – это, с одной стороны, очень простой, с другой – очень эффективный инструмент, значительно облегчающий работу программиста. Но как любой инструмент, он требует аккуратного использования.
Ещё раз повторюсь: мне всегда нравятся инструменты программиста, которые сочетают в себе простоту и эффективность, которые значительно облегчают работу и при этом не приводят к «утяжелению» программы. Несомненно, что деструктор – яркий пример такого инструмента. Не побоюсь назвать его гениальным изобретением!
Все права на данную статью принадлежат её автору, Курзенкову Алексею. Любое её копирование, полное или частичное, без согласования с автором, является нарушением авторских прав!