Виртуальные функции и деструктор

Posted: понедельник, 13 июля 2009 г.
Когда-то давным давно я собирался написать про механизм виртуальных функций относительно деструкторов. Теперь у меня наконец появилось свободное время и я решил воплотить эту затею в жизнь. На самом деле эта мини-статья служит «прологом» к моей следующей статье. Но я постарался изложить доходчиво и понятно основные моменты по текущей теме. Если вы чувствуете, что еще недостаточно разобрались в механизме виртуальных вызовов, то, возможно, вам следует для начала прочитать мою предыдущую статью.

Сразу же, как обычно, оговорюсь, что: 1) статья моя не претендует на полноту изложения материала; 2) мегапрограммеры ничего нового здесь не узнают; 3) материал не новый и давно описан во многих книгах, но если явно об этом не прочитать и самому специально не задумываться, то можно о некоторых моментах даже не подозревать (до поры, до времени). Также прошу прощения за надуманные примеры :)

Виртуальные деструкторы


Если вы уже знаете и умеете использовать виртуальные функции, то просто обязаны знать, когда и зачем нужны виртуальные деструкторы. Иначе нижеследующий текст был написан именно для вас.

Основное правило: если у вас в классе присутствует хотя бы одна виртуальная функция, деструктор также следует сделать виртуальным. При этом не следует забывать, что деструктор по умолчанию виртуальным не будует, поэтому следует объявить его явно. Если этого не сделать, у вас в программе почти наверняка будут утечки памяти (memory leaks). Чтобы понять почему, опять же много ума не надо. Рассмотрим несколько примеров.

В первом случае создадим объект производного класса в стеке:

#include <cstdlib>
#include <iostream>

using std::cout;
using std::endl;

class A {
public:
A() { cout << "A()" << endl; }
~A() { cout << "~A()" << endl; }
};

class B : public A {
public:
B() { cout << "B()" << endl; }
~B() { cout << "~B()" << endl; }
};

int main()
{
B b;
return EXIT_SUCCESS;
}


Всем ясно, что вывод программы будет следующим:

A()
B()
~B()
~A()

потому что сначала конструируется базовая часть класса, затем производная, а при разрушении наоборот — сначала вызывается деструктор производного класса, который по окончании своей работы вызывает по цепочке деструктор базового. Это правильно и так должно быть.

Попробуем теперь создать тот же объект в динамической памяти, используя при этом указатель на объект базового класса (код классов не изменился, поэтому привожу только код функции main()):

int main()
{
A * pA = new B;
delete pA;
return EXIT_SUCCESS;
}


На сей раз конструируется объект так, как и надо, а при разрушении происходит утечка памяти, потому как деструктор производного класса не вызывается:

A()
B()
~A()


Происходит это потому, что удаление производится через указатель на базовый класс и для вызова деструктора компилятор использует раннее связывание. Деструктор базового класса не может вызвать деструктор производного, потому что он о нем ничего не знает. В итоге часть памяти, выделенная под производный класс, безвозвратно теряется.

Чтобы этого избежать, деструктор в базовом классе должен быть объявлен как виртуальный:

#include <cstdlib>
#include <iostream>

using std::cout;
using std::endl;

class A {
public:
A() { cout << "A()" << endl; }
virtual ~A() { cout << "~A()" << endl; }
};

class B : public A {
public:
B() { cout << "B()" << endl; }
~B() { cout << "~B()" << endl; }
};

int main()
{
A * pA = new B;
delete pA;
return EXIT_SUCCESS;
}


Теперь-то мы получим желаемый порядок вызовов:

A()
B()
~B()
~A()


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


Виртуальные функции в деструкторах


Давайте для начала рассмотрим ситуацию с вызовом виртуальных функций внутри класса. Предположим, что у нас есть Кот, который просит покушать мяуканьем, а затем приступает к процессу :) Так поступают многие коты, но не Чеширский! Чеширский, как известно, мало того что вечно улыбается, так еще и довольно разговорчив, поэтому мы научим его говорить, переопределив метод speak():

#include <cstdlib>
#include <iostream>

using std::cout; 
using std::endl;

class Cat
{
public:
void askForFood() const
{
speak();
eat();
}
virtual void speak() const { cout << "Meow! "; }
virtual void eat() const
{
cout << "*champing*" << endl;
}
};

class CheshireCat : public Cat
{
public:
virtual void speak() const
{
cout << "WTF?! Where\'s my milk? =) ";
}
};

int main()
{
Cat * cats[] = { new Cat, new CheshireCat };

cout << "Ordinary Cat: "; cats[0]->askForFood();
cout << "Cheshire Cat: "; cats[1]->askForFood();

delete cats[0]; delete cats[1];
return EXIT_SUCCESS;
}


Вывод этой программы будет следующим:

Ordinary Cat: Meow! *champing*
Cheshire Cat: WTF?! Where's my milk? =) *champing*


Рассмотрим код более подробно. Есть класс Cat с парой виртуальных методов, один из которых переопределен в производном CheshireCat. Но всё самое интересное происходит в методе askForFood() класса Cat.

Как видно, метод всего лишь содержит вызовы двух других методов, однако конструкция speak() в данном контексте эквивалента this->speak(), то есть вызов происходит через указатель, а значит — будет использовано позднее связывание. Вот почему при вызове метода askForFood() через указатель на CheshireCat мы видим то, что и хотели: механизм виртуальных функций работает исправно даже несмотря на то, что вызов непосредственно виртуального метода происходит внутри другого метода класса.

А теперь самое интересное: что будет, если попытаться воспользоваться этим в деструкторе? Модернизируем код так, чтобы при деструкции наши питомцы прощались, кто как умеет:

#include <cstdlib>
#include <iostream>

using std::cout; 
using std::endl;

class Cat
{
public:
virtual ~Cat() { sayGoodbye(); }
void askForFood() const
{
speak();
eat();
}
virtual void speak() const { cout << "Meow! "; }
virtual void eat() const
{
cout << "*champing*" << endl;
}
virtual void sayGoodbye() const
{
cout << "Meow-meow!" << endl;
}
};

class CheshireCat : public Cat
{
public:
virtual void speak() const
{
cout << "WTF?! Where\'s my milk? =) ";
}
virtual void sayGoodbye() const
{
cout << "Bye-bye! (:" << endl;
}
};

int main()
{
Cat * cats[] = { new Cat, new CheshireCat };

cout << "Ordinary Cat: "; cats[0]->askForFood();
cout << "Cheshire Cat: "; cats[1]->askForFood();

delete cats[0]; delete cats[1];
return EXIT_SUCCESS;
}


Можно ожидать, что, как и в случае с вызовом метода speak(), будет выполнено позднее связывание, однако это не так:

Ordinary Cat: Meow! *champing*
Cheshire Cat: WTF?! Where's my milk? =) *champing*
Meow-meow!
Meow-meow!


Почему? Да потому что при вызове виртуальных методов из деструктора компилятор использует не позднее, а раннее связывание. Если подумать, зачем он делает именно так, всё становится очевидным: нужно просто рассмотреть порядок конструирования и разрушения объектов. Все помнят, что конструирование объекта происходит, начиная с базового класса, а разрушение идет в строго обратном порядке. Таким образом, когда мы создаем объект типа CheshireCat, порядок вызовов конструкторов/деструкторов будет таким:

Cat()
CheshireCat()
~CheshireCat()
~Cat()


Если же мы захотим внутри деструктора ~Cat() совершить виртуальный вызов метода sayGoodbye(), то фактически попытаемся обратиться к той части объекта, которая уже была разрушена.

Мораль: если в вашей голове витают помыслы выделить какой-то алгоритм «зачистки» в отдельный метод, переопределяемый в производных классах, а затем виртуально вызывать его в деструкторе, у вас ничего не выйдет.
blog comments powered by Disqus