Что такое виртуальный деструктор
Зачем нужен виртуальный деструктор в С++
Виртуальные функции могут быть переопределены в классах наследниках, но как деструктор может быть виртуальным?
У каждой функции есть адрес, с которого в памяти располагаются ее команды. При вызове функции этот адрес помещается в регистр программного счетчика процессора, который всегда указывает на текущую выполняемую команду. Это справедливо как для обычных функций, так и для функций-членов классов. Однако вызов виртуальной функции немного отличается, т.к. выбор функции должен происходить в зависимости от динамического типа объекта. Рассмотрим пример:
Такой эффект достигается за счет использования таблицы виртуальных функций, которая есть в каждом объекте, содержащем хоть одну виртуальную функцию. В этой таблице хранятся адреса функций, которые помещаются в таблицу конструктором и используются при вызове.
В рассмотренном примере есть проблема, связанная с тем, что компилятор автоматически создаст в классе невиртуальный деструктор. В связи с этим, при разрушении объекта будет вызван деструктор, соответствующий статическому типу объекта — для обоих рабочих вызовется
Чтобы решить проблему достаточно сделать деструктор базового класса виртуальным — в этом случае при разрешении объекта правильная реализация деструктора будет выбираться из таблицы виртуальных функций, т.е. использоваться динамический тип объекта:
18.4 – Виртуальные деструкторы, виртуальное присваивание и игнорирование виртуализации
Однако на самом деле мы хотим, чтобы функция удаления вызывала деструктор Derived (который, в свою очередь, вызывает деструктор Base ), иначе m_array не будет удален. Мы можем сделать это, сделав деструктор Base виртуальным:
Теперь эта программа дает следующий результат:
Правило
Всякий раз, когда вы имеете дело с наследованием, вы должны сделать любые явные деструкторы виртуальными.
Как и в случае с обычными виртуальными функциями-членами, если функция базового класса является виртуальной, все производные переопределения будут считаться виртуальными независимо от того, указаны ли они как таковые. Нет необходимости создавать пустой деструктор производного класса только для того, чтобы пометить его как виртуальный.
Обратите внимание, что если вы хотите, чтобы в вашем базовом классе был виртуальный деструктор, который в противном случае был бы пустым, вы можете определить свой деструктор следующим образом:
Виртуальное присваивание
Оператор присваивания можно сделать виртуальным. Однако, в отличие от случая с деструктором, где виртуализация всегда является хорошей идеей, виртуализация оператора присваивания на самом деле открывает ящик Пандоры, и затрагивает некоторые сложные темы, выходящие за рамки данного руководства. Следовательно, мы собираемся порекомендовать вам пока оставить свои присваивания не виртуальными, для простоты.
Игнорирование виртуализации
Хотя и редко, но вы можете захотеть проигнорировать виртуализацию функции. Например, рассмотрим следующий код:
Вероятно, вы не будете использовать это очень часто, но хорошо знать, что это, по крайней мере, возможно.
Должны ли мы делать все деструкторы виртуальными?
Это частый вопрос, который задают начинающие программисты. Как отмечено в примере выше, если деструктор базового класса не помечен как виртуальный, то программа подвергается риску утечки памяти, если программист позже удалит указатель базового класса, указывающий на объект производного класса. Один из способов избежать этого – пометить все ваши деструкторы как виртуальные. Но должны ли вы это делать?
Легко сказать «да», и в дальнейшем вы сможете использовать любой класс в качестве базового, но это приведет к снижению производительности (к каждому экземпляру вашего класса добавляется виртуальный указатель). Таким образом, вы должны найти компромисс между этими затратами со своими намерениями.
Традиционная мудрость (как первоначально была высказана Хербом Саттером, уважаемым гуру C++) предлагала избегать ситуации утечки памяти, связанной с невиртуальным деструктором, следующим образом: «Деструктор базового класса должен быть либо открытым и виртуальным, либо защищенным и невиртуальным». Класс с защищенным деструктором нельзя удалить с помощью указателя, что предотвращает случайное удаление объекта производного класса с помощью указателя базового класса, когда базовый класс содержит невиртуальный деструктор. К сожалению, это также означает, что и базовый класс нельзя удалить с помощью указателя базового класса, что, по сути, означает, что объект этого класса не может быть динамически размещен или удален, кроме как производным классом. Это также исключает для таких классов использование умных указателей (таких как std::unique_ptr и std::shared_ptr ), что ограничивает полезность этого правила (мы рассмотрим умные указатели в следующей главе). Это также означает, что объект базового класса не может быть размещен в стеке. Это довольно жесткий набор ограничений.
Урок №165. Виртуальные деструкторы и Виртуальное присваивание
Обновл. 15 Сен 2021 |
Хотя язык C++ автоматически предоставляет деструкторы для ваших классов, если вы не предоставляете их самостоятельно, все же иногда вы можете сделать это сами.
Виртуальные деструкторы
При работе с наследованием ваши деструкторы всегда должны быть виртуальными. Рассмотрим следующий пример:
Parent ( ) // примечание: Деструктор не виртуальный
Child ( ) // примечание: Деструктор не виртуальный
Поскольку parent является указателем класса Parent, то при его уничтожении компилятор будет смотреть, является ли деструктор класса Parent виртуальным. Поскольку это не так, то компилятор вызовет только деструктор класса Parent.
Результат выполнения программы:
Тем не менее, нам нужно, чтобы delete вызывал деструктор класса Child (который, в свою очередь, будет вызывать деструктор класса Parent), иначе m_array не будет удален. Это можно выполнить, сделав деструктор класса Parent виртуальным:
Parent ( ) // примечание: Деструктор виртуальный
Child ( ) // примечание: Деструктор виртуальный
Результат выполнения программы:
Правило: При работе с наследованием ваши деструкторы должны быть виртуальными.
Виртуальное присваивание
Оператор присваивания можно сделать виртуальным. Однако, в отличие от деструктора, виртуальное присваивание не всегда является хорошей идеей. Почему? Это уже выходит за рамки этого урока. Следовательно, для сохранения простоты в вашем коде, не рекомендуется использовать виртуальное присваивание.
Игнорирование виртуальных функций
В языке С++ мы можем игнорировать вызов переопределений. Например:
Виртуальные функции и деструктор
Когда-то давным давно я собирался и даже обещал написать про механизм виртуальных функций относительно деструкторов. Теперь у меня наконец появилось свободное время и я решил воплотить эту затею в жизнь. На самом деле эта мини-статья служит «прологом» к моей следующей статье. Но я постарался изложить доходчиво и понятно основные моменты по текущей теме. Если вы чувствуете, что еще недостаточно разобрались в механизме виртуальных вызовов, то, возможно, вам следует для начала прочитать мою предыдущую статью.
Сразу же, как обычно, оговорюсь, что: 1) статья моя не претендует на полноту изложения материала; 2) мегапрограммеры ничего нового здесь не узнают; 3) материал не новый и давно описан во многих книгах, но если явно об этом не прочитать и самому специально не задумываться, то можно о некоторых моментах даже не подозревать (до поры, до времени). Также прошу прощения за надуманные примеры 🙂
Виртуальные деструкторы
Если вы уже знаете и умеете использовать виртуальные функции, то просто обязаны знать, когда и зачем нужны виртуальные деструкторы. Иначе нижеследующий текст был написан именно для вас.
Основное правило: если у вас в классе присутствует хотя бы одна виртуальная функция, деструктор также следует сделать виртуальным. При этом не следует забывать, что деструктор по умолчанию виртуальным не будует, поэтому следует объявить его явно. Если этого не сделать, у вас в программе почти наверняка будут утечки памяти (memory leaks). Чтобы понять почему, опять же много ума не надо. Рассмотрим несколько примеров.
В первом случае создадим объект производного класса в стеке:
using std :: cout ;
using std :: endl ;
Всем ясно, что вывод программы будет следующим:
потому что сначала конструируется базовая часть класса, затем производная, а при разрушении наоборот — сначала вызывается деструктор производного класса, который по окончании своей работы вызывает по цепочке деструктор базового. Это правильно и так должно быть.
Попробуем теперь создать тот же объект в динамической памяти, используя при этом указатель на объект базового класса (код классов не изменился, поэтому привожу только код функции main()):
int main ( )
<
A * pA = new B ;
delete pA ;
return EXIT_SUCCESS ;
>
На сей раз конструируется объект так, как и надо, а при разрушении происходит утечка памяти, потому как деструктор производного класса не вызывается:
Происходит это потому, что удаление производится через указатель на базовый класс и для вызова деструктора компилятор использует раннее связывание. Деструктор базового класса не может вызвать деструктор производного, потому что он о нем ничего не знает. В итоге часть памяти, выделенная под производный класс, безвозвратно теряется.
Чтобы этого избежать, деструктор в базовом классе должен быть объявлен как виртуальный:
using std :: cout ;
using std :: endl ;
int main ( )
<
A * pA = new B ;
delete pA ;
return EXIT_SUCCESS ;
>
Теперь-то мы получим желаемый порядок вызовов:
Происходит так потому, что отныне для вызова деструктора используется позднее связывание, то есть при разрушении объекта берется указатель на класс, затем из таблицы виртуальных функций определяется адрес нужного нам деструктора, а это деструктор производного класса, который после своей работы, как и полагается, вызывает деструктор базового. Итог: объект разрушен, память освобождена.
Виртуальные функции в деструкторах
Давайте для начала рассмотрим ситуацию с вызовом виртуальных функций внутри класса. Предположим, что у нас есть Кот, который просит покушать мяуканьем, а затем приступает к процессу 🙂 Так поступают многие коты, но не Чеширский! Чеширский, как известно, мало того что вечно улыбается, так еще и довольно разговорчив, поэтому мы научим его говорить, переопределив метод speak():
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? =) " ; >
> ;
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 мы видим то, что и хотели: механизм виртуальных функций работает исправно даже несмотря на то, что вызов непосредственно виртуального метода происходит внутри другого метода класса.
А теперь самое интересное: что будет, если попытаться воспользоваться этим в деструкторе? Модернизируем код так, чтобы при деструкции наши питомцы прощались, кто как умеет:
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 ; >
> ;
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() совершить виртуальный вызов метода sayGoodbye(), то фактически попытаемся обратиться к той части объекта, которая уже была разрушена.
Мораль: если в вашей голове витают помыслы выделить какой-то алгоритм «зачистки» в отдельный метод, переопределяемый в производных классах, а затем виртуально вызывать его в деструкторе, у вас ничего не выйдет.
Деструкторы (C++)
). Например, деструктор для класса String объявляется следующим образом:
Если деструктор не определен, компилятор будет предоставлять его по умолчанию. для многих классов это достаточно. Необходимо определить пользовательский деструктор, если класс хранит дескрипторы для системных ресурсов, которые необходимо освободить, или указатели, владеющие памятью, на которую они указывают.
Рассмотрим следующее объявление класса String :
В предыдущем примере деструктор String::
String использует delete оператор для освобождения пространства, динамически выделяемого для хранения текста.
Объявление деструкторов
Деструкторы — это функции с тем же именем, что и класс, но с добавленным в начало знаком тильды (
При объявлении деструкторов действуют несколько правил. Деструкторы:
Не могут иметь аргументов.
Не возвращают значение (или void ).
Использование деструкторов
Деструкторы вызываются, когда происходит одно из следующих событий:
Локальный (автоматический) объект с областью видимости блока выходит за пределы области видимости.
Время существования временного объекта заканчивается.
Программа заканчивается, глобальные или статические объекты продолжают существовать.
Деструктор явно вызываться с использованием полного имени функции деструктора.
Деструкторы могут свободно вызывать функции-члена класса и осуществлять доступ к данным членов класса.
Существуют два ограничения на использование деструкторов.
Вы не можете получить его адрес.
Производные классы не наследуют деструктор своего базового класса.
Порядок уничтожения
Когда объект выходит за пределы области или удаляется, последовательность событий при его полном уничтожении выглядит следующим образом:
Вызывается деструктор класса, и выполняется тело функции деструктора.
Деструкторы для объектов нестатических членов вызываются в порядке, обратном порядку их появления в объявлении класса. Необязательный список инициализации элементов, используемый при создании этих элементов, не влияет на порядок создания или уничтожения.
Деструкторы для невиртуальных базовых классов вызываются в обратную последовательность объявления.
Деструкторы для виртуальных базовых классов вызываются в порядке, обратном порядку их объявления.
Виртуальные базовые классы
Деструкторы для виртуальных базовых классов вызываются в порядке, обратном их указанию в направленном ациклическом графе (в глубину, слева направо, обход в обратном порядке). На следующем рисунке представлен граф наследования.
Граф наследования, показывающий виртуальные базовые классы
Ниже перечислены заголовки классов, представленных на рисунке.
Просмотрите левую часть графа, начиная с самой глубокой точки графа (в данном случае E ).
Просматривайте граф справа налево, пока не будут пройдены все узлы. Запомните имя текущего узла.
Пересмотрите предыдущий узел (вниз и вправо), чтобы определить, является ли рассматриваемый узел виртуальным базовым классом.
Если рассматриваемый узел является виртуальным базовым классом, просмотрите список, чтобы проверить, был ли он введен ранее. Если он не является виртуальным базовым классом, игнорируйте его.
Если рассматриваемого узла еще нет в списке, добавьте его вниз списка.
Просмотрите граф вверх и вдоль следующего пути вправо.
Перейдите к шагу 2.
Если путь последний путь вверх исчерпан, запомните имя текущего узла.
Перейдите к шагу 3.
Выполняйте этот процесс, пока нижний узел снова не станет текущим узлом.
Таким образом, для класса E порядок удаления будет следующим.
В ходе этого процесса создается упорядоченный список уникальных записей. Имя класса никогда не отображается дважды. После создания список просматривается в обратном порядке, и вызывается деструктор для каждого класса в списке от последнего к первому.
Порядок построения или удаления очень важен, когда конструкторы и деструкторы в одном классе полагаются на другой компонент, который создается первым или сохраняется дольше, например если деструктор A (на рисунке выше) полагается на то, что B будет по-прежнему присутствовать после выполнения кода, или наоборот.
Такие взаимозависимости между классами в графе наследования опасны, поскольку классы, наследуемые впоследствии, могут изменить крайний левый путь, тем самым изменив порядок построения и удаления.
Не являющиеся виртуальными базовыми классами
Деструкторы для невиртуальных базовых классов вызываются в порядке, в котором объявляются имена базовых классов. Рассмотрим следующее объявление класса.
Явные вызовы деструктора
Редко возникает необходимость в явном вызове деструктора. Однако может быть полезно выполнить удаление объектов, размещенных по абсолютным адресам. Обычно эти объекты выделяются с помощью определяемого пользователем new оператора, принимающего аргумент размещения. delete Оператор не может освободить эту память, так как она не выделена из бесплатного хранилища (Дополнительные сведения см. delete ). Вызов деструктора, однако, может выполнить соответствующую очистку. Для явного вызова деструктора для объекта ( s ) класса String воспользуйтесь одним из следующих операторов.
Нотация для явных вызовов деструкторов, показанная в предыдущем примере, может использоваться независимо от того, определяет ли тип деструктор. Это позволяет выполнять такие явные вызовы, не зная, определен ли деструктор для типа. Явный вызов деструктора, если ни один из них не определен, не имеет никакого эффекта.
Отказоустойчивость
Классу требуется деструктор, если он получает ресурс, и для безопасного управления ресурсом, вероятно, потребуется реализовать конструктор копии и назначение копирования.
Если эти специальные функции не определены пользователем, они неявно определяются компилятором. Неявно созданные конструкторы и операторы присваивания выполняют поверхностную почленном копию, которая почти наверняка неверно, если объект управляет ресурсом.
Явное определение деструктора, конструктора копирования или оператора присваивания копирования предотвращает неявное определение конструктора перемещения и оператора присваивания перемещения. В этом случае не удастся предоставить операции перемещения, если копирование занимает много ресурсов, но пропущенная возможность оптимизации.