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

Обновлено: 13.08.2022

Деструкторы используются для высвобождения любых ресурсов, выделенных объектом. Например, класс Lock может заблокировать семафор, и деструктор освободит этот семафор. Самый распространенный пример – когда конструктор использует new , а деструктор использует delete .

Деструкторы – это функция-член «подготовки к смерти». Часто их называют сокращенно «dtor».

В каком порядке разрушаются локальные объекты?

В порядке, обратном созданию: первым создан – последним разрушен.

В следующем примере сначала будет выполнен деструктор b , а затем деструктор a :

В каком порядке разрушаются объекты в массиве?

В порядке, обратном созданию: первым создан – последним разрушен.

В следующем примере порядок деструкторов будет a[9] , a[8] ,…, a[1] , a[0] :

В каком порядке разрушаются подобъекты объекта?

В порядке, обратном созданию: первым создан – последним разрушен.

Выполняется тело деструктора объекта, за которым следуют деструкторы членов данных объекта (в обратном порядке их появления в определении класса), за которыми следуют деструкторы базовых классов объекта (в обратном порядке их появления в определении класса).

В следующем примере порядок вызовов деструктора, когда d выходит за пределы области видимости, будет ~local1() , ~local0() , ~member1() , ~member0() , ~base1() , ~base0() :

Могу ли я перегрузить деструктор для своего класса?

У вас может быть только один деструктор для класса Fred . Он всегда называется Fred::~Fred() . Он никогда не принимает никаких параметров и ничего не возвращает.

В любом случае вы не можете передавать параметры деструктору, поскольку вы никогда явно его не вызываете (ну, почти никогда).

Должен ли я явно вызывать деструктор локальной переменной?

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

Что, если я хочу, чтобы локальный объект «умер» до закрытия > области, в которой он был создан? Могу ли я вызвать деструктор локального объекта, если действительно хочу это сделать?

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

Просто оберните время жизни локального объекта в искусственный блок <. >:

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

В большинстве случаев вы можете ограничить время жизни локального объекта, заключив его в искусственный блок ( <. >). Но если по какой-то причине вы не можете этого сделать, добавьте функцию-член, которая имеет тот же эффект, что и деструктор. Но не вызывайте сам деструктор!

Например, в случае класса File вы можете добавить метод close() . Обычно деструктор просто вызывает этот метод close() . Обратите внимание, что метод close() должен будет пометить объект File , чтобы последующий вызов не закрывал уже закрытый File . Например, он может установить член данных fileHandle_ на какое-то бессмысленное значение, например -1, и может вначале проверить, равен ли fileHandle_ уже -1:

Обратите внимание, что другим методам File также может потребоваться проверить, имеет ли fileHandle_ значение -1 (т.е. проверить, закрыт ли файл).

Также обратите внимание, что любые конструкторы, которые на самом деле не открывают файл, должны установить для fileHandle_ значение -1.

Но могу ли я явно вызвать деструктор, если я разместил свой объект с помощью new ?

Если вы не использовали размещение new , вам следует просто удалить объект, используя delete , а не явно вызывать деструктор. Например, предположим, что вы разместили объект с помощью типового выражения new :

Тогда деструктор Fred::~Fred() будет автоматически вызван, когда вы удалите его через:

Вы не должны явно вызывать деструктор, поскольку это не освобождает память, выделенную для самого объекта Fred . Помните: delete p выполняет две функции: вызывает деструктор и освобождает память.

Что такое «размещение new » и зачем его использовать?

Есть много применений размещения new . Самый простой способ – поместить объект в определенном месте в памяти. Это делается путем предоставления места в качестве параметра указателя части new выражения new :

СОВЕТ. Без необходимости не используйте синтаксис «размещения new ». Используйте его только тогда, когда вам действительно важно, чтобы объект был помещен в определенное место в памяти. Например, если ваше оборудование имеет устройство таймера ввода-вывода с отображением в памяти, и вы хотите разместить объект Clock в этом месте памяти.

ОПАСНОСТЬ. Вы несете исключительную ответственность за то, чтобы указатель, который вы передаете оператору «размещение new », указывал на область памяти, которая достаточно велика и правильно выровнена для типа объекта, который вы создаете. Ни компилятор, ни система времени выполнения не пытаются проверить, правильно ли вы это сделали. Если ваш класс Fred нужно выровнять по 4-байтовой границе, но вы указали местоположение, которое не выровнено должным образом, у вас может возникнуть серьезная катастрофа (если вы не знаете, что означает «выравнивание», пожалуйста, не используйте синтаксис размещения new ). Вы предупреждены.

Вы также несете полную ответственность за уничтожение размещенного объекта. Это делается явным вызовом деструктора:

Это примерно единственный раз, когда вы явно вызываете деструктор.

Существует ли размещение delete ?

Нет, но если оно вам необходимо, вы можете написать свое собственное.

Рассмотрим размещение new , используемое для размещения объектов в наборе арен:

Учитывая это, мы можем написать

Но как потом правильно удалить эти объекты? Причина отсутствия встроенного «размещения delete », соответствующего размещению new , заключается в том, что нет общего способа гарантировать, что оно будет использоваться правильно. Ничто в системе типов C++ не позволяет нам сделать вывод, что p1 указывает на объект, размещенный в Arena a1 . Указатель на любой X , размещенный где угодно, может быть назначен указателю p1 .

Однако иногда программист знает, способ есть:

Теперь мы можем написать:

Если Arena отслеживает, какие объекты она содержит, вы даже можете написать destroy() , чтобы защитить себя от ошибок.

Также возможно определить пары операторов operator new() и operator delete() для иерархии классов TC++PL(SE) 15.6. Смотрите также D&E 10.4 и TC++PL(SE) 19.4.5.

Когда я пишу деструктор, нужно ли мне явно вызывать деструкторы для моих объектов-членов?

Нет. Вам никогда не нужно явно вызывать деструктор (за исключением размещения new ).

Деструктор класса (вне зависимости от того, определили вы его явно или нет) автоматически вызывает деструкторы для объектов-членов. Они уничтожаются в порядке, обратном тому, в котором были указаны в объявлении класса.

Когда я пишу деструктор производного класса, нужно ли мне явно вызывать деструктор для моего базового класса?

Нет. Вам никогда не нужно явно вызывать деструктор (за исключением размещения new ).

Деструктор производного класса (вне зависимости от того, определили вы его явно или нет) автоматически вызывает деструкторы для подобъектов базового класса. Базовые классы уничтожаются после объектов-членов. В случае множественного наследования прямые базовые классы уничтожаются в порядке, обратном их появлению в списке наследования.

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

Должен ли мой деструктор генерировать исключение при обнаружении проблемы?

Осторожно. Подробности смотрите в ответе на этот вопрос (ссылка скоро появится).

Есть ли способ заставить new выделять память из определенной области памяти?

Да. Хорошей новостью является то, что эти «пулы памяти» полезны в ряде ситуаций. Плохая новость в том, что мне придется протащить вас через болото того, как это работает, прежде чем мы обсудим все применения. Но если вы не знаете о пулах памяти, возможно, стоит потратить время на изучение ответа на этот вопрос – вы можете узнать кое-что полезное!

Прежде всего, напомним, что распределитель памяти должен просто возвращать неинициализированные биты памяти; он не должен создавать «объекты». В частности, распределитель памяти не должен устанавливать виртуальный указатель или любую другую часть объекта, так как это задача конструктора, который запускается после распределителя памяти. Начав с простой функции выделения памяти, allocate() , вы должны использовать размещение new для создания объекта в этой памяти. Другими словами, следующий код эквивалентен new Foo() :

Предполагая, что вы использовали размещение new и выжили после двух приведенных выше строк кода, следующим шагом будет превращение вашего распределителя памяти в объект. Такие объекты называют «пулом памяти» или «ареной памяти». Это позволяет вашим пользователям иметь более одного «пула» или «арены», из которых будет выделяться память. Каждый из этих объектов пула памяти будет выделять большой кусок памяти, используя определенный системный вызов (например, разделяемую память, постоянную память, стековую память и т.д.; смотрите ниже), и при необходимости распределяет его небольшими порциями. Ваш класс пула памяти может выглядеть примерно так:

Теперь у одного из ваших пользователей может быть Pool с именем pool , из которого он может выделять объекты примерно так:

Превратить Pool в класс полезно потому, что он позволяет пользователям создавать N разных пулов памяти вместо того, чтобы иметь один массивный пул, используемый совместно всеми пользователями. Это позволяет пользователям делать много забавных вещей. Например, если у них есть кусок системы, который как сумасшедший выделяет память, а затем уходит; они могут выделить всю свою память из Pool , а затем даже не беспокоиться об удалении небольших кусков: можно просто освободить весь пул сразу. Или они могут создать область «совместно используемой (shared) памяти» (где операционная система специально предоставляет память, которая совместно используется несколькими процессами) и сделать так, чтобы пул выделял фрагменты общей памяти, а не локальную память процесса. Другой аспект: многие системы поддерживают нестандартную функцию, часто называемую alloca() , которая выделяет блок памяти из стека, а не из кучи. Естественно, этот блок памяти автоматически удаляется при возврате функции, устраняя необходимость в явном удалении. Кто-то может использовать alloca() , чтобы предоставить пулу большой кусок памяти, тогда все маленькие кусочки, выделенные из этого объекта Pool , будут действовать как локальные: они автоматически исчезают, когда происходит возврат из функции. Конечно, в некоторых из этих случаев деструкторы не вызываются, и если деструкторы делают что-то нетривиальное, вы не сможете использовать эти методы, но в случаях, когда деструктор просто освобождает память, такие методы могут быть полезны.

Предположим, что вы пережили 6 или 8 строк кода, необходимых для обертывания вашей функции выделения памяти как метода класса Pool , следующим шагом будет изменение синтаксиса для выделения памяти для объектов. Цель состоит в том, чтобы перейти от довольно неуклюжего синтаксиса new(pool.alloc(sizeof(Foo))) Foo() к более простому синтаксису new(pool) Foo() . Чтобы это произошло, вам нужно добавить следующие две строки кода чуть ниже определения вашего класса Pool :

Теперь, когда компилятор видит new(pool) Foo() , он вызывает вышеуказанный оператор new и передает sizeof(Foo) и pool в качестве параметров, и единственная функция, которая в конечном итоге использует метод pool.alloc(nbytes) , – это ваш собственный оператор new .

Теперь к вопросу о том, как разрушить/высвободить объекты Foo . Напомним, что метод грубой силы, который иногда используется при размещении new , заключается в явном вызове деструктора, а затем явном освобождении памяти:

Здесь есть несколько проблем, и все они решаемы:

  1. Если Foo::Foo() вызовет исключение, произойдет утечка памяти.
  2. Синтаксис уничтожения/освобождения отличается от того, к чему привыкло большинство программистов, поэтому они, вероятно, всё испортят.
  3. Пользователи должны каким-то образом запоминать, какой пул с каким объектом связан. Поскольку код, который выделяет память, часто находится в другой функции, чем код, который освобождает выделенную памяти, программистам придется передавать два указателя ( Foo* и Pool* ), что быстро становится ужасным (например, что, если бы у них был массив объектов Foo , все из которых потенциально пришли из разных объектов Pool ).

Мы исправим эти проблемы в этом же порядке.

Проблема №1: устранение утечки памяти. Когда вы используете «обычный» оператор new , например, Foo* p = new Foo() , компилятор генерирует специальный код для обработки случая, когда конструктор генерирует исключение. Реальный код, сгенерированный компилятором, функционально похож на этот:

Дело в том, что компилятор освобождает память, если конструктор генерирует исключение. Но в случае синтаксиса « new с параметром» (обычно называемого «размещением new ») компилятор не знает, что делать, если возникает исключение, поэтому по умолчанию он ничего не делает:

Итак, цель состоит в том, чтобы заставить компилятор делать что-то похожее на то, что он делает с глобальным оператором new . К счастью, это просто: когда компилятор видит new(pool) Foo() , он ищет соответствующий оператор delete . Если он его находит, он выполняет эквивалент обертывания вызова конструктора в блок try , как показано выше. Поэтому мы просто предоставим оператор delete со следующей сигнатурой (будьте осторожны, чтобы сделать всё правильно; если второй параметр имеет тип, отличный от второго параметра operator new(size_t, Pool&) , компилятор не пожалуется; он просто обойдет блок try , когда ваши пользователи скажут new(pool) Foo() ):

После этого компилятор автоматически обернет вызовы конструкторов ваших выражений new в блок try :

Другими словами, однострочная функция operator delete(void* p, Pool& pool) заставляет компилятор автоматически закрывать утечку памяти. Конечно, эта функция может быть, но не обязательно, встраиваемой.

Проблемы №2 («уродливо, поэтому подвержено ошибкам») и №3 («пользователи должны вручную связывать указатели пула с объектом, который выделил для них память, что подвержено ошибкам») решаются одновременно с помощью дополнительных 10-20 строк кода в одном место. Другими словами, мы добавляем 10-20 строк кода в одном месте (заголовочный файл Pool ) и упрощаем сколь угодно большое количество других мест (каждый фрагмент кода, который использует ваш класс Pool ).

Идея состоит в том, чтобы неявно связывать Pool* с каждым выделением. Pool* , связанный с глобальным распределителем, будет иметь значение NULL , но, по крайней мере, концептуально можно сказать, что каждое выделение памяти имеет связанный Pool* . Затем вы заменяете глобальный оператор delete , чтобы он находил связанный Pool* , и, если тот не равен NULL , вызывал функцию освобождения объекта Pool . Например, если (!) обычный деаллокатор использует free() , замена глобального оператора delete будет выглядеть примерно так:

Если вы не уверены, был ли обычный деаллокатор функцией free() , самый простой способ – также заменить глобальный оператор new чем-то, что использует malloc() . Замена глобального оператора new будет выглядеть примерно так (обратите внимание: это определение игнорирует некоторые детали, такие как цикл new_handler и выкидывание исключения throw std::bad_alloc() , которое происходит, если у нас заканчивается память):

Единственная оставшаяся проблема – связать Pool* с выделением памяти. Один из подходов, используемых, по крайней мере, в одном коммерческом продукте, заключается в использовании std::map . Другими словами, создайте таблицу поиска, ключи которой – это указатели выделения памяти, а значения – связанные Pool* . По причинам, которые я опишу чуть позже, важно, чтобы вы вставляли пару ключ/значение в карту только в операторе new(size_t, Pool&) . В частности, вы не должны вставлять пару ключ/значение из глобального оператора new (например, вы не должны указывать poolMap[p] = NULL в глобальном операторе new ). Причина: это создало бы неприятную проблему с курицей и яйцом – поскольку std::map , вероятно, использует глобальный оператор new , он заканчивает тем, что вставляет новую запись каждый раз, когда вставляет новую запись, что приводит к бесконечной рекурсии – бах, вы мертвы.

Несмотря на то, что этот метод требует поиска в std::map для каждого освобождения памяти, кажется, что он имеет приемлемую производительность, по крайней мере, во многих случаях.

Естественно, последние несколько абзацев этого FAQ применимы только в том случае, если вам разрешено изменять глобальные operator new и operator delete . Если вам не разрешено изменять эти глобальные функции, действуют первые три четверти ответа на этот вопрос.

Вызов виртуальных функций в конструкторах (C++)


В разных языках программирования поведение виртуальных функций отличается, когда речь заходит о конструкторах и деструкторах. Неправильное использование виртуальных функций – это классическая ошибка при разработке на языке С++, которую мы разберём в этой статье.

Теория

Предполагаю, что читатель уже знаком с виртуальными функциями в языке C++, поэтому сразу перейду к сути. Когда в конструкторе вызывается виртуальная функция, она работает только в пределах базовых или создаваемого в данный момент классов. Конструкторы в классах-наследниках ещё не вызывались, и поэтому реализованные в них виртуальные функции не будут вызваны.

Для начала поясню это рисунком.

Вызов виртуальных функций в конструкторах (C++)

  • От класса A наследуется класс B;
  • От класса B наследуется класс C;
  • Функции foo и bar являются виртуальными;
  • У функции foo нет реализации в классе B.

Создадим объект класса C и рассмотрим, что произойдёт, если мы вызовем эти две функции в конструкторе класса B.

  • Функция foo. Класс C ещё не создан, а в классе B нет функции foo. Поэтому будет вызвана реализация функции из класса A.
  • Функция bar. Класс C ещё не создан. Поэтому вызывается функция, относящаяся к текущему классу B.

Теперь продемонстрирую то же самое кодом.

Если скомпилировать и запустить этот код, то он распечатает:

При вызове виртуальных методов в деструкторах всё работает точно так же.

Казалось бы, в чём проблема? Всё это описано в книжках по программированию на языке С++.

Проблема в том, что про это легко забыть! И считать, что функции foo и bar будут вызваны из крайнего наследника, т.е. из класса C.

Вопрос "Почему код работает неожиданным образом?" вновь и вновь поднимается на форумах. Пример: Calling virtual functions inside constructors.

Если её запустить, то будет распечатано:

Соответствующая визуальная схема:

Вызывается функция в наследнике из конструктора базового класса!

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

Стоит отметить, что такое поведение тоже может быть чревато ошибками. Например, проблемы могут возникнуть, если виртуальный метод работает с членами производного типа, которые ещё не были проинициализированы в его конструкторе.

При попытке создания экземпляра типа Derived возникнет исключение типа NullReferenceException, даже если в качестве аргумента в конструктор передаётся значение, отличное от null: new Derived("Hello there").

При исполнении тела конструктора типа Base будет вызвана реализация метода Test из типа Derived. Этот метод обращается к свойству MyStr, которое в текущий момент проинициализировано значением по умолчанию (null), а не параметром, переданным в конструктор (myStr).

С теорией разобрались. Теперь расскажу, почему я вообще решил написать эту статью.

Как появилась статья

Всё началось с вопроса "Scan-Build for clang-13 not showing errors" на сайте StackOverflow. Хотя вернее будет сказать, что всё началось с обсуждения статьи "О том, как мы с сочувствием смотрим на вопрос на StackOverflow, но молчим".

Можете не переходить по ссылкам. Я сейчас кратко перескажу суть истории.

Человек спросил, как с помощью статического анализа искать ошибки двух видов. Первая ошибка касается переменных типа bool, и сейчас нам не интересна. Вторая часть вопроса как раз касалась поиска вызовов виртуальных функций в конструкторе и деструкторе.

Если удалить всё, не относящееся к теме, то задача состоит в выявлении вызовов виртуальных функций в этом коде:

И вдруг выяснилось, что не все понимают, чем опасен такой код и почему статические анализаторы кода предупреждают о вызове виртуальных методов в конструкторах/деструкторах.

Опасный код

К публикации на сайте habr появились комментарии (RU) следующего вида:

Сокращенный комментарий N1. Так что компилятор прав, ошибки нет. Ошибка только в логике программиста, его пример кода всегда будет возвращать единицу в первом случае. И он мог бы даже использовать inline для того, чтобы ускорить работу и кода конструктора, и деструктора. Но компилятору это все равно не имеет значение, либо результат функции нигде не используется, функция не задействует никакие внешние аргументы — компилятор просто выкинет пример в качестве оптимизации. И это логичный правильный поступок. Как итог, ошибки просто нет.

Сокращенный комментарий N2. Про виртуальные функции вообще вашего юмора не понял. [цитата из книги про виртуальные функции]. Автор подчеркивает, что ключевое слово virtual используется только один раз. Далее в книге разъясняется, что оно наследуется. А теперь студенты ответьте мне на вопрос: "Где вы увидели проблему вызова виртуальной функции в конструкторе и деструкторе класса? Ответ дать по отдельности для каждого случая". Подразумевая, что вы оба, как неприлежные студенты, не разбираетесь в вопросе, когда вызываются конструктор и деструктор класса. И в добавок совершенно упустили тему "В каком порядке определяются объекты родительских классов при определение предка, и в каком порядке они уничтожаются".

Возможно, прочитав эти комментарии вы недоумеваете, как всё это относится к рассмотренной ранее теме. Правильно, что недоумеваете. Ответ: никак не относится.

Тот, кто оставлял комментарии, просто не догадывается, от какой проблемы хочет защититься человек, задавший вопрос на StackOverflow.

Да, стоит признать, что вопрос можно было бы сформулировать лучше. Собственно, как таковой проблемы в приведённом коде действительно нет. Пока нет. Она появится в дальнейшем, когда у классов появятся новые наследники, реализующие функцию GetAge, которые что-то делают. Если бы в примере присутствовал ещё один класс, наследующий P, то вопрос стал бы более полным.

Однако любой, кто хорошо знает язык C++, сразу понимает, какая проблема обсуждается и почему человек хочет искать вызовы функций.

Запрет на вызов виртуальных функций в конструкторах/деструкторах нашёл своё отражение и в стандартах кодирования. Например в SEI CERT C++ Coding Standard есть правило: OOP50-CPP. Do not invoke virtual functions from constructors or destructors. Это диагностическое правило реализуют многие анализаторы кода, такие как Parasoft C/C++test, Polyspace Bug Finder, PRQA QA-C++, SonarQube C/C++ Plugin. В их число входит и разрабатываемый нашей командой PVS-Studio (диагностика V1053).

А если ошибки нет?

Мы не рассмотрели ситуацию, что никакой ошибки нет. Другими словами, всё работает ровно так, как задумывалось. В этом случае можно явно указать, какие функции мы планируем вызывать:

Такой код будет однозначно правильно понят вашими коллегами. Статические анализаторы в свою очередь тоже всё поймут и промолчат.

Заключение

Цените статический анализ кода. Он поможет выявить потенциальные проблемы в коде, причём такие, о которых вы и ваши коллеги могут даже не догадываться. Несколько примеров:

    . The 'Foo' function should not be called from 'DllMain' function. . Pointer is cast to a more strictly aligned pointer type. . Potentially unsafe double-checked locking.

Работа виртуальных функций, конечно, не такое тайное знание, как примеры по ссылкам :). Однако, как показывают комментарии и вопросы на StackOverflow, эта тема заслуживает внимания и контроля. Было бы всё очевидно – не было бы этой статьи. Хорошо, что анализаторы кода способны подстраховать программиста в его работе.

Спасибо за внимание, и приходите попробовать анализатор PVS-Studio.

Если хотите поделиться этой статьей с англоязычной аудиторией, то прошу использовать ссылку на перевод: Andrey Karpov. Virtual function calls in constructors and destructors (C++).

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

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

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

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

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

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

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

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

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

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

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

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

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

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
virtual void eat ( ) const < cout
> ;

class CheshireCat : public Cat
<
public :
virtual void speak ( ) const < cout
> ;

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

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

Рассмотрим код более подробно. Есть класс 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
virtual void eat ( ) const < cout
virtual void sayGoodbye ( ) const < cout
> ;

class CheshireCat : public Cat
<
public :
virtual void speak ( ) const < cout
virtual void sayGoodbye ( ) const < cout
> ;

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++

Итак, пожалуй начнем с конструкторов. Тут все очень просто — виртуальных конструкторов (а также похожих на них конструкторов) в C++ не существует. Просто потому что не бывает и всё тут (конкретно: это запрещено стандартом языка).

Вы, наверно, спросите: «а зачем такое вообще может понадобится?». На самом деле разговор про «виртуальный конструктор» немного некорректны. Конструктор не может быть виртуальным в смысле виртуальных функций, т.к. чтобы была виртуальность необходимо в конструкторе (а больше и негде особо) настроить указатель на ТВМ (таблицу виртуальных функций) для создаваемого объекта.

Замечание: обычно виртуальность реализуется через ТВМ и указатель на нее в объекте. Подробнее вы можете прочесть об этом тут

Так вот, иногда «виртуальным конструктором» называют механизм создания объект любого заранее неизвестного класса. Это может пригодится, например, при копировании массива объектов, унаследованных от общего предка (при этом нам бы очень хотелось чтобы вызывался конструктор копирования именно нужного нам класса, конечно же). В C++ для подобного, обычно, используют виртуальную функцию вроде virtual void assign (const object &o) , или подобную, однако, это не является повсеместным, и возможны другие реализации.

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

А вот деструктор, напротив может быть виртуальным. И даже более того — это часто встречается.
Обычным является использование вирт деструктора в классах, имеющих вирт функции. Более того, gcc, например, выдаст вам предупреждение, если вы не сделаете виртуальным деструктор, объявив виртуальную функцию.

Часто можно встретить миф: «вирт деструктор нужен лишь в том случае, когда на деструктор классов-потомков возлагаются какие-то нестандартные функции, если деструктор потомка не отличается по функционалу от родителя, то делать его виртуальным нет особого смысла». Это может и будет работать «сейчас», но может сыграть злую шутку в будущем, да и в общем-то не очень верно. Если деструктор не виртуальный, то будет вызван деструктор того типа, какой заявлен в указателе. В тоже время будет правильнее что для объектов потомков должны вызываться свои деструкторы. Просто стоит принять это как правило, иначе в будущем могут быть очень большие проблемы с отладкой непонятно почему текучих в плане памяти программ.

Другой миф: чисто виртуальных деструкторов не бывает. Ещё как бывают.

* This source code was highlighted with Source Code Highlighter .


Существует миф, что данный класс является абстрактным. И это верно.
Также распространено заблуждение, что налсденики этого класса будут полиморфными. Это неверно — деструкторы не наследуются.
Существут миф, что наследкника этого класса создать нельзя. Можно, вот пример:

class Sample public :
virtual ~Sample()=0<> //обратите особое внимание сюда, так писать по стандарту нельзя, но MS VC проглотит
>;

class DSample: public Sample

* This source code was highlighted with Source Code Highlighter .

Для вменяемых компиляторов класс Sample нужно писать так:

Sample::~Sample() >

* This source code was highlighted with Source Code Highlighter .

Сразу же замечание про нормальность компилятора, и заодно миф: по стандарту определять чисто вирт. функцию внутри определения класса нельзя. Но определенные корпорации говорят «если мы видим возможность улучшить стандарт, то мы незадумываясь делаем это».

Вы ниразу не видели чисто виртуальных деструкторов с определенными «телами»? Так вот, миф что они несуществуют, также неверен. Также определять можно и другие чисто виртуальные функции.

Почему надо писать именно с определением деструктора? Ответ на самом деле прост: из налсденика DSample в его деструкторе ~DSample будет вызываться деструктор ~Sample, и поэтому его необходимо определить, иначе у вас это даже не будет компилироваться.

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

Замечания об устройстве указаталей на функцию-член

Казалось бы данная часть не имеет отношения к виртуальности. Если вы так думаете, то сильно заблуждаетесь. Вообще говоря указатели на функцию член в C++ используются не так часто. Отчасти это связано с мутными (как мне кажется) главами в стандарте языка, отчасти, потому что их реализация выливается в полный кошмар для программистов компиляторов. Мне не известно ни одного компилятора, который мог бы работать с этими «штуками» полностью по стандарту.

Заключение

Вот, наверно, и все что я хотел бы рассказать вам в дополнение. Конечно, часть вопросов, связанных с виртуальностью все еще требуют открытий, оставляю это на вашей совести 8)

Удачи в программировании.

P.S. перенести бы в CPP блог надо бы… думаю там оно будет более востребовано.

Хотя C++ предоставляет для ваших классов деструктор по умолчанию, если вы не предоставляете его самостоятельно, иногда бывает так, что вам нужно предоставить свой собственный деструктор (особенно, если классу необходимо освободить память). Если вы имеете дело с наследованием, то всегда должны делать свои деструкторы виртуальными. Рассмотрим следующий пример:

Примечание. Если вы попытаетесь скомпилировать приведенный выше пример, ваш компилятор может предупредить вас о не виртуальном деструкторе (что для этого примера сделано специально). Для выполнения компиляции может потребоваться отключить флаг компилятора, который рассматривает предупреждения как ошибки.

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

Однако на самом деле мы хотим, чтобы функция удаления вызывала деструктор Derived (который, в свою очередь, вызывает деструктор Base ), иначе m_array не будет удален. Мы можем сделать это, сделав деструктор Base виртуальным:

Теперь эта программа дает следующий результат:

Правило

Всякий раз, когда вы имеете дело с наследованием, вы должны сделать любые явные деструкторы виртуальными.

Как и в случае с обычными виртуальными функциями-членами, если функция базового класса является виртуальной, все производные переопределения будут считаться виртуальными независимо от того, указаны ли они как таковые. Нет необходимости создавать пустой деструктор производного класса только для того, чтобы пометить его как виртуальный.

Обратите внимание, что если вы хотите, чтобы в вашем базовом классе был виртуальный деструктор, который в противном случае был бы пустым, вы можете определить свой деструктор следующим образом:

Виртуальное присваивание

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

Игнорирование виртуализации

Хотя и редко, но вы можете захотеть проигнорировать виртуализацию функции. Например, рассмотрим следующий код:

Могут быть случаи, когда вы хотите, чтобы указатель Base на объект Derived вызывал Base::getName() вместо Derived::getName() . Для этого просто используйте оператор разрешения области видимости:

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

Должны ли мы делать все деструкторы виртуальными?

Это частый вопрос, который задают начинающие программисты. Как отмечено в примере выше, если деструктор базового класса не помечен как виртуальный, то программа подвергается риску утечки памяти, если программист позже удалит указатель базового класса, указывающий на объект производного класса. Один из способов избежать этого – пометить все ваши деструкторы как виртуальные. Но должны ли вы это делать?

Легко сказать «да», и в дальнейшем вы сможете использовать любой класс в качестве базового, но это приведет к снижению производительности (к каждому экземпляру вашего класса добавляется виртуальный указатель). Таким образом, вы должны найти компромисс между этими затратами со своими намерениями.

Традиционная мудрость (как первоначально была высказана Хербом Саттером, уважаемым гуру C++) предлагала избегать ситуации утечки памяти, связанной с невиртуальным деструктором, следующим образом: «Деструктор базового класса должен быть либо открытым и виртуальным, либо защищенным и невиртуальным». Класс с защищенным деструктором нельзя удалить с помощью указателя, что предотвращает случайное удаление объекта производного класса с помощью указателя базового класса, когда базовый класс содержит невиртуальный деструктор. К сожалению, это также означает, что и базовый класс нельзя удалить с помощью указателя базового класса, что, по сути, означает, что объект этого класса не может быть динамически размещен или удален, кроме как производным классом. Это также исключает для таких классов использование умных указателей (таких как std::unique_ptr и std::shared_ptr ), что ограничивает полезность этого правила (мы рассмотрим умные указатели в следующей главе). Это также означает, что объект базового класса не может быть размещен в стеке. Это довольно жесткий набор ограничений.

Теперь, когда в язык был введен спецификатор final , наши рекомендации заключаются в следующем:

Читайте также: